diff --git a/neode-ui/src/stores/__tests__/resourcesClear.test.ts b/neode-ui/src/stores/__tests__/resourcesClear.test.ts new file mode 100644 index 00000000..7bda3863 --- /dev/null +++ b/neode-ui/src/stores/__tests__/resourcesClear.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' + +import { useResourcesStore } from '../resources' + +vi.mock('@/api/rpc-client', () => ({ + rpcClient: { + logout: vi.fn(), + }, +})) + +describe('resources store — clearAll (T-02-02 logout purge)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + sessionStorage.clear() + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('drops every in-memory entry', async () => { + const store = useResourcesStore() + await store.refresh('k1', async () => 'a') + await store.refresh('k2', async () => 'b') + expect(store.entries.size).toBe(2) + store.clearAll() + expect(store.entries.size).toBe(0) + }) + + it('removes every resource:-prefixed sessionStorage key and leaves unrelated keys untouched', async () => { + const store = useResourcesStore() + await store.refresh('k1', async () => 'a') + sessionStorage.setItem('unrelated-app-setting', 'keep-me') + + expect(sessionStorage.getItem('resource:k1')).not.toBeNull() + expect(sessionStorage.getItem('unrelated-app-setting')).toBe('keep-me') + + store.clearAll() + + expect(sessionStorage.getItem('resource:k1')).toBeNull() + expect(sessionStorage.getItem('unrelated-app-setting')).toBe('keep-me') + }) + + it('cancels pending invalidate timers so a scheduled revalidate never fires post-logout', async () => { + const store = useResourcesStore() + await store.refresh('k1', async () => 'a') + const revalidate = vi.fn() + store.subscribe('k1', revalidate) + store.invalidate('k1') // schedules a debounced revalidate + + store.clearAll() + + // Advance past the debounce window — the cancelled timer must not fire. + vi.advanceTimersByTime(2000) + expect(revalidate).not.toHaveBeenCalled() + }) + + it('drops in-flight bookkeeping so a fetch resolving after clearAll does not repopulate memory or sessionStorage', async () => { + const store = useResourcesStore() + let resolveOld!: (v: string) => void + const oldFetch = new Promise((res) => { resolveOld = res }) + const p = store.refresh('k2', () => oldFetch) + + store.clearAll() + // The key was dropped from `entries` by clearAll — a fresh lookup must + // not see the old session's in-flight fetch as still pending for it. + expect(store.entries.has('k2')).toBe(false) + + resolveOld('stale-session-data') + await p + + // The old fetch resolved after clearAll's generation bump — its result + // must be dropped, not written into a fresh entry or sessionStorage. + expect(store.entries.has('k2')).toBe(false) + const e = store.entry('k2') + expect(e.data).toBeNull() + expect(sessionStorage.getItem('resource:k2')).toBeNull() + }) + + it('does not throw when sessionStorage.removeItem throws', async () => { + const store = useResourcesStore() + await store.refresh('k1', async () => 'a') + const spy = vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new Error('sessionStorage unavailable') + }) + expect(() => store.clearAll()).not.toThrow() + spy.mockRestore() + }) + + it('does not throw when sessionStorage.key/length throw', async () => { + const store = useResourcesStore() + await store.refresh('k1', async () => 'a') + const spy = vi.spyOn(Storage.prototype, 'key').mockImplementation(() => { + throw new Error('sessionStorage inaccessible') + }) + expect(() => store.clearAll()).not.toThrow() + spy.mockRestore() + }) +}) + +describe('auth store — logout purges the cache even when the RPC rejects', () => { + beforeEach(() => { + setActivePinia(createPinia()) + sessionStorage.clear() + vi.resetModules() + }) + + it('clears the resources cache after a rejected rpcClient.logout()', async () => { + const { rpcClient } = await import('@/api/rpc-client') + vi.mocked(rpcClient.logout).mockRejectedValueOnce(new Error('network down')) + + const resources = useResourcesStore() + await resources.refresh('some.cached.key', async () => 'sensitive-data') + expect(resources.entries.size).toBe(1) + + const { useAuthStore } = await import('../auth') + const auth = useAuthStore() + await auth.logout() + + expect(resources.entries.size).toBe(0) + expect(sessionStorage.getItem('resource:some.cached.key')).toBeNull() + }) + + it('clears the resources cache after a successful logout', async () => { + const { rpcClient } = await import('@/api/rpc-client') + vi.mocked(rpcClient.logout).mockResolvedValueOnce(undefined as never) + + const resources = useResourcesStore() + await resources.refresh('another.key', async () => 'data') + expect(resources.entries.size).toBe(1) + + const { useAuthStore } = await import('../auth') + const auth = useAuthStore() + await auth.logout() + + expect(resources.entries.size).toBe(0) + }) +}) diff --git a/neode-ui/src/stores/auth.ts b/neode-ui/src/stores/auth.ts index 29d06583..5966cb7c 100644 --- a/neode-ui/src/stores/auth.ts +++ b/neode-ui/src/stores/auth.ts @@ -4,6 +4,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { rpcClient } from '../api/rpc-client' import { useSyncStore } from './sync' +import { useResourcesStore } from './resources' export const useAuthStore = defineStore('auth', () => { // State @@ -84,6 +85,10 @@ export const useAuthStore = defineStore('auth', () => { sessionValidated = false localStorage.removeItem('neode-auth') sync.resetOnLogout() + // Purge every cached resource (memory + sessionStorage) regardless of + // whether the server-side logout RPC succeeded — a failed remote + // logout must not leave a locally-cached payload behind (T-02-02). + useResourcesStore().clearAll() } } diff --git a/neode-ui/src/stores/resources.ts b/neode-ui/src/stores/resources.ts index d0ea37ea..533d1b7c 100644 --- a/neode-ui/src/stores/resources.ts +++ b/neode-ui/src/stores/resources.ts @@ -56,6 +56,11 @@ export const useResourcesStore = defineStore('resources', () => { const inflight = new Map>() const revalidators = new Map void>>() const invalidateTimers = new Map>() + // Bumped by clearAll() so an in-flight fetch from a just-ended session that + // resolves afterward can detect it and skip writing its result — without + // this guard the resolving promise would still call writeSnapshot() and + // repopulate sessionStorage even though the cache was just purged. + let generation = 0 /** Get (or create) the reactive entry for a key, hydrating from the * sessionStorage snapshot on first sight so revisits after a reload paint @@ -84,17 +89,22 @@ export const useResourcesStore = defineStore('resources', () => { ): Promise { const existing = inflight.get(key) if (existing) return existing + const startGeneration = generation const e = entry(key, opts.persist ?? true) e.loadState = e.loadState === 'ready' || e.loadState === 'refreshing' ? 'refreshing' : 'loading' const p = (async () => { try { const data = await fetcher() + // A clearAll() (logout) ran while this fetch was in flight — drop + // the result rather than repopulate a cache that was just purged. + if (generation !== startGeneration) return e.data = data e.error = null e.fetchedAt = Date.now() e.loadState = 'ready' if (opts.persist ?? true) writeSnapshot(key, data, e.fetchedAt) } catch (err) { + if (generation !== startGeneration) return e.error = err instanceof Error ? err.message : String(err) // Keep last-known data visible; only 'error' when we have nothing. e.loadState = e.data !== null ? 'ready' : 'error' @@ -162,5 +172,29 @@ export const useResourcesStore = defineStore('resources', () => { } } - return { entries, entry, refresh, invalidate, subscribe, optimistic, evict } + /** Purge every cached resource — memory and sessionStorage — so no payload + * from this session outlives a logout or identity switch (T-02-02). Cancels + * pending invalidate timers and drops in-flight/revalidator bookkeeping + * first, so a fetch that resolves after this call cannot repopulate the + * cache with data from the ending session. */ + function clearAll(): void { + generation++ + for (const timer of invalidateTimers.values()) clearTimeout(timer) + invalidateTimers.clear() + inflight.clear() + revalidators.clear() + entries.clear() + try { + const keys: string[] = [] + for (let i = 0; i < sessionStorage.length; i++) { + const k = sessionStorage.key(i) + if (k && k.startsWith(SNAPSHOT_PREFIX)) keys.push(k) + } + for (const k of keys) sessionStorage.removeItem(k) + } catch { + /* sessionStorage unavailable or inaccessible — memory is already clear */ + } + } + + return { entries, entry, refresh, invalidate, subscribe, optimistic, evict, clearAll } })