diff --git a/packages/design-system/src/components/OcAvatars/OcAvatars.vue b/packages/design-system/src/components/OcAvatars/OcAvatars.vue
index 01a74527992..9a7f220eb4b 100644
--- a/packages/design-system/src/components/OcAvatars/OcAvatars.vue
+++ b/packages/design-system/src/components/OcAvatars/OcAvatars.vue
@@ -55,7 +55,7 @@ import { getTailwindGapClass } from '../../helpers/tailwind'
type Item = {
displayName?: string
name?: string
- avatarType?: 'user' | 'link' | 'remote' | 'group' | 'guest' | string
+ avatarType?: 'user' | 'link' | 'remote' | 'group' | 'mail' | string
userName?: string
avatar?: string
userId?: string
@@ -187,7 +187,7 @@ const getAvatarComponentForItem = (item: Item) => {
return OcAvatarFederated
case 'group':
return OcAvatarGroup
- case 'guest':
+ case 'mail':
return OcAvatarGuest
}
}
diff --git a/packages/design-system/src/utils/logger.ts b/packages/design-system/src/utils/logger.ts
deleted file mode 100644
index e8544e731c8..00000000000
--- a/packages/design-system/src/utils/logger.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-const __logger = (v: unknown) => v
-
-export default __logger
diff --git a/packages/design-system/src/utils/shareType.ts b/packages/design-system/src/utils/shareType.ts
deleted file mode 100644
index 6f567c36749..00000000000
--- a/packages/design-system/src/utils/shareType.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/**
- * Share type
- */
-export const shareType = {
- user: 0,
- group: 1,
- link: 3,
- guest: 4,
- remote: 6
-}
diff --git a/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.vue b/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.vue
index 9062e5859ee..1a433339d95 100644
--- a/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.vue
+++ b/packages/web-app-files/src/components/SideBar/Shares/Collaborators/InviteCollaborator/InviteCollaboratorForm.vue
@@ -156,6 +156,7 @@
diff --git a/packages/web-runtime/src/pages/resolveGuestLink.vue b/packages/web-runtime/src/pages/resolveGuestLink.vue
new file mode 100644
index 00000000000..2269f4e572b
--- /dev/null
+++ b/packages/web-runtime/src/pages/resolveGuestLink.vue
@@ -0,0 +1,91 @@
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
diff --git a/packages/web-runtime/src/router/helpers.ts b/packages/web-runtime/src/router/helpers.ts
index 73a66beccb5..d4532978a57 100644
--- a/packages/web-runtime/src/router/helpers.ts
+++ b/packages/web-runtime/src/router/helpers.ts
@@ -2,6 +2,7 @@ import { RouteLocation, RouteParams, Router, RouteRecordNormalized } from 'vue-r
import {
AuthContext,
authContextValues,
+ AuthStore,
contextQueryToFileContextProps,
queryItemAsString,
WebRouteMeta
@@ -120,6 +121,19 @@ export const isAnonymousContext = (router: Router, to: RouteLocation): boolean =
return getRouteMeta(to).authContext === 'anonymous'
}
+/**
+ * A guest lands on ordinary share routes, which declare `authContext: 'user'`. Until it is
+ * decided whether a guest rides the user context or gets one of its own
+ * (https://github.com/opencloud-eu/web/issues/2825), an established guest session stands in for
+ * a signed-in user towards the auth guard - and nowhere else.
+ *
+ * @param authStore {AuthStore}
+ * @returns {boolean}
+ */
+export const isGuestContextSufficient = (authStore: AuthStore): boolean => {
+ return authStore.guestContextReady
+}
+
/**
* The contextRoute in URLs is used to give applications additional context where the application route was triggered from
* (e.g. from a project space, a public link file listing, a personal space, etc).
diff --git a/packages/web-runtime/src/router/index.ts b/packages/web-runtime/src/router/index.ts
index 11fa593d674..76b3f9563e5 100644
--- a/packages/web-runtime/src/router/index.ts
+++ b/packages/web-runtime/src/router/index.ts
@@ -5,6 +5,8 @@ import NotFoundPage from '../pages/notFound.vue'
import OidcCallbackPage from '../pages/oidcCallback.vue'
import ResolvePublicLinkPage from '../pages/resolvePublicLink.vue'
import ResolvePrivateLinkPage from '../pages/resolvePrivateLink.vue'
+import ResolveGuestLinkPage from '../pages/resolveGuestLink.vue'
+import GuestSessionExpiredPage from '../pages/guestSessionExpired.vue'
import { setupRouterHooks } from './setupRouter'
import { setupAuthGuard } from './setupAuthGuard'
import { patchRouter } from './patchCleanPath'
@@ -96,6 +98,18 @@ const routes: readonly RouteRecordRaw[] = [
component: ResolvePublicLinkPage,
meta: { title: $gettext('OCM link'), authContext: 'anonymous' }
},
+ {
+ path: '/g/:token',
+ name: routeNames.resolveGuestLink,
+ component: ResolveGuestLinkPage,
+ meta: { title: $gettext('Guest invitation'), authContext: 'anonymous' }
+ },
+ {
+ path: '/guest-session-expired',
+ name: routeNames.guestSessionExpired,
+ component: GuestSessionExpiredPage,
+ meta: { title: $gettext('Guest session expired'), authContext: 'anonymous' }
+ },
{
path: '/access-denied',
name: routeNames.accessDenied,
diff --git a/packages/web-runtime/src/router/names.ts b/packages/web-runtime/src/router/names.ts
index 9129edb18ce..85bad18bffe 100644
--- a/packages/web-runtime/src/router/names.ts
+++ b/packages/web-runtime/src/router/names.ts
@@ -5,6 +5,8 @@ export const routeNames = {
resolvePrivateLink: 'resolvePrivateLink',
resolvePublicLink: 'resolvePublicLink',
resolvePublicOcmLink: 'resolvePublicOcmLink',
+ resolveGuestLink: 'resolveGuestLink',
+ guestSessionExpired: 'guestSessionExpired',
accessDenied: 'accessDenied',
account: 'account',
notFound: 'notFound'
diff --git a/packages/web-runtime/src/router/setupAuthGuard.ts b/packages/web-runtime/src/router/setupAuthGuard.ts
index 92e9ab20983..80944b9576a 100644
--- a/packages/web-runtime/src/router/setupAuthGuard.ts
+++ b/packages/web-runtime/src/router/setupAuthGuard.ts
@@ -1,9 +1,10 @@
import {
extractPublicLinkToken,
+ isGuestContextSufficient,
isIdpContextRequired,
isPublicLinkContextRequired,
isUserContextRequired
-} from './index'
+} from './helpers'
import { Router, RouteLocation } from 'vue-router'
import {
contextRouteNameKey,
@@ -33,6 +34,12 @@ export const setupAuthGuard = (router: Router) => {
return to.name === 'accessDenied' || { name: 'accessDenied' }
}
+ // same indirection as above: the guest session manager can't push a route from inside this
+ // guard, so it raises a flag and the redirect happens here
+ if (authService.guestSessionExpired) {
+ return to.name === 'guestSessionExpired' || { name: 'guestSessionExpired' }
+ }
+
if (isPublicLinkContextRequired(router, to)) {
if (!authStore.publicLinkContextReady) {
const publicLinkToken = extractPublicLinkToken(to)
@@ -46,7 +53,7 @@ export const setupAuthGuard = (router: Router) => {
}
if (isUserContextRequired(router, to)) {
- if (!authStore.userContextReady) {
+ if (!authStore.userContextReady && !isGuestContextSufficient(authStore)) {
if (unref(isDelegatingAuthentication)) {
return { path: '/web-oidc-callback' }
}
diff --git a/packages/web-runtime/src/services/auth/authService.ts b/packages/web-runtime/src/services/auth/authService.ts
index d6ea81c6227..669898bbd9a 100644
--- a/packages/web-runtime/src/services/auth/authService.ts
+++ b/packages/web-runtime/src/services/auth/authService.ts
@@ -1,5 +1,6 @@
import { UserManager } from './userManager'
import { PublicLinkManager } from './publicLinkManager'
+import { GuestSessionManager } from './guestSessionManager'
import {
AuthStore,
ClientService,
@@ -7,7 +8,10 @@ import {
CapabilityStore,
ConfigStore,
useTokenTimerWorker,
- AuthServiceInterface
+ AuthServiceInterface,
+ GuestSession,
+ MessageStore,
+ SpacesStore
} from '@opencloud-eu/web-pkg'
import { RouteLocation, Router } from 'vue-router'
import {
@@ -41,12 +45,15 @@ export class AuthService implements AuthServiceInterface {
private router: Router
private userManager: UserManager
private publicLinkManager: PublicLinkManager
+ private guestSessionManager: GuestSessionManager
private ability: Ability
private language: Language
private userStore: UserStore
private authStore: AuthStore
private capabilityStore: CapabilityStore
private webWorkersStore: WebWorkersStore
+ private spacesStore: SpacesStore
+ private messagesStore: MessageStore
private tokenTimerWorker: ReturnType
private tokenTimerInitialized = false
@@ -66,7 +73,9 @@ export class AuthService implements AuthServiceInterface {
userStore: UserStore,
authStore: AuthStore,
capabilityStore: CapabilityStore,
- webWorkersStore: WebWorkersStore
+ webWorkersStore: WebWorkersStore,
+ spacesStore: SpacesStore,
+ messagesStore: MessageStore
): void {
this.configStore = configStore
this.clientService = clientService
@@ -78,6 +87,21 @@ export class AuthService implements AuthServiceInterface {
this.authStore = authStore
this.capabilityStore = capabilityStore
this.webWorkersStore = webWorkersStore
+ this.spacesStore = spacesStore
+ this.messagesStore = messagesStore
+
+ this.guestSessionManager = new GuestSessionManager({
+ clientService,
+ configStore,
+ authStore,
+ spacesStore,
+ messagesStore,
+ router
+ })
+ }
+
+ public get guestSessionExpired(): boolean {
+ return this.guestSessionManager?.sessionExpired === true
}
/**
@@ -110,6 +134,11 @@ export class AuthService implements AuthServiceInterface {
this.publicLinkManager.clearContext()
}
+ // Unlike the public link context, the guest context is session-scoped rather than
+ // route-scoped: a guest lands on ordinary share routes that carry no marker to derive it
+ // from, so there is nothing to clear here. Restoring is cheap and idempotent.
+ this.guestSessionManager.restoreContext()
+
if (!this.userManager) {
this.userManager = new UserManager({
clientService: this.clientService,
@@ -301,6 +330,13 @@ export class AuthService implements AuthServiceInterface {
}
public async handleAuthError(route: RouteLocation) {
+ // Must come first: with a guest session satisfying user-context routes, the user branch
+ // below would find no OIDC user, call `removeUser('authError')` and bounce the guest to
+ // the access denied page instead of the guest session expired page.
+ if (this.authStore.guestContextReady && !this.authStore.userContextReady) {
+ await this.guestSessionManager.handleSessionExpired()
+ return this.router.push({ name: 'guestSessionExpired' })
+ }
if (isPublicLinkContextRequired(this.router, route)) {
const token = extractPublicLinkToken(route)
this.publicLinkManager.clear(token)
@@ -391,6 +427,14 @@ export class AuthService implements AuthServiceInterface {
this.publicLinkManager.updateContext(token)
}
+ public resolveGuestLink(token: string): Promise {
+ return this.guestSessionManager.verifyToken(token)
+ }
+
+ public verifyGuestPin(pin: string): Promise {
+ return this.guestSessionManager.verifyPin(pin)
+ }
+
public async logoutUser() {
const endSessionEndpoint = await this.userManager.metadataService?.getEndSessionEndpoint()
if (!endSessionEndpoint) {
diff --git a/packages/web-runtime/src/services/auth/guestAuth.ts b/packages/web-runtime/src/services/auth/guestAuth.ts
new file mode 100644
index 00000000000..c4a94c8b1b5
--- /dev/null
+++ b/packages/web-runtime/src/services/auth/guestAuth.ts
@@ -0,0 +1,81 @@
+import { z } from 'zod'
+import { AxiosError } from 'axios'
+import { GraphSharePermission, urlJoin } from '@opencloud-eu/web-client'
+import { GuestSession } from '@opencloud-eu/web-pkg'
+
+const basePath = 'magic_guest_link_auth'
+
+export const guestAuthEndpoints = {
+ verifyToken: 'verify/token',
+ verifyPin: 'verify/pin',
+ renew: 'renew'
+} as const
+
+export function guestAuthUrl(serverUrl: string, endpoint: string): string {
+ return urlJoin(serverUrl, basePath, endpoint)
+}
+
+export const guestSessionResponseSchema = z.object({
+ share_id: z.string().min(1),
+ share_name: z.string().optional(),
+ permissions: z.array(z.string()).optional(),
+ // rejected rather than allowed through as NaN, which would look like a session that expired
+ // the instant it was granted
+ expires_at: z.string().refine((value) => !Number.isNaN(new Date(value).getTime()))
+})
+
+export type GuestSessionResponse = z.infer
+
+export const guestAuthErrorTypes = ['token_expired', 'session_expired'] as const
+export type GuestAuthErrorType = (typeof guestAuthErrorTypes)[number]
+
+const guestAuthErrorSchema = z.object({
+ error_type: z.enum(guestAuthErrorTypes),
+ share_id: z.string().optional()
+})
+
+/**
+ * Carries only what the UI is allowed to act on. The server's `message` is deliberately
+ * dropped: the resolve page must not surface anything about an invitation it hasn't
+ * successfully authenticated.
+ */
+export class GuestAuthError extends Error {
+ public readonly errorType: GuestAuthErrorType
+ public readonly shareId: string
+ public readonly statusCode: number
+
+ constructor({
+ errorType,
+ shareId,
+ statusCode
+ }: {
+ errorType?: GuestAuthErrorType
+ shareId?: string
+ statusCode?: number
+ }) {
+ super('guest authentication failed')
+ this.errorType = errorType
+ this.shareId = shareId
+ this.statusCode = statusCode
+ }
+}
+
+export function toGuestAuthError(error: unknown): GuestAuthError {
+ const response = (error as AxiosError)?.response
+ const parsed = guestAuthErrorSchema.safeParse(response?.data)
+
+ return new GuestAuthError({
+ statusCode: response?.status,
+ errorType: parsed.success ? parsed.data.error_type : undefined,
+ shareId: parsed.success ? parsed.data.share_id : undefined
+ })
+}
+
+export function toGuestSession(data: GuestSessionResponse): GuestSession {
+ return {
+ shareId: data.share_id,
+ shareName: data.share_name || 'share',
+ permissions: (data.permissions || []) as GraphSharePermission[],
+ expiresAt: new Date(data.expires_at).getTime()
+ }
+}
diff --git a/packages/web-runtime/src/services/auth/guestSessionManager.ts b/packages/web-runtime/src/services/auth/guestSessionManager.ts
new file mode 100644
index 00000000000..93a8ca3cfe6
--- /dev/null
+++ b/packages/web-runtime/src/services/auth/guestSessionManager.ts
@@ -0,0 +1,245 @@
+import { z } from 'zod'
+import { Router } from 'vue-router'
+import {
+ $gettext,
+ AuthStore,
+ ClientService,
+ ConfigStore,
+ GuestSession,
+ MessageStore,
+ SpacesStore
+} from '@opencloud-eu/web-pkg'
+import { GraphSharePermission } from '@opencloud-eu/web-client'
+import {
+ GuestAuthError,
+ guestAuthEndpoints,
+ guestAuthUrl,
+ guestSessionResponseSchema,
+ toGuestAuthError,
+ toGuestSession
+} from './guestAuth'
+
+const storageKey = 'oc.guestSession'
+const expiryWarningLeadTime = 15 * 60 * 1000
+
+const persistedGuestSessionSchema = z.object({
+ shareId: z.string(),
+ shareName: z.string(),
+ permissions: z.array(z.string()),
+ expiresAt: z.number()
+})
+
+export interface GuestSessionManagerOptions {
+ clientService: ClientService
+ configStore: ConfigStore
+ authStore: AuthStore
+ spacesStore: SpacesStore
+ messagesStore: MessageStore
+ router: Router
+}
+
+export class GuestSessionManager {
+ private clientService: ClientService
+ private configStore: ConfigStore
+ private authStore: AuthStore
+ private spacesStore: SpacesStore
+ private messagesStore: MessageStore
+ private router: Router
+
+ private warningTimer: ReturnType
+ private expiryTimer: ReturnType
+ private expiryPromise: Promise | null = null
+
+ /**
+ * Set once the session died. The router guard reads it and redirects, rather than this
+ * class pushing a route from inside `beforeEach` - see the same pattern for
+ * `hasAuthErrorOccurred` in the auth guard.
+ */
+ public sessionExpired = false
+
+ constructor(options: GuestSessionManagerOptions) {
+ this.clientService = options.clientService
+ this.configStore = options.configStore
+ this.authStore = options.authStore
+ this.spacesStore = options.spacesStore
+ this.messagesStore = options.messagesStore
+ this.router = options.router
+ }
+
+ public async verifyToken(token: string): Promise {
+ try {
+ const session = await this.exchange(guestAuthEndpoints.verifyToken, { token })
+ this.applySession(session)
+ return session
+ } catch (error) {
+ // The link itself was recognised but is spent or aged out. Send a fresh link plus PIN so
+ // the caller can route to the expired page, which offers the PIN form.
+ if (error instanceof GuestAuthError && error.errorType === 'token_expired') {
+ await this.handleSessionExpired(error.shareId)
+ }
+ throw error
+ }
+ }
+
+ public async verifyPin(pin: string): Promise {
+ const session = await this.exchange(guestAuthEndpoints.verifyPin, {
+ pin,
+ share_id: this.authStore.guestShareId
+ })
+ this.applySession(session)
+ return session
+ }
+
+ public async renew(shareId: string): Promise {
+ if (!shareId) {
+ return false
+ }
+ try {
+ await this.post(guestAuthEndpoints.renew, { share_id: shareId })
+ return true
+ } catch (error) {
+ console.error('guest session renewal failed', error)
+ return false
+ }
+ }
+
+ /**
+ * Restores a persisted session on page load and on every navigation. Cheap and idempotent:
+ * the auth guard calls into this for each route change.
+ */
+ public restoreContext(): void {
+ const session = this.read()
+ if (!session) {
+ return
+ }
+ // A real user session owns the browser. A guest record left over from an earlier visit must
+ // not shadow it, or that user's next 401 would be mistaken for an expired guest session.
+ if (this.authStore.userContextReady) {
+ this.clear()
+ return
+ }
+ if (session.expiresAt <= Date.now()) {
+ void this.handleSessionExpired(session.shareId)
+ return
+ }
+ if (this.authStore.guestContextReady && this.authStore.guestShareId === session.shareId) {
+ return
+ }
+ this.applySession(session)
+ }
+
+ /**
+ * Idempotent: parallel 401s from a single expired session must result in exactly one
+ * renewal email. Callers outside the router guard navigate to the expired page themselves.
+ */
+ public handleSessionExpired(shareId = this.authStore.guestShareId): Promise {
+ if (this.expiryPromise) {
+ return this.expiryPromise
+ }
+
+ this.expiryPromise = (async () => {
+ this.cancelTimers()
+ localStorage.removeItem(storageKey)
+ this.authStore.invalidateGuestSession()
+ this.authStore.setGuestShareId(shareId)
+ this.sessionExpired = true
+ await this.renew(shareId)
+ })()
+
+ return this.expiryPromise
+ }
+
+ public clear(): void {
+ this.cancelTimers()
+ this.expiryPromise = null
+ this.sessionExpired = false
+ localStorage.removeItem(storageKey)
+ this.authStore.clearGuestContext()
+ }
+
+ private applySession(session: GuestSession): void {
+ localStorage.setItem(storageKey, JSON.stringify(session))
+ this.authStore.setGuestContext(session)
+
+ this.spacesStore.createShareSpace({
+ driveAliasPrefix: 'share',
+ id: session.shareId,
+ shareName: session.shareName,
+ graphPermissions: session.permissions
+ })
+ // The vault guard blocks every navigation carrying a `driveAliasAndItem` until the spaces
+ // store reports itself initialized, and only the user and public link contexts ever set
+ // that flag. Without this a guest navigation never settles.
+ this.spacesStore.setSpacesInitialized(true)
+
+ this.expiryPromise = null
+ this.sessionExpired = false
+ this.armTimers(session.expiresAt)
+ }
+
+ private armTimers(expiresAt: number): void {
+ this.cancelTimers()
+
+ const warningDelay = expiresAt - expiryWarningLeadTime - Date.now()
+ if (warningDelay > 0) {
+ this.warningTimer = setTimeout(() => {
+ this.messagesStore.showMessage({
+ title: $gettext('Your guest session expires soon'),
+ desc: $gettext('Save any unsaved changes now.'),
+ status: 'warning',
+ timeout: 0
+ })
+ }, warningDelay)
+ }
+
+ // Covers the guest who is idle when the session dies: without a request in flight
+ // nothing else would ever notice.
+ this.expiryTimer = setTimeout(
+ async () => {
+ await this.handleSessionExpired()
+ await this.router.push({ name: 'guestSessionExpired' })
+ },
+ Math.max(expiresAt - Date.now(), 0)
+ )
+ }
+
+ private cancelTimers(): void {
+ clearTimeout(this.warningTimer)
+ clearTimeout(this.expiryTimer)
+ this.warningTimer = undefined
+ this.expiryTimer = undefined
+ }
+
+ private read(): GuestSession | undefined {
+ const raw = localStorage.getItem(storageKey)
+ if (!raw) {
+ return undefined
+ }
+ try {
+ const parsed = persistedGuestSessionSchema.parse(JSON.parse(raw))
+ return { ...parsed, permissions: parsed.permissions as GraphSharePermission[] }
+ } catch {
+ localStorage.removeItem(storageKey)
+ return undefined
+ }
+ }
+
+ private async exchange(endpoint: string, body: Record): Promise {
+ try {
+ const { data } = await this.post(endpoint, body)
+ return toGuestSession(guestSessionResponseSchema.parse(data))
+ } catch (error) {
+ throw toGuestAuthError(error)
+ }
+ }
+
+ private post(endpoint: string, body: Record) {
+ // `withCredentials` is what lets the browser store and return the session cookie when web
+ // is served from a different origin than the server. It's a no-op same-origin.
+ return this.clientService.httpUnAuthenticated.post(
+ guestAuthUrl(this.configStore.serverUrl, endpoint),
+ body,
+ { withCredentials: true }
+ )
+ }
+}
diff --git a/packages/web-runtime/tests/unit/composables/layout/useLayout.spec.ts b/packages/web-runtime/tests/unit/composables/layout/useLayout.spec.ts
new file mode 100644
index 00000000000..921c6a6e5de
--- /dev/null
+++ b/packages/web-runtime/tests/unit/composables/layout/useLayout.spec.ts
@@ -0,0 +1,28 @@
+import { ref, unref } from 'vue'
+import { Router } from 'vue-router'
+import { useLayout } from '../../../../src/composables/layout/useLayout'
+
+describe('useLayout', () => {
+ it.each([
+ ['login', 'bare'],
+ ['oidcCallback', 'bare'],
+ ['logout', 'plain'],
+ ['resolvePublicLink', 'plain'],
+ ['resolveGuestLink', 'plain'],
+ ['guestSessionExpired', 'plain'],
+ ['accessDenied', 'plain'],
+ ['files-spaces-generic', 'application']
+ ])('renders the %s route in the %s layout', (name, expected) => {
+ const router = { currentRoute: ref({ name }) } as unknown as Router
+ const { layoutType } = useLayout({ router })
+
+ expect(unref(layoutType)).toEqual(expected)
+ })
+
+ it('falls back to the bare layout before a route is resolved', () => {
+ const router = { currentRoute: ref({ name: undefined }) } as unknown as Router
+ const { layoutType } = useLayout({ router })
+
+ expect(unref(layoutType)).toEqual('bare')
+ })
+})
diff --git a/packages/web-runtime/tests/unit/pages/guestSessionExpired.spec.ts b/packages/web-runtime/tests/unit/pages/guestSessionExpired.spec.ts
new file mode 100644
index 00000000000..ecf262b67a9
--- /dev/null
+++ b/packages/web-runtime/tests/unit/pages/guestSessionExpired.spec.ts
@@ -0,0 +1,129 @@
+import { defineComponent, unref, useTemplateRef } from 'vue'
+import { mockDeep } from 'vitest-mock-extended'
+import { flushPromises } from '@vue/test-utils'
+import { ClientService, GuestSession } from '@opencloud-eu/web-pkg'
+import { Resource, ShareSpaceResource } from '@opencloud-eu/web-client'
+import { defaultComponentMocks, defaultPlugins, shallowMount } from '@opencloud-eu/web-test-helpers'
+import GuestSessionExpired from '../../../src/pages/guestSessionExpired.vue'
+import { authService } from '../../../src/services/auth'
+
+vi.mock('../../../src/services/auth')
+
+// the auto generated stub would drop the focus() the page calls on mount
+const OcTextInputStub = defineComponent({
+ name: 'OcTextInput',
+ setup(_, { expose }) {
+ const input = useTemplateRef('input')
+ expose({ focus: () => unref(input).focus() })
+ return {}
+ },
+ template: ''
+})
+
+const selectors = {
+ form: 'form',
+ submitButton: '.oc-login-authorize-button'
+}
+
+const guestSession: GuestSession = {
+ shareId: 'share-id',
+ shareName: 'Invited folder',
+ permissions: [],
+ expiresAt: Date.now() + 1000
+}
+
+describe('guestSessionExpired', () => {
+ it('offers the pin form when the share id is known', () => {
+ const { wrapper } = getWrapper()
+ expect(wrapper.find(selectors.form).exists()).toBeTruthy()
+ })
+
+ it('only tells the guest to check their inbox when the share id is unknown', () => {
+ const { wrapper } = getWrapper({ guestShareId: null })
+ expect(wrapper.find(selectors.form).exists()).toBeFalsy()
+ expect(wrapper.text()).toContain('Open the invitation link in your email inbox')
+ })
+
+ it('disables the submit button until a pin is entered', async () => {
+ const { wrapper } = getWrapper()
+ expect(wrapper.find(selectors.submitButton).attributes('disabled')).toEqual('true')
+
+ ;(wrapper.vm as any).pin = '123456'
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.find(selectors.submitButton).attributes('disabled')).toEqual('false')
+ })
+
+ it('restores the session and navigates on a correct pin', async () => {
+ const { wrapper, mocks } = getWrapper()
+ ;(wrapper.vm as any).pin = '123456'
+
+ await (wrapper.vm as any).verifyPinTask.perform()
+ await flushPromises()
+
+ expect(authService.verifyGuestPin).toHaveBeenCalledWith('123456')
+ expect(mocks.$router.replace).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'files-spaces-generic',
+ query: expect.objectContaining({ shareId: 'share-id' })
+ })
+ )
+ })
+
+ it('reports an incorrect pin', async () => {
+ const { wrapper } = getWrapper({ verifyError: new Error('unauthorized') })
+ ;(wrapper.vm as any).pin = 'wrong'
+
+ await expect((wrapper.vm as any).verifyPinTask.perform()).rejects.toThrow()
+ await wrapper.vm.$nextTick()
+
+ expect(wrapper.findComponent(OcTextInputStub).attributes('error-message')).toEqual(
+ 'Incorrect PIN'
+ )
+ })
+})
+
+function getWrapper({
+ guestShareId = 'share-id',
+ verifyError = null
+}: { guestShareId?: string; verifyError?: Error } = {}) {
+ const $clientService = mockDeep()
+ const space = mockDeep({
+ id: 'share-id',
+ driveType: 'share',
+ driveAlias: 'share/Invited folder',
+ getDriveAliasAndItem: () => 'share/Invited folder'
+ })
+
+ $clientService.webdav.getFileInfo.mockResolvedValue(
+ mockDeep({ id: 'resource-id', isFolder: true, path: '/' })
+ )
+
+ if (verifyError) {
+ vi.mocked(authService.verifyGuestPin).mockRejectedValue(verifyError)
+ } else {
+ vi.mocked(authService.verifyGuestPin).mockResolvedValue(guestSession)
+ }
+
+ const mocks = { ...defaultComponentMocks(), $clientService }
+
+ return {
+ mocks,
+ wrapper: shallowMount(GuestSessionExpired, {
+ global: {
+ plugins: [
+ ...defaultPlugins({
+ piniaOptions: {
+ stubActions: false,
+ authState: { guestShareId },
+ spacesState: { spaces: [space] }
+ }
+ })
+ ],
+ mocks,
+ provide: mocks,
+ stubs: { OcCard: false, PlainCard: false, OcTextInput: OcTextInputStub }
+ }
+ })
+ }
+}
diff --git a/packages/web-runtime/tests/unit/pages/resolveGuestLink.spec.ts b/packages/web-runtime/tests/unit/pages/resolveGuestLink.spec.ts
new file mode 100644
index 00000000000..8cfa7a7a182
--- /dev/null
+++ b/packages/web-runtime/tests/unit/pages/resolveGuestLink.spec.ts
@@ -0,0 +1,144 @@
+import { ref } from 'vue'
+import { mockDeep } from 'vitest-mock-extended'
+import { flushPromises } from '@vue/test-utils'
+import { ClientService, GuestSession, useRouteParam } from '@opencloud-eu/web-pkg'
+import { Resource, ShareSpaceResource } from '@opencloud-eu/web-client'
+import { defaultComponentMocks, defaultPlugins, shallowMount } from '@opencloud-eu/web-test-helpers'
+import ResolveGuestLink from '../../../src/pages/resolveGuestLink.vue'
+import { authService } from '../../../src/services/auth'
+import { GuestAuthError } from '../../../src/services/auth/guestAuth'
+
+vi.mock('../../../src/services/auth')
+
+vi.mock('@opencloud-eu/web-pkg', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useRouteParam: vi.fn()
+}))
+
+const selectors = {
+ spinner: 'oc-spinner-stub',
+ errorMessage: '[data-testid="error-message"]'
+}
+
+const guestSession: GuestSession = {
+ shareId: 'share-id',
+ shareName: 'Invited folder',
+ permissions: [],
+ expiresAt: Date.now() + 1000
+}
+
+describe('resolveGuestLink', () => {
+ it('shows a spinner while resolving', () => {
+ const { wrapper } = getWrapper()
+ expect(wrapper.find(selectors.spinner).exists()).toBeTruthy()
+ expect(wrapper.find(selectors.errorMessage).exists()).toBeFalsy()
+ })
+
+ it('exchanges the token from the url path', async () => {
+ getWrapper()
+ await flushPromises()
+ expect(authService.resolveGuestLink).toHaveBeenCalledWith('magic-token')
+ })
+
+ it('replaces the route with the invited folder', async () => {
+ const { mocks } = getWrapper()
+ await flushPromises()
+
+ expect(mocks.$router.replace).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'files-spaces-generic',
+ params: { driveAliasAndItem: 'share/Invited folder' },
+ query: expect.objectContaining({ shareId: 'share-id' })
+ })
+ )
+ // never pushed: the magic token sits in the url path and must not stay in the history
+ expect(mocks.$router.push).not.toHaveBeenCalled()
+ })
+
+ it('replaces the route with the default app for a single file invite', async () => {
+ const { mocks } = getWrapper({ isFolder: false })
+ await flushPromises()
+
+ expect(mocks.$router.replace).toHaveBeenCalled()
+ expect(mocks.$router.replace.mock.calls[0][0]).not.toMatchObject({
+ name: 'files-spaces-generic'
+ })
+ })
+
+ it('routes to the expired page when the token is spent', async () => {
+ const { mocks } = getWrapper({
+ resolveError: new GuestAuthError({ errorType: 'token_expired', shareId: 'share-id' })
+ })
+ await flushPromises()
+
+ expect(mocks.$router.replace).toHaveBeenCalledWith({ name: 'guestSessionExpired' })
+ })
+
+ it('shows a generic error for an invalid link and grants no session', async () => {
+ const { wrapper, mocks } = getWrapper({
+ resolveError: new GuestAuthError({ statusCode: 404 })
+ })
+ await flushPromises()
+
+ expect(wrapper.find(selectors.errorMessage).text()).toEqual(
+ 'This invitation link is invalid or has expired.'
+ )
+ expect(mocks.$router.replace).not.toHaveBeenCalled()
+ })
+
+ it('does not render anything derived from the invitation before it resolved', async () => {
+ const { wrapper } = getWrapper({
+ resolveError: new GuestAuthError({ statusCode: 404 })
+ })
+ await flushPromises()
+
+ const html = wrapper.html()
+ expect(html).not.toContain('magic-token')
+ expect(html).not.toContain('share-id')
+ expect(html).not.toContain('Invited folder')
+ })
+})
+
+function getWrapper({
+ isFolder = true,
+ resolveError = null
+}: { isFolder?: boolean; resolveError?: Error } = {}) {
+ const $clientService = mockDeep()
+ const space = mockDeep({
+ id: 'share-id',
+ driveType: 'share',
+ driveAlias: 'share/Invited folder',
+ getDriveAliasAndItem: () => 'share/Invited folder'
+ })
+
+ $clientService.webdav.getFileInfo.mockResolvedValue(
+ mockDeep({ id: 'resource-id', isFolder, path: '/' })
+ )
+
+ if (resolveError) {
+ vi.mocked(authService.resolveGuestLink).mockRejectedValue(resolveError)
+ } else {
+ vi.mocked(authService.resolveGuestLink).mockResolvedValue(guestSession)
+ }
+
+ vi.mocked(useRouteParam).mockReturnValue(ref('magic-token'))
+
+ const mocks = { ...defaultComponentMocks(), $clientService }
+
+ return {
+ mocks,
+ wrapper: shallowMount(ResolveGuestLink, {
+ global: {
+ // the share space is normally created by the guest session manager, which is mocked here
+ plugins: [
+ ...defaultPlugins({
+ piniaOptions: { stubActions: false, spacesState: { spaces: [space] } }
+ })
+ ],
+ mocks,
+ provide: mocks,
+ stubs: { OcCard: false, PlainCard: false }
+ }
+ })
+ }
+}
diff --git a/packages/web-runtime/tests/unit/router/setupAuthGuard.spec.ts b/packages/web-runtime/tests/unit/router/setupAuthGuard.spec.ts
new file mode 100644
index 00000000000..36e290a19c3
--- /dev/null
+++ b/packages/web-runtime/tests/unit/router/setupAuthGuard.spec.ts
@@ -0,0 +1,118 @@
+import { RouteRecordNormalized, Router } from 'vue-router'
+import { useAuthStore } from '@opencloud-eu/web-pkg'
+import { createTestingPinia } from '@opencloud-eu/web-test-helpers'
+import { setupAuthGuard } from '../../../src/router/setupAuthGuard'
+import { authService } from '../../../src/services/auth/authService'
+
+vi.mock('../../../src/services/auth/authService', () => ({
+ authService: {
+ initializeContext: vi.fn(),
+ hasAuthErrorOccurred: false,
+ guestSessionExpired: false
+ }
+}))
+
+type Guard = (to: any, from?: any) => Promise
+
+const userRoute = {
+ name: 'files-spaces-generic',
+ meta: { authContext: 'user' },
+ fullPath: '/files/spaces/share/Invited%20folder',
+ params: {},
+ query: {}
+}
+
+const publicLinkRoute = {
+ name: 'files-public-link',
+ meta: { authContext: 'publicLink' },
+ fullPath: '/files/link/public/token',
+ params: { driveAliasAndItem: 'public/token', token: 'token' },
+ query: {}
+}
+
+function installGuard() {
+ let guard: Guard
+ const router = {
+ beforeEach: (fn: Guard) => (guard = fn),
+ afterEach: vi.fn(),
+ getRoutes: (): RouteRecordNormalized[] => []
+ } as unknown as Router
+
+ setupAuthGuard(router)
+ return guard
+}
+
+describe('setupAuthGuard', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ authService.hasAuthErrorOccurred = false
+ ;(authService as any).guestSessionExpired = false
+ createTestingPinia({ stubActions: false })
+ })
+
+ it('sends an unauthenticated visitor to the login page', async () => {
+ const guard = installGuard()
+
+ expect(await guard(userRoute)).toEqual({
+ path: '/login',
+ query: { redirectUrl: userRoute.fullPath }
+ })
+ })
+
+ it('lets a signed-in user through', async () => {
+ useAuthStore().setUserContextReady(true)
+ const guard = installGuard()
+
+ expect(await guard(userRoute)).toBe(true)
+ })
+
+ it('lets an established guest session through a user-context route', async () => {
+ useAuthStore().setGuestContext({
+ shareId: 'share-id',
+ shareName: 'Invited folder',
+ permissions: [],
+ expiresAt: Date.now() + 1000
+ })
+ const guard = installGuard()
+
+ expect(await guard(userRoute)).toBe(true)
+ })
+
+ it('does not let a guest session stand in for a public link context', async () => {
+ useAuthStore().setGuestContext({
+ shareId: 'share-id',
+ shareName: 'Invited folder',
+ permissions: [],
+ expiresAt: Date.now() + 1000
+ })
+ const guard = installGuard()
+
+ expect(await guard(publicLinkRoute)).toEqual({
+ name: 'resolvePublicLink',
+ params: { token: 'token' },
+ query: { redirectUrl: publicLinkRoute.fullPath }
+ })
+ })
+
+ it('redirects an expired guest session to the expired page', async () => {
+ ;(authService as any).guestSessionExpired = true
+ const guard = installGuard()
+
+ expect(await guard(userRoute)).toEqual({ name: 'guestSessionExpired' })
+ })
+
+ it('stays on the expired page once it is reached', async () => {
+ ;(authService as any).guestSessionExpired = true
+ const guard = installGuard()
+
+ expect(await guard({ ...userRoute, name: 'guestSessionExpired' })).toBe(true)
+ })
+
+ it('gives an auth error precedence over the guest session', async () => {
+ authService.hasAuthErrorOccurred = true
+ ;(authService as any).guestSessionExpired = true
+ const guard = installGuard()
+
+ expect(await guard(userRoute)).toEqual({ name: 'accessDenied' })
+ })
+})
diff --git a/packages/web-runtime/tests/unit/router/setupVaultUnlockGuard.spec.ts b/packages/web-runtime/tests/unit/router/setupVaultUnlockGuard.spec.ts
index 33fce51e435..eb6a35e2db5 100644
--- a/packages/web-runtime/tests/unit/router/setupVaultUnlockGuard.spec.ts
+++ b/packages/web-runtime/tests/unit/router/setupVaultUnlockGuard.spec.ts
@@ -391,6 +391,25 @@ describe('setupVaultUnlockGuard', () => {
})
})
+ it('does not lazy-load mount-points when the share space is already in the store', async () => {
+ // A guest session pre-creates its share space, precisely so this guard never reaches the
+ // user-scoped mount-point graph call.
+ const shareSpace = { id: 'share-id', driveAlias: 'share/Invited folder' }
+ const guard = installGuard({ spaces: [shareSpace], claim: null })
+
+ const result = await guard(
+ {
+ params: { driveAliasAndItem: 'share/Invited folder' },
+ query: { shareId: 'share-id' },
+ fullPath: '/files/spaces/share/Invited folder'
+ },
+ coldStart
+ )
+
+ expect(result).toBe(true)
+ expect(loadMountPoints).not.toHaveBeenCalled()
+ })
+
it('lazy-loads mount-points and builds the share space, then redirects a locked vault', async () => {
// On a hard reload into a share space, the share space isn't in the store
// yet (mount-point spaces are fetched on demand). Given the `shareId` query,
diff --git a/packages/web-runtime/tests/unit/services/auth/authService.spec.ts b/packages/web-runtime/tests/unit/services/auth/authService.spec.ts
index 8eb24aed428..24bf23c3d55 100644
--- a/packages/web-runtime/tests/unit/services/auth/authService.spec.ts
+++ b/packages/web-runtime/tests/unit/services/auth/authService.spec.ts
@@ -1,4 +1,10 @@
-import { ConfigStore, useAuthStore, useConfigStore } from '@opencloud-eu/web-pkg'
+import {
+ ConfigStore,
+ useAuthStore,
+ useConfigStore,
+ useMessages,
+ useSpacesStore
+} from '@opencloud-eu/web-pkg'
import { mock } from 'vitest-mock-extended'
import { Router } from 'vue-router'
import { ErrorResponse, ErrorTimeout } from 'oidc-client-ts'
@@ -20,14 +26,33 @@ const initAuthService = ({
configStore?: ConfigStore
router?: Router
}) => {
- createTestingPinia()
+ // stubActions: false so the guest session actions actually mutate the store
+ createTestingPinia({ stubActions: false })
const authStore = useAuthStore()
configStore = configStore || useConfigStore()
- authService.initialize(configStore, null, router, null, null, null, authStore, null, null)
+ authService.initialize(
+ configStore,
+ null,
+ router,
+ null,
+ null,
+ null,
+ authStore,
+ null,
+ null,
+ useSpacesStore(),
+ useMessages()
+ )
+
+ return { authStore }
}
describe('AuthService', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
describe('signInCallback', () => {
it.each([
['/', '/', {}],
@@ -331,5 +356,110 @@ describe('AuthService', () => {
expect(updateContext).toHaveBeenCalledTimes(1)
expect(removeUser).toHaveBeenCalledWith('authError')
})
+
+ it('routes an expired guest to the guest session expired page instead of logging out', async () => {
+ const authService = new AuthService()
+ const removeUser = vi.fn()
+
+ Object.defineProperty(authService, 'userManager', {
+ value: mock({ getUser: vi.fn().mockResolvedValue(null), removeUser })
+ })
+
+ const router = createRouter({
+ routes: [
+ {
+ name: 'guestSessionExpired',
+ path: '/guest-session-expired',
+ component: { template: '' }
+ }
+ ]
+ })
+ const pushSpy = vi.spyOn(router, 'push')
+ const { authStore } = initAuthService({ authService, router })
+ authStore.setGuestContext({
+ shareId: 'share-id',
+ shareName: 'share',
+ permissions: [],
+ expiresAt: Date.now() + 1000
+ })
+
+ await authService.handleAuthError(userContextRoute)
+
+ expect(pushSpy).toHaveBeenCalledWith({ name: 'guestSessionExpired' })
+ expect(removeUser).not.toHaveBeenCalled()
+ expect(authStore.guestContextReady).toBeFalsy()
+ // the share id survives, the renew endpoint and the PIN form both need it
+ expect(authStore.guestShareId).toEqual('share-id')
+ })
+
+ it("treats a signed-in user's auth error as a user error even with a guest record around", async () => {
+ const authService = new AuthService()
+ const removeUser = vi.fn()
+
+ Object.defineProperty(authService, 'userManager', {
+ value: mock({ getUser: vi.fn().mockResolvedValue(null), removeUser })
+ })
+
+ const { authStore } = initAuthService({ authService, router: createRouter() })
+ authStore.setGuestContext({
+ shareId: 'share-id',
+ shareName: 'share',
+ permissions: [],
+ expiresAt: Date.now() + 1000
+ })
+ authStore.setUserContextReady(true)
+
+ await authService.handleAuthError(userContextRoute)
+
+ expect(removeUser).toHaveBeenCalledWith('authError')
+ })
+ })
+
+ describe('initializeContext with a guest session', () => {
+ it('restores a persisted guest session', async () => {
+ localStorage.setItem(
+ 'oc.guestSession',
+ JSON.stringify({
+ shareId: 'share-id',
+ shareName: 'Invited folder',
+ permissions: [],
+ expiresAt: Date.now() + 60000
+ })
+ )
+
+ const authService = new AuthService()
+ Object.defineProperty(authService, 'userManager', {
+ value: mock({ getUser: vi.fn().mockResolvedValue(null) })
+ })
+
+ const { authStore } = initAuthService({ authService, router: createRouter() })
+ await authService.initializeContext(mock({}))
+
+ expect(authStore.guestContextReady).toBeTruthy()
+ expect(authStore.guestShareId).toEqual('share-id')
+ })
+
+ it('does not restore an expired guest session', async () => {
+ localStorage.setItem(
+ 'oc.guestSession',
+ JSON.stringify({
+ shareId: 'share-id',
+ shareName: 'Invited folder',
+ permissions: [],
+ expiresAt: Date.now() - 1
+ })
+ )
+
+ const authService = new AuthService()
+ Object.defineProperty(authService, 'userManager', {
+ value: mock({ getUser: vi.fn().mockResolvedValue(null) })
+ })
+
+ const { authStore } = initAuthService({ authService, router: createRouter() })
+ await authService.initializeContext(mock({}))
+
+ expect(authStore.guestContextReady).toBeFalsy()
+ expect(authService.guestSessionExpired).toBeTruthy()
+ })
})
})
diff --git a/packages/web-runtime/tests/unit/services/auth/guestSessionManager.spec.ts b/packages/web-runtime/tests/unit/services/auth/guestSessionManager.spec.ts
new file mode 100644
index 00000000000..d9423f2c364
--- /dev/null
+++ b/packages/web-runtime/tests/unit/services/auth/guestSessionManager.spec.ts
@@ -0,0 +1,317 @@
+import { mockDeep } from 'vitest-mock-extended'
+import { AxiosResponse } from 'axios'
+import { GraphSharePermission } from '@opencloud-eu/web-client'
+import {
+ ClientService,
+ GuestSession,
+ useAuthStore,
+ useConfigStore,
+ useMessages,
+ useSpacesStore
+} from '@opencloud-eu/web-pkg'
+import { createRouter, createTestingPinia } from '@opencloud-eu/web-test-helpers'
+import { GuestSessionManager } from '../../../../src/services/auth/guestSessionManager'
+import { GuestAuthError } from '../../../../src/services/auth/guestAuth'
+
+const storageKey = 'oc.guestSession'
+const dayInMs = 24 * 60 * 60 * 1000
+
+const sessionResponse = (overrides = {}) => ({
+ share_id: 'share-id',
+ share_name: 'Invited folder',
+ permissions: ['libre.graph/driveItem/basic/read'],
+ expires_at: new Date(Date.now() + dayInMs).toISOString(),
+ ...overrides
+})
+
+const persisted = (overrides = {}): GuestSession => ({
+ shareId: 'share-id',
+ shareName: 'Invited folder',
+ permissions: [] as GraphSharePermission[],
+ expiresAt: Date.now() + dayInMs,
+ ...overrides
+})
+
+describe('GuestSessionManager', () => {
+ beforeEach(() => {
+ localStorage.clear()
+ })
+
+ describe('method "verifyToken"', () => {
+ it('establishes the session, the share space and the spaces store', async () => {
+ const { manager, authStore, spacesStore, clientService } = getManager()
+ clientService.httpUnAuthenticated.post.mockResolvedValue({
+ data: sessionResponse()
+ } as AxiosResponse)
+
+ await manager.verifyToken('magic-token')
+
+ expect(clientService.httpUnAuthenticated.post).toHaveBeenCalledWith(
+ expect.stringContaining('magic_guest_link_auth/verify/token'),
+ { token: 'magic-token' },
+ { withCredentials: true }
+ )
+ expect(authStore.guestContextReady).toBeTruthy()
+ expect(authStore.guestShareId).toEqual('share-id')
+ expect(JSON.parse(localStorage.getItem(storageKey)).shareId).toEqual('share-id')
+
+ // pre-populating the space is what keeps the guest's first folder listing off the
+ // user-scoped mount point graph call, and unblocks the vault guard
+ expect(spacesStore.spaces[0].graphPermissions).toEqual(['libre.graph/driveItem/basic/read'])
+ expect(spacesStore.spacesInitialized).toBeTruthy()
+ })
+
+ it('renews and marks the session expired when the token is spent', async () => {
+ const { manager, authStore, clientService } = getManager()
+ clientService.httpUnAuthenticated.post
+ .mockRejectedValueOnce({
+ response: { status: 401, data: { error_type: 'token_expired', share_id: 'share-id' } }
+ })
+ .mockResolvedValue({ data: {} } as AxiosResponse)
+
+ await expect(manager.verifyToken('magic-token')).rejects.toThrow(GuestAuthError)
+
+ expect(clientService.httpUnAuthenticated.post).toHaveBeenCalledWith(
+ expect.stringContaining('magic_guest_link_auth/renew'),
+ { share_id: 'share-id' },
+ { withCredentials: true }
+ )
+ expect(manager.sessionExpired).toBeTruthy()
+ expect(authStore.guestContextReady).toBeFalsy()
+ // remembered so the expired page can offer the PIN form
+ expect(authStore.guestShareId).toEqual('share-id')
+ })
+
+ it('rejects a response with an unusable expiry instead of granting a session', async () => {
+ const { manager, authStore, clientService } = getManager()
+ clientService.httpUnAuthenticated.post.mockResolvedValue({
+ data: sessionResponse({ expires_at: 'not a date' })
+ } as AxiosResponse)
+
+ await expect(manager.verifyToken('magic-token')).rejects.toThrow(GuestAuthError)
+ expect(authStore.guestContextReady).toBeFalsy()
+ })
+
+ it('does not renew when the link is simply invalid', async () => {
+ const { manager, clientService } = getManager()
+ clientService.httpUnAuthenticated.post.mockRejectedValue({
+ response: { status: 404, data: {} }
+ })
+
+ await expect(manager.verifyToken('magic-token')).rejects.toThrow(GuestAuthError)
+
+ expect(clientService.httpUnAuthenticated.post).toHaveBeenCalledTimes(1)
+ expect(manager.sessionExpired).toBeFalsy()
+ })
+ })
+
+ describe('method "verifyPin"', () => {
+ it('sends the pin together with the remembered share id', async () => {
+ const { manager, authStore, clientService } = getManager()
+ authStore.setGuestShareId('share-id')
+ clientService.httpUnAuthenticated.post.mockResolvedValue({
+ data: sessionResponse()
+ } as AxiosResponse)
+
+ await manager.verifyPin('123456')
+
+ expect(clientService.httpUnAuthenticated.post).toHaveBeenCalledWith(
+ expect.stringContaining('magic_guest_link_auth/verify/pin'),
+ { pin: '123456', share_id: 'share-id' },
+ { withCredentials: true }
+ )
+ expect(manager.sessionExpired).toBeFalsy()
+ expect(authStore.guestContextReady).toBeTruthy()
+ })
+ })
+
+ describe('method "restoreContext"', () => {
+ it('does nothing without a persisted session', () => {
+ const { manager, authStore } = getManager()
+ manager.restoreContext()
+ expect(authStore.guestContextReady).toBeFalsy()
+ })
+
+ it('restores a live session', () => {
+ localStorage.setItem(storageKey, JSON.stringify(persisted()))
+ const { manager, authStore, spacesStore } = getManager()
+
+ manager.restoreContext()
+
+ expect(authStore.guestContextReady).toBeTruthy()
+ expect(authStore.guestShareName).toEqual('Invited folder')
+ expect(spacesStore.spacesInitialized).toBeTruthy()
+ })
+
+ it('is idempotent across navigations', () => {
+ localStorage.setItem(storageKey, JSON.stringify(persisted()))
+ const { manager, authStore } = getManager()
+ const setGuestContext = vi.spyOn(authStore, 'setGuestContext')
+
+ manager.restoreContext()
+ manager.restoreContext()
+ manager.restoreContext()
+
+ expect(setGuestContext).toHaveBeenCalledTimes(1)
+ })
+
+ it('expires a session that outlived its lifetime instead of restoring it', async () => {
+ localStorage.setItem(storageKey, JSON.stringify(persisted({ expiresAt: Date.now() - 1 })))
+ const { manager, authStore, clientService } = getManager()
+ clientService.httpUnAuthenticated.post.mockResolvedValue({ data: {} } as AxiosResponse)
+
+ manager.restoreContext()
+ await manager.handleSessionExpired()
+
+ expect(authStore.guestContextReady).toBeFalsy()
+ expect(manager.sessionExpired).toBeTruthy()
+ expect(localStorage.getItem(storageKey)).toBeNull()
+ })
+
+ it('drops a leftover record when a real user session owns the browser', () => {
+ localStorage.setItem(storageKey, JSON.stringify(persisted()))
+ const { manager, authStore } = getManager()
+ authStore.setUserContextReady(true)
+
+ manager.restoreContext()
+
+ expect(authStore.guestContextReady).toBeFalsy()
+ expect(localStorage.getItem(storageKey)).toBeNull()
+ })
+
+ it('drops an unreadable persisted session', () => {
+ localStorage.setItem(storageKey, 'not json')
+ const { manager, authStore } = getManager()
+
+ manager.restoreContext()
+
+ expect(authStore.guestContextReady).toBeFalsy()
+ expect(localStorage.getItem(storageKey)).toBeNull()
+ })
+ })
+
+ describe('method "handleSessionExpired"', () => {
+ it('sends exactly one renewal for concurrent expiries', async () => {
+ const { manager, authStore, clientService } = getManager()
+ authStore.setGuestShareId('share-id')
+ clientService.httpUnAuthenticated.post.mockResolvedValue({ data: {} } as AxiosResponse)
+
+ await Promise.all(Array.from({ length: 5 }, () => manager.handleSessionExpired()))
+
+ expect(clientService.httpUnAuthenticated.post).toHaveBeenCalledTimes(1)
+ })
+
+ it('survives a failing renewal', async () => {
+ vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ const { manager, authStore, clientService } = getManager()
+ authStore.setGuestShareId('share-id')
+ clientService.httpUnAuthenticated.post.mockRejectedValue(new Error('network down'))
+
+ await manager.handleSessionExpired()
+
+ expect(manager.sessionExpired).toBeTruthy()
+ })
+ })
+
+ describe('session timers', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ })
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('warns 15 minutes before the session expires', async () => {
+ const { manager, messagesStore, clientService } = getManager()
+ clientService.httpUnAuthenticated.post.mockResolvedValue({
+ data: sessionResponse()
+ } as AxiosResponse)
+
+ await manager.verifyToken('magic-token')
+ expect(messagesStore.messages.length).toBe(0)
+
+ vi.advanceTimersByTime(dayInMs - 15 * 60 * 1000)
+
+ expect(messagesStore.messages.length).toBe(1)
+ expect(messagesStore.messages[0].status).toEqual('warning')
+ // must not auto-close, the guest needs time to save unsaved work
+ expect(messagesStore.messages[0].timeout).toBe(0)
+ })
+
+ it('expires an idle session when its lifetime runs out', async () => {
+ const { manager, authStore, clientService, router } = getManager()
+ const pushSpy = vi.spyOn(router, 'push')
+ clientService.httpUnAuthenticated.post.mockResolvedValue({
+ data: sessionResponse()
+ } as AxiosResponse)
+
+ await manager.verifyToken('magic-token')
+ await vi.advanceTimersByTimeAsync(dayInMs)
+
+ expect(authStore.guestContextReady).toBeFalsy()
+ expect(pushSpy).toHaveBeenCalledWith({ name: 'guestSessionExpired' })
+ })
+
+ it('does not warn when the session is already within the warning window', async () => {
+ const { manager, messagesStore, clientService } = getManager()
+ clientService.httpUnAuthenticated.post.mockResolvedValue({
+ data: sessionResponse({ expires_at: new Date(Date.now() + 60000).toISOString() })
+ } as AxiosResponse)
+
+ await manager.verifyToken('magic-token')
+ vi.advanceTimersByTime(59000)
+
+ expect(messagesStore.messages.length).toBe(0)
+ })
+
+ it('cancels both timers on clear', async () => {
+ const { manager, messagesStore, clientService, router } = getManager()
+ const pushSpy = vi.spyOn(router, 'push')
+ clientService.httpUnAuthenticated.post.mockResolvedValue({
+ data: sessionResponse()
+ } as AxiosResponse)
+
+ await manager.verifyToken('magic-token')
+ manager.clear()
+ await vi.advanceTimersByTimeAsync(dayInMs)
+
+ expect(messagesStore.messages.length).toBe(0)
+ expect(pushSpy).not.toHaveBeenCalled()
+ })
+ })
+})
+
+function getManager() {
+ createTestingPinia({ stubActions: false })
+ const authStore = useAuthStore()
+ const configStore = useConfigStore()
+ const spacesStore = useSpacesStore()
+ const messagesStore = useMessages()
+ const clientService = mockDeep()
+ const router = createRouter({
+ routes: [
+ {
+ name: 'guestSessionExpired',
+ path: '/guest-session-expired',
+ component: { template: '' }
+ }
+ ]
+ })
+
+ return {
+ manager: new GuestSessionManager({
+ clientService,
+ configStore,
+ authStore,
+ spacesStore,
+ messagesStore,
+ router
+ }),
+ authStore,
+ configStore,
+ spacesStore,
+ messagesStore,
+ clientService,
+ router
+ }
+}
diff --git a/packages/web-test-helpers/src/mocks/pinia.ts b/packages/web-test-helpers/src/mocks/pinia.ts
index 755ce2965ae..72eedb5f7e8 100644
--- a/packages/web-test-helpers/src/mocks/pinia.ts
+++ b/packages/web-test-helpers/src/mocks/pinia.ts
@@ -14,6 +14,7 @@ import {
} from '@opencloud-eu/web-pkg'
import {
CollaboratorShare,
+ GraphSharePermission,
LinkShare,
Resource,
ShareRole,
@@ -37,6 +38,11 @@ export type PiniaMockOptions = {
idpContextReady?: boolean
userContextReady?: boolean
publicLinkContextReady?: boolean
+ guestContextReady?: boolean
+ guestShareId?: string
+ guestShareName?: string
+ guestPermissions?: GraphSharePermission[]
+ guestSessionExpiresAt?: number
}
themeState?: { availableThemes?: WebThemeType[]; currentTheme?: WebThemeType }
clipboardState?: { action?: ClipboardActions; resources?: Resource[] }