diff --git a/apps/app/src/stores/portal/__tests__/usePortalStore.spec.ts b/apps/app/src/stores/portal/__tests__/usePortalStore.spec.ts new file mode 100644 index 00000000..050d8b46 --- /dev/null +++ b/apps/app/src/stores/portal/__tests__/usePortalStore.spec.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +vi.mock('@/lib/axios', () => ({ + apiClient: { get: vi.fn(), post: vi.fn() }, +})) + +const { usePortalStore } = await import('@/stores/portal/usePortalStore') +const { useAuthStore } = await import('@/stores/useAuthStore') + +describe('usePortalStore — A13-8 sessionStorage migration (WS-3 PR-B2a)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + sessionStorage.clear() + localStorage.clear() + }) + + afterEach(() => { + sessionStorage.clear() + localStorage.clear() + }) + + describe('storage migration', () => { + it('persists active event id to sessionStorage with prefix', () => { + const store = usePortalStore() + + store.userEvents.push({ + event_id: '01EVENT', + event_name: 'Test', + organisation_name: 'Org', + person_status: 'approved', + start_date: '2026-06-01', + end_date: '2026-06-02', + }) + store.setActiveEvent('01EVENT') + + expect(sessionStorage.getItem('crewli:portal:activeEventId')).toBe('01EVENT') + expect(localStorage.getItem('crewli:portal:activeEventId')).toBeNull() + }) + + it('reads active event id from sessionStorage on store init', () => { + sessionStorage.setItem('crewli:portal:activeEventId', '01PREVIOUS') + + const store = usePortalStore() + + expect(store.activeEventId).toBe('01PREVIOUS') + }) + + it('reset() clears all crewli:portal: prefixed sessionStorage keys', () => { + sessionStorage.setItem('crewli:portal:events', '[]') + sessionStorage.setItem('crewli:portal:activeEventId', '01EVENT') + sessionStorage.setItem('crewli:portal:future-key', 'whatever') + + const store = usePortalStore() + + store.reset() + + expect(sessionStorage.getItem('crewli:portal:events')).toBeNull() + expect(sessionStorage.getItem('crewli:portal:activeEventId')).toBeNull() + expect(sessionStorage.getItem('crewli:portal:future-key')).toBeNull() + }) + + it('reset() does NOT touch unrelated sessionStorage keys', () => { + sessionStorage.setItem('crewli:portal:events', '[]') + sessionStorage.setItem('someOtherApp:foo', 'untouched') + sessionStorage.setItem('analytics:trace', '01TRACE') + + const store = usePortalStore() + + store.reset() + + expect(sessionStorage.getItem('crewli:portal:events')).toBeNull() + expect(sessionStorage.getItem('someOtherApp:foo')).toBe('untouched') + expect(sessionStorage.getItem('analytics:trace')).toBe('01TRACE') + }) + + it('reset() resets reactive state to defaults', () => { + const store = usePortalStore() + + store.userEvents.push({ + event_id: '01EVENT', + event_name: 'Test', + organisation_name: 'Org', + person_status: 'approved', + start_date: '2026-06-01', + end_date: '2026-06-02', + }) + store.loadError = 'some error' + + store.reset() + + expect(store.userEvents).toEqual([]) + expect(store.activeEventId).toBeNull() + expect(store.currentPerson).toBeNull() + expect(store.loadError).toBeNull() + expect(store.isHydrated).toBe(false) + }) + }) + + describe('integration with useAuthStore', () => { + it('useAuthStore.clearAll() resets portal sessionStorage', async () => { + sessionStorage.setItem('crewli:portal:events', '[]') + sessionStorage.setItem('crewli:portal:activeEventId', '01EVENT') + + const auth = useAuthStore() + + await auth.clearAll() + + expect(sessionStorage.getItem('crewli:portal:events')).toBeNull() + expect(sessionStorage.getItem('crewli:portal:activeEventId')).toBeNull() + }) + + it('useAuthStore.handleUnauthorized() resets portal sessionStorage (401 path)', async () => { + sessionStorage.setItem('crewli:portal:events', '[]') + sessionStorage.setItem('crewli:portal:activeEventId', '01EVENT') + + // Stub window.location to avoid jsdom navigation errors + const originalLocation = window.location + + Object.defineProperty(window, 'location', { + value: { pathname: '/portal/evenementen', href: '' }, + writable: true, + }) + + const auth = useAuthStore() + + await auth.handleUnauthorized() + + expect(sessionStorage.getItem('crewli:portal:events')).toBeNull() + expect(sessionStorage.getItem('crewli:portal:activeEventId')).toBeNull() + + Object.defineProperty(window, 'location', { value: originalLocation }) + }) + }) +}) diff --git a/apps/app/src/stores/portal/usePortalStore.ts b/apps/app/src/stores/portal/usePortalStore.ts index 3c9da0d0..58107503 100644 --- a/apps/app/src/stores/portal/usePortalStore.ts +++ b/apps/app/src/stores/portal/usePortalStore.ts @@ -3,14 +3,18 @@ import { computed, ref } from 'vue' import { apiClient } from '@/lib/axios' import type { AuthMeUser, PortalEvent, PortalPersonPayload } from '@/types/portal' -const STORAGE_EVENTS = 'crewli_portal_user_events_v1' -const STORAGE_ACTIVE_EVENT = 'crewli_portal_active_event_id_v1' +// A13-8 (SECURITY_AUDIT.md) — portal session state lives in +// sessionStorage, not localStorage. Tab-close clears the session; +// reset() also iterates the prefix to wipe explicit logout paths. +const STORAGE_PREFIX = 'crewli:portal:' +const STORAGE_EVENTS = `${STORAGE_PREFIX}events` +const STORAGE_ACTIVE_EVENT = `${STORAGE_PREFIX}activeEventId` function readStoredEvents(): PortalEvent[] { - if (typeof localStorage === 'undefined') + if (typeof sessionStorage === 'undefined') return [] try { - const raw = localStorage.getItem(STORAGE_EVENTS) + const raw = sessionStorage.getItem(STORAGE_EVENTS) if (!raw) return [] @@ -33,24 +37,38 @@ function readStoredEvents(): PortalEvent[] { } function writeStoredEvents(events: PortalEvent[]): void { - if (typeof localStorage === 'undefined') + if (typeof sessionStorage === 'undefined') return - localStorage.setItem(STORAGE_EVENTS, JSON.stringify(events)) + sessionStorage.setItem(STORAGE_EVENTS, JSON.stringify(events)) } function readStoredActiveEventId(): string | null { - if (typeof localStorage === 'undefined') + if (typeof sessionStorage === 'undefined') return null - return localStorage.getItem(STORAGE_ACTIVE_EVENT) + return sessionStorage.getItem(STORAGE_ACTIVE_EVENT) } function writeStoredActiveEventId(id: string | null): void { - if (typeof localStorage === 'undefined') + if (typeof sessionStorage === 'undefined') return if (id) - localStorage.setItem(STORAGE_ACTIVE_EVENT, id) - else localStorage.removeItem(STORAGE_ACTIVE_EVENT) + sessionStorage.setItem(STORAGE_ACTIVE_EVENT, id) + else sessionStorage.removeItem(STORAGE_ACTIVE_EVENT) +} + +function clearStoragePrefix(): void { + if (typeof sessionStorage === 'undefined') + return + + const keysToRemove: string[] = [] + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i) + if (key?.startsWith(STORAGE_PREFIX)) + keysToRemove.push(key) + } + for (const k of keysToRemove) + sessionStorage.removeItem(k) } /** @@ -247,10 +265,7 @@ export const usePortalStore = defineStore('portal', () => { loadError.value = null isHydrated.value = false hydratePromise = null - if (typeof localStorage !== 'undefined') { - localStorage.removeItem(STORAGE_EVENTS) - localStorage.removeItem(STORAGE_ACTIVE_EVENT) - } + clearStoragePrefix() } return { diff --git a/apps/app/src/stores/useAuthStore.ts b/apps/app/src/stores/useAuthStore.ts index 3522490b..493f753b 100644 --- a/apps/app/src/stores/useAuthStore.ts +++ b/apps/app/src/stores/useAuthStore.ts @@ -193,8 +193,11 @@ export const useAuthStore = defineStore('auth', () => { usePortalStore().reset() } - function handleUnauthorized() { - clearState() + async function handleUnauthorized(): Promise { + // A13-8: clear portal sessionStorage on 401 in addition to in-memory + // state. clearAll() handles both via the dynamic-import seam to + // stores-portal. + await clearAll() // Do NOT reset isInitialized — the full page reload (below) resets all JS state. // Resetting it here causes a race condition: the async 401 interceptor fires