feat(02-03): purge every cached resource on logout
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m34s

- resources.ts: clearAll() drops memory entries, in-flight/revalidator/
  invalidate-timer bookkeeping, and every resource:-prefixed sessionStorage
  key; a generation counter stops an in-flight fetch that resolves after
  clearAll from repopulating memory or sessionStorage (T-02-02)
- auth.ts: logout() calls clearAll() in the finally path so a failed
  server-side logout still leaves no cached payload behind locally
- resourcesClear.test.ts: covers all five required behaviors plus the
  generation-guard fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-30 08:01:01 -04:00
parent a9a20039eb
commit f44b8ac78c
3 changed files with 179 additions and 1 deletions

View File

@ -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<string>((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<string>('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)
})
})

View File

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

View File

@ -56,6 +56,11 @@ export const useResourcesStore = defineStore('resources', () => {
const inflight = new Map<string, Promise<void>>()
const revalidators = new Map<string, Set<() => void>>()
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>()
// 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<void> {
const existing = inflight.get(key)
if (existing) return existing
const startGeneration = generation
const e = entry<T>(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 }
})