fix(security): A13-8 — migrate portal store to sessionStorage with explicit reset

usePortalStore now persists state in sessionStorage instead of
localStorage. Tab-close clears the session implicitly; explicit logout
+ 401 paths invoke reset() which iterates the `crewli:portal:` prefix
and removes every key (forward-compatible for future portal-namespaced
state).

Storage keys are renamed under the canonical prefix:
- crewli_portal_user_events_v1 → crewli:portal:events
- crewli_portal_active_event_id_v1 → crewli:portal:activeEventId

The single new prefix-clear function (clearStoragePrefix) replaces the
hand-listed key removals, so future portal-namespaced state additions
need no reset() change.

useAuthStore.handleUnauthorized() (the 401 interceptor target) is now
async and invokes clearAll() — the canonical session-cleanup hub —
restoring the portal-storage cleanup that the deleted
usePortalAuthStore.handleUnauthorized previously owned. The merge in
Phase E left this gap; this commit closes it.

Adds 7 vitest specs in stores/portal/__tests__/usePortalStore.spec.ts
covering: sessionStorage persistence, reset() prefix-iteration,
non-prefixed-key isolation, reactive state reset, useAuthStore.clearAll
+ handleUnauthorized integration.

Test count 198 → 205. Lint + typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-05 21:43:40 +02:00
parent 38a94c78e9
commit 3019095a2a
3 changed files with 170 additions and 17 deletions

View File

@@ -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 })
})
})
})

View File

@@ -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 {

View File

@@ -193,8 +193,11 @@ export const useAuthStore = defineStore('auth', () => {
usePortalStore().reset()
}
function handleUnauthorized() {
clearState()
async function handleUnauthorized(): Promise<void> {
// 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