fix(client): prevent cached state leaking across user sessions

Use request generation counters in composables to ignore in-flight fetches that resolve after a state reset. This ensures that user-scoped data like smart scopes, libraries, and collections do not persist across user switches.

Closes #510
This commit is contained in:
neon
2026-07-02 23:10:15 +00:00
parent 283d9ba6d9
commit 13be42792d
8 changed files with 355 additions and 11 deletions
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
type VoidFn = () => void
const routerPushMock = vi.hoisted(() => vi.fn<(to: string) => void>())
const setAccessTokenMock = vi.hoisted(() => vi.fn<(token: string | null) => void>())
const setOnAuthFailureMock = vi.hoisted(() => vi.fn<(fn: VoidFn) => void>())
const refreshAccessTokenMock = vi.hoisted(() => vi.fn<() => Promise<string>>())
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>())
const resetLibrariesMock = vi.hoisted(() => vi.fn<VoidFn>())
const resetSmartScopesMock = vi.hoisted(() => vi.fn<VoidFn>())
const resetCollectionsMock = vi.hoisted(() => vi.fn<VoidFn>())
const resetWhatsNewMock = vi.hoisted(() => vi.fn<VoidFn>())
const cancelPendingThemeSyncMock = vi.hoisted(() => vi.fn<VoidFn>())
const cancelPendingDisplaySettingsSyncMock = vi.hoisted(() => vi.fn<VoidFn>())
const disconnectAuthorEnrichmentSocketMock = vi.hoisted(() => vi.fn<VoidFn>())
const disconnectBookMetadataFetchSocketMock = vi.hoisted(() => vi.fn<VoidFn>())
vi.mock('@/router', () => ({
default: {
push: routerPushMock,
currentRoute: { value: { query: {} } },
},
}))
vi.mock('@/lib/api', () => ({
api: apiMock,
refreshAccessToken: refreshAccessTokenMock,
setAccessToken: setAccessTokenMock,
setOnAuthFailure: setOnAuthFailureMock,
}))
vi.mock('@/composables/useThemeSync', () => ({
cancelPendingThemeSync: cancelPendingThemeSyncMock,
initThemeSync: vi.fn<VoidFn>(),
loadFromServer: vi.fn<() => Promise<void>>(),
}))
vi.mock('@/composables/useDisplaySettingsSync', () => ({
cancelPendingDisplaySettingsSync: cancelPendingDisplaySettingsSyncMock,
initDisplaySettingsSync: vi.fn<VoidFn>(),
loadDisplaySettingsFromServer: vi.fn<() => Promise<void>>(),
}))
vi.mock('../useSetupStatus', () => ({
useSetupStatus: () => ({
needsSetup: { value: false },
}),
}))
vi.mock('@/features/settings/composables/useAuthorEnrichmentStatus', () => ({
disconnectAuthorEnrichmentSocket: disconnectAuthorEnrichmentSocketMock,
}))
vi.mock('@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus', () => ({
disconnectBookMetadataFetchSocket: disconnectBookMetadataFetchSocketMock,
}))
vi.mock('@/features/whats-new/composables/useWhatsNew', () => ({
resetWhatsNew: resetWhatsNewMock,
}))
vi.mock('@/features/library/composables/useLibraries', () => ({
resetLibraries: resetLibrariesMock,
}))
vi.mock('@/features/smart-scope/composables/useSmartScopes', () => ({
resetSmartScopes: resetSmartScopesMock,
}))
vi.mock('@/features/collection/composables/useCollections', () => ({
resetCollections: resetCollectionsMock,
}))
describe('useAuth', () => {
beforeEach(() => {
vi.resetModules()
routerPushMock.mockReset()
setAccessTokenMock.mockReset()
setOnAuthFailureMock.mockReset()
refreshAccessTokenMock.mockReset()
apiMock.mockReset()
resetLibrariesMock.mockReset()
resetSmartScopesMock.mockReset()
resetCollectionsMock.mockReset()
resetWhatsNewMock.mockReset()
cancelPendingThemeSyncMock.mockReset()
cancelPendingDisplaySettingsSyncMock.mockReset()
disconnectAuthorEnrichmentSocketMock.mockReset()
disconnectBookMetadataFetchSocketMock.mockReset()
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>().mockResolvedValue({
ok: true,
json: async () => ({}),
} as Response),
)
})
it('clears user-scoped sidebar caches on logout', async () => {
const { useAuth } = await import('../useAuth')
await useAuth().logout()
expect(resetLibrariesMock).toHaveBeenCalledTimes(1)
expect(resetSmartScopesMock).toHaveBeenCalledTimes(1)
expect(resetCollectionsMock).toHaveBeenCalledTimes(1)
expect(setAccessTokenMock).toHaveBeenCalledWith(null)
expect(routerPushMock).toHaveBeenCalledWith('/login')
})
})
@@ -8,6 +8,9 @@ import { useSetupStatus } from './useSetupStatus'
import { disconnectAuthorEnrichmentSocket } from '@/features/settings/composables/useAuthorEnrichmentStatus'
import { disconnectBookMetadataFetchSocket } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus'
import { resetWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
import { resetLibraries } from '@/features/library/composables/useLibraries'
import { resetSmartScopes } from '@/features/smart-scope/composables/useSmartScopes'
import { resetCollections } from '@/features/collection/composables/useCollections'
const SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1000
@@ -49,6 +52,9 @@ function clearAuth() {
cancelPendingThemeSync()
cancelPendingDisplaySettingsSync()
stopSessionRefresh()
resetLibraries()
resetSmartScopes()
resetCollections()
user.value = null
setAccessToken(null)
disconnectAuthorEnrichmentSocket()
@@ -111,4 +111,30 @@ describe('useCollections', () => {
await expect(removeBooksFromCollection(1, { bookIds: [9] })).rejects.toThrow('Failed to remove books from collection')
})
it('resets cached collections so the next fetch reloads them', async () => {
const first = makeCollection({ id: 1, name: 'Owner Collection' })
const second = makeCollection({ id: 2, name: 'Next User Collection' })
apiMock.mockResolvedValueOnce(makeResponse([first])).mockResolvedValueOnce(makeResponse([second]))
const { resetCollections, useCollections } = await import('../useCollections')
const { collections, loaded, fetchCollections } = useCollections()
await fetchCollections()
await fetchCollections()
expect(apiMock).toHaveBeenCalledTimes(1)
expect(collections.value).toEqual([first])
expect(loaded.value).toBe(true)
resetCollections()
expect(collections.value).toEqual([])
expect(loaded.value).toBe(false)
await fetchCollections()
expect(apiMock).toHaveBeenCalledTimes(2)
expect(collections.value).toEqual([second])
expect(loaded.value).toBe(true)
})
})
@@ -7,6 +7,16 @@ const loaded = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
let fetchPromise: Promise<void> | null = null
let requestGeneration = 0
export function resetCollections(): void {
requestGeneration += 1
collections.value = []
loaded.value = false
loading.value = false
error.value = null
fetchPromise = null
}
export function useCollections() {
async function fetchCollections(): Promise<void> {
@@ -14,20 +24,26 @@ export function useCollections() {
if (fetchPromise) return fetchPromise
loading.value = true
error.value = null
const generation = requestGeneration
fetchPromise = api('/api/v1/collections')
.then(async (res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
collections.value = await res.json()
const nextCollections: Collection[] = await res.json()
if (generation !== requestGeneration) return
collections.value = nextCollections
loaded.value = true
})
.catch((e: unknown) => {
if (generation !== requestGeneration) return
error.value = e instanceof Error ? e.message : 'Failed to load collections'
})
.finally(() => {
loading.value = false
fetchPromise = null
if (generation === requestGeneration) {
loading.value = false
fetchPromise = null
}
})
return fetchPromise
}
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Library } from '@bookorbit/types'
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>())
vi.mock('@/lib/api', () => ({
api: apiMock,
}))
function makeLibrary(overrides: Partial<Library> = {}): Library {
return {
id: 3,
name: 'Main Library',
icon: null,
displayOrder: 0,
coverAspectRatio: '2/3',
watch: false,
autoScanCronExpression: null,
metadataPrecedence: [],
formatPriority: [],
allowedFormats: [],
organizationMode: 'book_per_file',
excludePatterns: [],
readingThreshold: 10,
markAsFinishedPercentComplete: 95,
fileNamingPattern: null,
fileWriteEnabled: false,
fileWriteWriteCover: false,
fileWriteEpubEnabled: false,
fileWriteEpubMaxFileSizeMb: 50,
fileWritePdfEnabled: false,
fileWritePdfMaxFileSizeMb: 50,
fileWriteCbxEnabled: false,
fileWriteCbxMaxFileSizeMb: 50,
fileWriteAudioEnabled: false,
fileWriteAudioMaxFileSizeMb: 50,
fileRenameEnabled: false,
folders: [],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
}
}
function makeResponse(data?: unknown, ok = true): Response {
return {
ok,
json: async () => data,
} as Response
}
describe('useLibraries', () => {
beforeEach(() => {
vi.resetModules()
apiMock.mockReset()
})
it('resets cached libraries so the next fetch reloads them', async () => {
const first = makeLibrary({ id: 1, name: 'Owner Library' })
const second = makeLibrary({ id: 2, name: 'Next User Library' })
apiMock.mockResolvedValueOnce(makeResponse([first])).mockResolvedValueOnce(makeResponse([second]))
const { resetLibraries, useLibraries } = await import('../useLibraries')
const { libraries, loaded, fetchLibraries } = useLibraries()
await fetchLibraries()
await fetchLibraries()
expect(apiMock).toHaveBeenCalledTimes(1)
expect(libraries.value).toEqual([first])
expect(loaded.value).toBe(true)
resetLibraries()
expect(libraries.value).toEqual([])
expect(loaded.value).toBe(false)
await fetchLibraries()
expect(apiMock).toHaveBeenCalledTimes(2)
expect(libraries.value).toEqual([second])
expect(loaded.value).toBe(true)
})
it('ignores an in-flight fetch after libraries are reset', async () => {
const stale = makeLibrary({ id: 1, name: 'Stale Library' })
let resolveFetch!: (response: Response) => void
apiMock.mockReturnValueOnce(new Promise<Response>((resolve) => (resolveFetch = resolve)))
const { resetLibraries, useLibraries } = await import('../useLibraries')
const { libraries, loaded, loading, fetchLibraries } = useLibraries()
const fetchPromise = fetchLibraries()
expect(loading.value).toBe(true)
resetLibraries()
resolveFetch(makeResponse([stale]))
await fetchPromise
expect(libraries.value).toEqual([])
expect(loaded.value).toBe(false)
expect(loading.value).toBe(false)
})
})
@@ -6,6 +6,15 @@ const libraries = ref<Library[]>([])
const loading = ref(false)
const loaded = ref(false)
let fetchPromise: Promise<void> | null = null
let requestGeneration = 0
export function resetLibraries(): void {
requestGeneration += 1
libraries.value = []
loading.value = false
loaded.value = false
fetchPromise = null
}
export function useLibraries() {
async function fetchLibraries(): Promise<void> {
@@ -16,15 +25,20 @@ export function useLibraries() {
async function refreshLibraries(): Promise<void> {
if (fetchPromise) return fetchPromise
loading.value = true
const generation = requestGeneration
fetchPromise = api('/api/v1/libraries')
.then(async (res) => {
if (!res.ok) return
libraries.value = await res.json()
const nextLibraries: Library[] = await res.json()
if (generation !== requestGeneration) return
libraries.value = nextLibraries
loaded.value = true
})
.finally(() => {
fetchPromise = null
loading.value = false
if (generation === requestGeneration) {
fetchPromise = null
loading.value = false
}
})
return fetchPromise
}
@@ -65,4 +65,50 @@ describe('useSmartScopes', () => {
expect(smartScopes.value).toEqual([updated])
expect(smartScopes.value).not.toBe(previous)
})
it('resets cached smartScopes so the next fetch reloads them', async () => {
const first = makeSmartScope({ id: 1, name: 'Owner Scope' })
const second = makeSmartScope({ id: 2, userId: 4, name: 'Next User Scope' })
apiMock.mockResolvedValueOnce(makeResponse([first])).mockResolvedValueOnce(makeResponse([second]))
const { resetSmartScopes, useSmartScopes } = await import('../useSmartScopes')
const { smartScopes, loaded, fetchSmartScopes } = useSmartScopes()
await fetchSmartScopes()
await fetchSmartScopes()
expect(apiMock).toHaveBeenCalledTimes(1)
expect(smartScopes.value).toEqual([first])
expect(loaded.value).toBe(true)
resetSmartScopes()
expect(smartScopes.value).toEqual([])
expect(loaded.value).toBe(false)
await fetchSmartScopes()
expect(apiMock).toHaveBeenCalledTimes(2)
expect(smartScopes.value).toEqual([second])
expect(loaded.value).toBe(true)
})
it('ignores an in-flight fetch after smartScopes are reset', async () => {
const stale = makeSmartScope({ id: 1, name: 'Stale Scope' })
let resolveFetch!: (response: Response) => void
apiMock.mockReturnValueOnce(new Promise<Response>((resolve) => (resolveFetch = resolve)))
const { resetSmartScopes, useSmartScopes } = await import('../useSmartScopes')
const { smartScopes, loaded, loading, fetchSmartScopes } = useSmartScopes()
const fetchPromise = fetchSmartScopes()
expect(loading.value).toBe(true)
resetSmartScopes()
resolveFetch(makeResponse([stale]))
await fetchPromise
expect(smartScopes.value).toEqual([])
expect(loaded.value).toBe(false)
expect(loading.value).toBe(false)
})
})
@@ -7,6 +7,16 @@ const loaded = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
let fetchPromise: Promise<void> | null = null
let requestGeneration = 0
export function resetSmartScopes(): void {
requestGeneration += 1
smartScopes.value = []
loaded.value = false
loading.value = false
error.value = null
fetchPromise = null
}
export function useSmartScopes() {
async function fetchSmartScopes() {
@@ -14,20 +24,26 @@ export function useSmartScopes() {
if (fetchPromise) return fetchPromise
loading.value = true
error.value = null
const generation = requestGeneration
fetchPromise = api('/api/v1/smart-scopes')
.then(async (res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
smartScopes.value = await res.json()
const nextSmartScopes: SmartScope[] = await res.json()
if (generation !== requestGeneration) return
smartScopes.value = nextSmartScopes
loaded.value = true
})
.catch((e: unknown) => {
if (generation !== requestGeneration) return
error.value = e instanceof Error ? e.message : 'Failed to load smart scopes'
})
.finally(() => {
loading.value = false
fetchPromise = null
if (generation === requestGeneration) {
loading.value = false
fetchPromise = null
}
})
return fetchPromise
}
@@ -35,17 +51,23 @@ export function useSmartScopes() {
async function refreshSmartScopes() {
loading.value = true
error.value = null
const generation = requestGeneration
try {
const res = await api('/api/v1/smart-scopes')
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
smartScopes.value = await res.json()
const nextSmartScopes: SmartScope[] = await res.json()
if (generation !== requestGeneration) return
smartScopes.value = nextSmartScopes
loaded.value = true
} catch (e: unknown) {
if (generation !== requestGeneration) return
error.value = e instanceof Error ? e.message : 'Failed to load smart scopes'
} finally {
loading.value = false
if (generation === requestGeneration) {
loading.value = false
}
}
}