Files
archy/neode-ui/src/stores/__tests__/resourcesClear.test.ts
T

258 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useResourcesStore, SCHEMA_MARKER_KEY, CURRENT_SCHEMA_VERSION } 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', { persist: true })
await store.refresh('k2', async () => 'b', { persist: true })
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', { persist: true })
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', { persist: true })
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, { persist: true })
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', true)
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', { persist: true })
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', { persist: true })
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', { persist: true })
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', { persist: true })
expect(resources.entries.size).toBe(1)
const { useAuthStore } = await import('../auth')
const auth = useAuthStore()
await auth.logout()
expect(resources.entries.size).toBe(0)
})
})
describe('resources store — one-time schema migration purge (CR-01 follow-up, T-02-01)', () => {
beforeEach(() => {
sessionStorage.clear()
localStorage.clear()
})
it('purges a legacy resource: snapshot written with no marker at all (pre-migration tab)', () => {
// Simulates the exact incident scenario: a tab that was open before this
// migration shipped, holding a snapshot the OLD bundle wrote under the
// OLD (now-incorrect) persist decision — e.g. CR-01's web5.lnd-info.
sessionStorage.setItem('resource:web5.lnd-info', JSON.stringify({
data: { balance_sats: 50000, channel_balance_sats: 25000, synced_to_chain: true },
fetchedAt: Date.now(),
}))
setActivePinia(createPinia())
useResourcesStore() // store setup() runs the migration check synchronously
expect(sessionStorage.getItem('resource:web5.lnd-info')).toBeNull()
})
it('purges every resource: snapshot when the marker is present but stale (persist contract changed since)', () => {
sessionStorage.setItem(SCHEMA_MARKER_KEY, 'some-older-version-string')
sessionStorage.setItem('resource:some-key', JSON.stringify({ data: 'stale-contract-data', fetchedAt: Date.now() }))
setActivePinia(createPinia())
useResourcesStore()
expect(sessionStorage.getItem('resource:some-key')).toBeNull()
})
it('writes the current marker after purging, so this is a one-time cost per tab session', () => {
sessionStorage.setItem('resource:legacy', JSON.stringify({ data: 'x', fetchedAt: Date.now() }))
setActivePinia(createPinia())
useResourcesStore()
expect(sessionStorage.getItem(SCHEMA_MARKER_KEY)).toBe(CURRENT_SCHEMA_VERSION)
})
it('does NOT purge — a snapshot written under the CURRENT marker survives a later store init (one-time, not every-boot)', () => {
// Marker already matches CURRENT_SCHEMA_VERSION, as if this same tab
// already ran the migration once (e.g. an earlier navigation/reload).
sessionStorage.setItem(SCHEMA_MARKER_KEY, CURRENT_SCHEMA_VERSION)
const snapshot = JSON.stringify({ data: 'keep-me', fetchedAt: Date.now() })
sessionStorage.setItem('resource:already-migrated-key', snapshot)
// A second store "init" — e.g. a fresh Pinia instance, standing in for
// another reload of the same tab (sessionStorage persists across it).
setActivePinia(createPinia())
useResourcesStore()
// Byte-for-byte untouched — the migration was a no-op this time.
expect(sessionStorage.getItem('resource:already-migrated-key')).toBe(snapshot)
})
it('a persist:false key never writes a sessionStorage snapshot in the first place', async () => {
setActivePinia(createPinia())
const store = useResourcesStore()
await store.refresh('web5.lnd-info', async () => ({ balance_sats: 1, channel_balance_sats: 2 }), { persist: false })
expect(sessionStorage.getItem('resource:web5.lnd-info')).toBeNull()
})
it('migration purge is strictly bounded to the resource: prefix — every other storage key (localStorage auth, onboarding seed scratch, PWA/splash flags, hand-rolled view caches) survives untouched', () => {
// A representative sample of this app's real non-`resource:` storage
// usage, seeded BEFORE a legacy snapshot forces the purge to actually
// run — proving the purge cannot log anyone out or destroy unrelated
// state (Dorian's blast-radius requirement).
localStorage.setItem('neode-auth', 'true') // auth session flag (localStorage, not sessionStorage — untouched by construction)
sessionStorage.setItem('archipelago_from_boot', '1')
sessionStorage.setItem('archipelago_from_splash', '1')
sessionStorage.setItem('_seed_words', JSON.stringify(['abandon', 'ability']))
sessionStorage.setItem('_seed_challenge_indices', JSON.stringify([1, 2, 3]))
sessionStorage.setItem('archipelago.web5.identities.v1', JSON.stringify({ v: 1, savedAt: Date.now(), identities: [] }))
sessionStorage.setItem('archipelago.fleet.cache.v1', JSON.stringify({ v: 1 }))
sessionStorage.setItem('unrelated-app-setting', 'keep-me')
// The thing that actually triggers the purge this run:
sessionStorage.setItem('resource:legacy-key', JSON.stringify({ data: 'x', fetchedAt: Date.now() }))
setActivePinia(createPinia())
useResourcesStore()
// The purge fired (proves the test actually exercised the purge path).
expect(sessionStorage.getItem('resource:legacy-key')).toBeNull()
// Everything else survives byte-for-byte.
expect(localStorage.getItem('neode-auth')).toBe('true')
expect(sessionStorage.getItem('archipelago_from_boot')).toBe('1')
expect(sessionStorage.getItem('archipelago_from_splash')).toBe('1')
expect(sessionStorage.getItem('_seed_words')).toBe(JSON.stringify(['abandon', 'ability']))
expect(sessionStorage.getItem('_seed_challenge_indices')).toBe(JSON.stringify([1, 2, 3]))
expect(sessionStorage.getItem('archipelago.web5.identities.v1')).not.toBeNull()
expect(sessionStorage.getItem('archipelago.fleet.cache.v1')).not.toBeNull()
expect(sessionStorage.getItem('unrelated-app-setting')).toBe('keep-me')
})
it('the migration cannot race an in-flight fetch — it runs synchronously at store setup, before any entry()/refresh() call is possible, and a refresh immediately afterward behaves normally', async () => {
sessionStorage.setItem('resource:legacy-key', JSON.stringify({ data: 'x', fetchedAt: Date.now() }))
setActivePinia(createPinia())
const store = useResourcesStore()
// Nothing was, or could have been, in flight during the migration: the
// store's internal bookkeeping is still pristine immediately after
// construction (entries empty, no leaked in-flight promise).
expect(store.entries.size).toBe(0)
// A normal refresh right after store construction is unaffected by the
// migration having just run — proves no corruption/wedged state.
await store.refresh('fresh-key', async () => 'fresh-data', { persist: true })
const e = store.entry<string>('fresh-key', true)
expect(e.data).toBe('fresh-data')
expect(e.loadState).toBe('ready')
expect(sessionStorage.getItem('resource:fresh-key')).not.toBeNull()
})
})