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 @@ + + + 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[] }