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:
135
apps/app/src/stores/portal/__tests__/usePortalStore.spec.ts
Normal file
135
apps/app/src/stores/portal/__tests__/usePortalStore.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user