mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user