feat(02-03): purge every cached resource on logout
All checks were successful
Demo images / Build & push demo images (push) Successful in 3m34s
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:
parent
a9a20039eb
commit
f44b8ac78c
139
neode-ui/src/stores/__tests__/resourcesClear.test.ts
Normal file
139
neode-ui/src/stores/__tests__/resourcesClear.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 }
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user