Harden the API and the data layer after a security and performance audit
Security - Every route now requires a login and refuses ids outside the caller's workspace with 404: plan, leave-wishes, day-overrides and vacation-days were fully anonymous; every other router trusted client-supplied workspace/child/parent ids. Scraper sessions are bound to their owner. - Invitations can no longer be created for a foreign workspace, and a redeemer who is already a member (or a full workspace) is refused. - Production refuses the default JWT secret; API docs are hidden in production; a failed migration aborts startup instead of serving. - Login and register no longer reveal whether an email exists; inactive accounts are rejected; /auth/refresh is rate limited. - The FK scraper writes debug artefacts only when explicitly enabled, logs no scraped values or page text, clears the BankID QR as soon as it is scanned and purges finished sessions after five minutes. - Real FK page captures are removed from the tree (history rewrite follows); the parser tests use a synthetic fixture. Performance and correctness - Plan apply/sync run inside one locked transaction with batched writes instead of one commit per row, so a failure mid-batch leaves nothing half-applied and concurrent syncs no longer corrupt each other. - SQLite runs in WAL mode with a busy timeout; 14 indexes cover the filters that were full scans; argon2 hashing runs off the event loop; responses are gzipped. - Frontend: a 401 now refreshes the token once and retries, so sessions no longer die silently after 15 minutes; every data hook discards stale responses; logout clears state even when the server call fails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsXCCcDfmV4Sps4ZQSvNEY
This commit is contained in:
@@ -128,4 +128,20 @@ describe('Layout', () => {
|
||||
expect(logout).toHaveBeenCalled()
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/auth', { replace: true })
|
||||
})
|
||||
|
||||
it('still navigates to /auth when logout rejects (S3 — best-effort server call)', async () => {
|
||||
const logout = vi.fn().mockRejectedValue(new Error('network down'))
|
||||
mockUseAuth.mockReturnValue({
|
||||
accessToken: 'token',
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout,
|
||||
})
|
||||
renderLayout()
|
||||
await userEvent.click(screen.getByTestId('logout-button'))
|
||||
expect(logout).toHaveBeenCalled()
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/auth', { replace: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,8 +19,16 @@ export function Layout({ children, header }: LayoutProps): ReactNode {
|
||||
const selectedChild = childrenList.find((c) => c.id === selectedChildId)
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await logout()
|
||||
navigate('/auth', { replace: true })
|
||||
try {
|
||||
await logout()
|
||||
} catch {
|
||||
// Best-effort server call — AuthContext already clears local
|
||||
// token/state in its own `finally` even when this rejects. Swallow it
|
||||
// here so a network failure during logout doesn't surface as an
|
||||
// unhandled rejection; still navigate away below.
|
||||
} finally {
|
||||
navigate('/auth', { replace: true })
|
||||
}
|
||||
}, [logout, navigate])
|
||||
|
||||
const headerProps: HeaderProps = {
|
||||
|
||||
@@ -122,6 +122,47 @@ describe('AuthContext', () => {
|
||||
expect(result.current.accessToken).toBeNull()
|
||||
})
|
||||
|
||||
it('clears local state even when the server logout call rejects (S3 — offline logout)', async () => {
|
||||
vi.mocked(api.refreshToken).mockResolvedValue(tokenResponse)
|
||||
vi.mocked(api.logout).mockRejectedValue(new Error('network down'))
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
await waitFor(() => expect(result.current.isAuthenticated).toBe(true))
|
||||
|
||||
// The rejection still propagates to the caller (Layout decides how to
|
||||
// handle a failed best-effort server call) — but local state must
|
||||
// already be cleared by the time it does.
|
||||
await act(async () => {
|
||||
await expect(result.current.logout()).rejects.toThrow('network down')
|
||||
})
|
||||
|
||||
expect(api.setAccessToken).toHaveBeenCalledWith(null)
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
expect(result.current.accessToken).toBeNull()
|
||||
})
|
||||
|
||||
it('logs the user out when apiFetch reports a failed refresh-and-retry (S1 interceptor wiring)', async () => {
|
||||
vi.mocked(api.refreshToken).mockResolvedValue(tokenResponse)
|
||||
|
||||
const { result } = renderHook(() => useAuth(), { wrapper })
|
||||
await waitFor(() => expect(result.current.isAuthenticated).toBe(true))
|
||||
|
||||
// Simulate services/api.ts's apiFetch invoking the handler AuthContext
|
||||
// registered via api.setUnauthorizedHandler after a mid-session 401's
|
||||
// refresh-and-retry gives up.
|
||||
const registeredHandler = vi.mocked(api.setUnauthorizedHandler).mock.calls.find(
|
||||
(call) => call[0] !== null,
|
||||
)?.[0]
|
||||
expect(registeredHandler).toBeInstanceOf(Function)
|
||||
|
||||
act(() => {
|
||||
registeredHandler?.()
|
||||
})
|
||||
|
||||
expect(result.current.isAuthenticated).toBe(false)
|
||||
expect(result.current.accessToken).toBeNull()
|
||||
})
|
||||
|
||||
it('throws when useAuth is used outside an AuthProvider', () => {
|
||||
expect(() => renderHook(() => useAuth())).toThrow(
|
||||
'useAuth must be used inside <AuthProvider>',
|
||||
|
||||
@@ -42,6 +42,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// apiFetch's own refresh-and-retry (see services/api.ts) handles a 401 hit
|
||||
// mid-session by refreshing once and retrying the original request. If
|
||||
// that refresh itself fails, this is the hook that actually logs the user
|
||||
// out — without it, isAuthenticated would stay true forever while every
|
||||
// subsequent request 401s.
|
||||
useEffect(() => {
|
||||
api.setUnauthorizedHandler(() => {
|
||||
setState({ accessToken: null, isAuthenticated: false, isLoading: false })
|
||||
})
|
||||
return () => api.setUnauthorizedHandler(null)
|
||||
}, [])
|
||||
|
||||
const handleTokenResponse = useCallback((res: TokenResponse) => {
|
||||
api.setAccessToken(res.access_token)
|
||||
setState({ accessToken: res.access_token, isAuthenticated: true, isLoading: false })
|
||||
@@ -64,9 +76,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.logout()
|
||||
api.setAccessToken(null)
|
||||
setState({ accessToken: null, isAuthenticated: false, isLoading: false })
|
||||
// Clear local state even if the server call fails (offline logout must
|
||||
// still work) — but still let the failure propagate to the caller so it
|
||||
// can decide how to handle a best-effort server call that didn't land.
|
||||
try {
|
||||
await api.logout()
|
||||
} finally {
|
||||
api.setAccessToken(null)
|
||||
setState({ accessToken: null, isAuthenticated: false, isLoading: false })
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -217,7 +217,12 @@ describe('WorkspaceContext', () => {
|
||||
expect(result.current.selectedChildId).toBe('c2')
|
||||
})
|
||||
|
||||
it('setWorkspaceId stores the id and updates state', async () => {
|
||||
it('setWorkspaceId stores the id, updates state, and fetches that workspace directly', async () => {
|
||||
// P2 in the frontend audit: this used to rely on a reactive effect that
|
||||
// only fired while `workspace` was still null, so switching workspaces
|
||||
// after the initial load silently never fetched the new one. Fetching
|
||||
// explicitly here fixes that without reintroducing the cold-load
|
||||
// duplicate GET /workspace the same effect used to cause.
|
||||
vi.mocked(useAuth).mockReturnValue(authenticated)
|
||||
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
|
||||
vi.mocked(api.getWorkspace).mockResolvedValue(workspace)
|
||||
@@ -225,12 +230,19 @@ describe('WorkspaceContext', () => {
|
||||
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
||||
await waitFor(() => expect(result.current.workspace).not.toBeNull())
|
||||
|
||||
const otherWorkspace: Workspace = { ...workspace, id: 'ws2', name: 'Other workspace' }
|
||||
vi.mocked(api.getWorkspace).mockResolvedValue(otherWorkspace)
|
||||
vi.mocked(api.getWorkspace).mockClear()
|
||||
|
||||
act(() => {
|
||||
result.current.setWorkspaceId('ws2')
|
||||
})
|
||||
|
||||
expect(result.current.workspaceId).toBe('ws2')
|
||||
expect(localStorage.getItem('workspaceId')).toBe('ws2')
|
||||
|
||||
await waitFor(() => expect(api.getWorkspace).toHaveBeenCalledWith('ws2'))
|
||||
await waitFor(() => expect(result.current.workspace).toEqual(otherWorkspace))
|
||||
})
|
||||
|
||||
it('refetch re-fetches the current workspace', async () => {
|
||||
@@ -322,6 +334,38 @@ describe('WorkspaceContext', () => {
|
||||
expect(result.current.workspace).toEqual(otherWorkspace)
|
||||
})
|
||||
|
||||
it('discards a refreshContext response that resolves after logout (S4)', async () => {
|
||||
vi.mocked(useAuth).mockReturnValue(authenticated)
|
||||
let resolveContext: (value: api.UserContext) => void = () => {}
|
||||
const pending = new Promise<api.UserContext>((resolve) => {
|
||||
resolveContext = resolve
|
||||
})
|
||||
vi.mocked(api.getUserContext).mockReturnValue(pending)
|
||||
|
||||
const { result, rerender } = renderHook(() => useWorkspace(), { wrapper })
|
||||
|
||||
// Logout happens while the initial refreshContext() call is still
|
||||
// in flight (e.g. a slow /auth/context response).
|
||||
vi.mocked(useAuth).mockReturnValue(unauthenticated)
|
||||
rerender()
|
||||
|
||||
await waitFor(() => expect(result.current.workspace).toBeNull())
|
||||
expect(result.current.workspaceId).toBeNull()
|
||||
|
||||
// The stale response arrives after logout — it must be discarded
|
||||
// rather than repopulating this account's context.
|
||||
await act(async () => {
|
||||
resolveContext(userContext)
|
||||
await pending
|
||||
})
|
||||
|
||||
expect(result.current.workspaceId).toBeNull()
|
||||
expect(result.current.parentName).toBeNull()
|
||||
expect(result.current.personalDataUuid).toBeNull()
|
||||
expect(result.current.children).toEqual([])
|
||||
expect(localStorage.getItem('workspaceId')).toBeNull()
|
||||
})
|
||||
|
||||
it('throws when useWorkspace is used outside a WorkspaceProvider', () => {
|
||||
vi.mocked(useAuth).mockReturnValue(unauthenticated)
|
||||
|
||||
|
||||
@@ -43,25 +43,38 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React.
|
||||
// absence) of cached data — see the effect for why that distinction is
|
||||
// what makes a login swap actually refetch.
|
||||
const wasAuthenticatedRef = useRef(false)
|
||||
// Bumped by clearWorkspaceState() (logout). A refreshContext/fetchWorkspace
|
||||
// call captures the generation it started with; if that generation is
|
||||
// stale by the time the response arrives (logout happened mid-flight),
|
||||
// the response is discarded instead of repopulating state for the
|
||||
// account that just logged out — see S4 in the frontend audit.
|
||||
const generationRef = useRef(0)
|
||||
|
||||
const fetchWorkspace = useCallback(async (id: string) => {
|
||||
const generation = generationRef.current
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const ws = await api.getWorkspace(id)
|
||||
if (generation !== generationRef.current) return
|
||||
setWorkspace(ws)
|
||||
} catch (err: unknown) {
|
||||
if (generation !== generationRef.current) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load workspace')
|
||||
setWorkspace(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (generation === generationRef.current) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshContext = useCallback(async () => {
|
||||
const generation = generationRef.current
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const context = await api.getUserContext()
|
||||
if (generation !== generationRef.current) return
|
||||
localStorage.setItem('workspaceId', context.workspace_id)
|
||||
setWorkspaceId(context.workspace_id)
|
||||
setPersonalDataUuid(context.personal_data_uuid)
|
||||
@@ -73,15 +86,20 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React.
|
||||
}
|
||||
try {
|
||||
const ws = await api.getWorkspace(context.workspace_id)
|
||||
if (generation !== generationRef.current) return
|
||||
setWorkspace(ws)
|
||||
} catch (err: unknown) {
|
||||
if (generation !== generationRef.current) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load workspace')
|
||||
setWorkspace(null)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (generation !== generationRef.current) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load user context')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (generation === generationRef.current) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -92,6 +110,7 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React.
|
||||
// one account's data could otherwise flash under another account's
|
||||
// session.
|
||||
const clearWorkspaceState = useCallback(() => {
|
||||
generationRef.current += 1
|
||||
localStorage.removeItem('workspaceId')
|
||||
setWorkspace(null)
|
||||
setWorkspaceId(null)
|
||||
@@ -123,16 +142,22 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React.
|
||||
}
|
||||
}, [isAuthenticated, refreshContext, clearWorkspaceState])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && workspaceId && !workspace) {
|
||||
void fetchWorkspace(workspaceId)
|
||||
}
|
||||
}, [isAuthenticated, workspaceId, workspace, fetchWorkspace])
|
||||
|
||||
const handleSetWorkspaceId = useCallback((id: string) => {
|
||||
localStorage.setItem('workspaceId', id)
|
||||
setWorkspaceId(id)
|
||||
}, [])
|
||||
// NOTE (P2 in the frontend audit): a cold login already fetches the
|
||||
// workspace once inside refreshContext() above. This used to be a
|
||||
// reactive `useEffect` on `[isAuthenticated, workspaceId, workspace]` that
|
||||
// fired a *second* GET /workspace as soon as `workspaceId` state landed
|
||||
// but before `workspace` itself did — a duplicate cold-load request.
|
||||
// Fetching explicitly here, only when the id is set imperatively (e.g. a
|
||||
// future workspace switcher), removes that duplicate without losing the
|
||||
// ability to load a workspace picked outside the login flow.
|
||||
const handleSetWorkspaceId = useCallback(
|
||||
(id: string) => {
|
||||
localStorage.setItem('workspaceId', id)
|
||||
setWorkspaceId(id)
|
||||
void fetchWorkspace(id)
|
||||
},
|
||||
[fetchWorkspace],
|
||||
)
|
||||
|
||||
const handleSelectChild = useCallback((id: string) => {
|
||||
setSelectedChildId(id)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { renderHook, act } from '@testing-library/react'
|
||||
import { useAnalytics } from './useAnalytics'
|
||||
import * as api from '@/services/api'
|
||||
import { makeWorkspaceOverview } from '../../tests/fixtures'
|
||||
import type { WorkspaceOverview } from '@/types'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
|
||||
@@ -123,4 +124,59 @@ describe('useAnalytics', () => {
|
||||
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
|
||||
it('discards a stale response for a previous childId (P1 stale-response guard)', async () => {
|
||||
let resolveFirst: (value: WorkspaceOverview) => void = () => {}
|
||||
let resolveSecond: (value: WorkspaceOverview) => void = () => {}
|
||||
const firstPromise = new Promise<WorkspaceOverview>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const secondPromise = new Promise<WorkspaceOverview>((resolve) => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
|
||||
vi.mocked(api.getWorkspaceOverview)
|
||||
.mockReturnValueOnce(firstPromise)
|
||||
.mockReturnValueOnce(secondPromise)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ childId }: { childId: string }) => useAnalytics('ws1', childId),
|
||||
{ initialProps: { childId: 'child1' } },
|
||||
)
|
||||
|
||||
// Switching childId while the first (slower) request is still in
|
||||
// flight fires a second request for the new child.
|
||||
rerender({ childId: 'child2' })
|
||||
|
||||
const child1Data = makeWorkspaceOverview({
|
||||
workspaceId: 'ws1',
|
||||
childId: 'child1',
|
||||
parentProjections: [],
|
||||
combinedDaysUsed: 1,
|
||||
combinedDaysLeft: 1,
|
||||
combinedExpiringAtB4: 0,
|
||||
})
|
||||
const child2Data = makeWorkspaceOverview({
|
||||
workspaceId: 'ws1',
|
||||
childId: 'child2',
|
||||
parentProjections: [],
|
||||
combinedDaysUsed: 5,
|
||||
combinedDaysLeft: 5,
|
||||
combinedExpiringAtB4: 0,
|
||||
})
|
||||
|
||||
// The newer (child2) request resolves first...
|
||||
await act(async () => {
|
||||
resolveSecond(child2Data)
|
||||
await secondPromise
|
||||
})
|
||||
expect(result.current.data).toEqual(child2Data)
|
||||
|
||||
// ...then the stale child1 response arrives late and must not overwrite it.
|
||||
await act(async () => {
|
||||
resolveFirst(child1Data)
|
||||
await firstPromise
|
||||
})
|
||||
expect(result.current.data).toEqual(child2Data)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { WorkspaceOverview } from '@/types'
|
||||
|
||||
interface UseAnalyticsResult {
|
||||
@@ -13,8 +14,10 @@ export function useAnalytics(workspaceId: string | null, childId?: string): UseA
|
||||
const [data, setData] = useState<WorkspaceOverview | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
const requestId = start()
|
||||
if (!workspaceId || !childId) {
|
||||
// The identity this data belongs to is gone (e.g. a logout cleared
|
||||
// the workspace/child selection) — clear rather than leave the
|
||||
@@ -27,14 +30,18 @@ export function useAnalytics(workspaceId: string | null, childId?: string): UseA
|
||||
setError(null)
|
||||
try {
|
||||
const response = await api.getWorkspaceOverview(workspaceId, childId)
|
||||
if (!isCurrent(requestId)) return
|
||||
setData(response)
|
||||
} catch (err) {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch analytics')
|
||||
setData(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (isCurrent(requestId)) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [workspaceId, childId])
|
||||
}, [workspaceId, childId, start, isCurrent])
|
||||
|
||||
useEffect(() => {
|
||||
fetch()
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { CalendarWindow } from '@/types'
|
||||
|
||||
interface UseCalendarDaysResult {
|
||||
@@ -32,20 +33,24 @@ export function useCalendarDays(
|
||||
const [data, setData] = useState<CalendarWindow | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
|
||||
const fetchWindow = useCallback(async () => {
|
||||
const requestId = start()
|
||||
if (!workspaceId) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.getCalendarDays(workspaceId, startYear, years)
|
||||
if (!isCurrent(requestId)) return
|
||||
setData(result)
|
||||
} catch (err: unknown) {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load calendar days')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId, startYear, years])
|
||||
}, [workspaceId, startYear, years, start, isCurrent])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchWindow()
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { Child } from '@/types'
|
||||
|
||||
export interface ChildSettingsUpdatePayload {
|
||||
@@ -24,8 +25,10 @@ export function useChildSettings(childId: string | null): UseChildSettingsResult
|
||||
const [data, setData] = useState<Child | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
const requestId = start()
|
||||
if (!childId) {
|
||||
setData(null)
|
||||
return
|
||||
@@ -34,13 +37,15 @@ export function useChildSettings(childId: string | null): UseChildSettingsResult
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.getChild(childId)
|
||||
if (!isCurrent(requestId)) return
|
||||
setData(result)
|
||||
} catch (err: unknown) {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load child')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
}
|
||||
}, [childId])
|
||||
}, [childId, start, isCurrent])
|
||||
|
||||
useEffect(() => {
|
||||
void fetch()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { DayOverride, DayOverrideCreateRequest } from '@/types'
|
||||
|
||||
interface UseDayOverridesResult {
|
||||
@@ -14,20 +15,24 @@ export function useDayOverrides(workspaceId: string): UseDayOverridesResult {
|
||||
const [data, setData] = useState<DayOverride[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
const requestId = start()
|
||||
if (!workspaceId) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.listDayOverridesByWorkspace(workspaceId)
|
||||
if (!isCurrent(requestId)) return
|
||||
setData(result)
|
||||
} catch (err: unknown) {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load day overrides')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId])
|
||||
}, [workspaceId, start, isCurrent])
|
||||
|
||||
useEffect(() => {
|
||||
void fetch()
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
|
||||
interface UseLatestRequestResult {
|
||||
/** Call at the start of a fetch to obtain this call's request id. */
|
||||
start: () => number
|
||||
/** True if `id` is still the most recently started request — i.e. no
|
||||
* newer call to `start()` has happened since. Guard every `setState` in
|
||||
* an async fetch with this so a stale response (e.g. from a fetch kicked
|
||||
* off for a since-replaced childId/workspaceId) can't overwrite state a
|
||||
* newer, faster request already set. */
|
||||
isCurrent: (id: number) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared stale-response guard for the data-fetching hooks in this
|
||||
* directory. Each hook has no cache/dedup layer (no TanStack Query), so
|
||||
* switching identity (e.g. the selected child) while a fetch is in flight
|
||||
* can otherwise let an older, slower response land after — and overwrite —
|
||||
* a newer one. See P1 in the frontend audit.
|
||||
*
|
||||
* Usage:
|
||||
* const { start, isCurrent } = useLatestRequest()
|
||||
* const fetch = useCallback(async () => {
|
||||
* const requestId = start()
|
||||
* const result = await api.get(...)
|
||||
* if (!isCurrent(requestId)) return
|
||||
* setData(result)
|
||||
* }, [...])
|
||||
*/
|
||||
export function useLatestRequest(): UseLatestRequestResult {
|
||||
const idRef = useRef(0)
|
||||
|
||||
const start = useCallback((): number => {
|
||||
idRef.current += 1
|
||||
return idRef.current
|
||||
}, [])
|
||||
|
||||
const isCurrent = useCallback((id: number): boolean => id === idRef.current, [])
|
||||
|
||||
return { start, isCurrent }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { LeaveWish, LeaveWishCreateRequest, LeaveWishUpdateRequest } from '@/types'
|
||||
|
||||
interface UseLeaveWishesResult {
|
||||
@@ -34,9 +35,11 @@ export function useLeaveWishes(workspaceId: string | null): UseLeaveWishesResult
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
|
||||
const fetch = useCallback(
|
||||
async (options?: { background?: boolean }) => {
|
||||
const requestId = start()
|
||||
if (!workspaceId) return
|
||||
const background = options?.background ?? false
|
||||
const setBusy = background ? setIsRefreshing : setIsLoading
|
||||
@@ -44,14 +47,16 @@ export function useLeaveWishes(workspaceId: string | null): UseLeaveWishesResult
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.getLeaveWishes(workspaceId)
|
||||
if (!isCurrent(requestId)) return
|
||||
setData(result)
|
||||
} catch (err: unknown) {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load leave wishes')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
if (isCurrent(requestId)) setBusy(false)
|
||||
}
|
||||
},
|
||||
[workspaceId],
|
||||
[workspaceId, start, isCurrent],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { Tip } from '@/types'
|
||||
|
||||
interface UseOptimizationTipsResult {
|
||||
@@ -15,21 +16,25 @@ export function useOptimizationTips(workspaceId: string | null): UseOptimization
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set())
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
const requestId = start()
|
||||
if (!workspaceId) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await api.getTips(workspaceId)
|
||||
if (!isCurrent(requestId)) return
|
||||
setData(response)
|
||||
} catch (err) {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch tips')
|
||||
setData(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId])
|
||||
}, [workspaceId, start, isCurrent])
|
||||
|
||||
const dismiss = useCallback(
|
||||
async (tipId: string) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { ParentalLeaveFetch } from '@/types'
|
||||
|
||||
interface UseParentalLeaveResult {
|
||||
@@ -16,8 +17,10 @@ export function useParentalLeave(
|
||||
const [data, setData] = useState<ParentalLeaveFetch | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
const requestId = start()
|
||||
if (!personalDataUuid || !childId) {
|
||||
// The identity this data belongs to is gone (e.g. a logout cleared
|
||||
// the parent/child selection) — clear rather than leave the previous
|
||||
@@ -30,14 +33,16 @@ export function useParentalLeave(
|
||||
setError(null)
|
||||
try {
|
||||
const response = await api.getParentalLeave(personalDataUuid, childId)
|
||||
if (!isCurrent(requestId)) return
|
||||
setData(response)
|
||||
} catch (err) {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data')
|
||||
setData(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
}
|
||||
}, [personalDataUuid, childId])
|
||||
}, [personalDataUuid, childId, start, isCurrent])
|
||||
|
||||
useEffect(() => {
|
||||
fetch()
|
||||
|
||||
@@ -339,6 +339,65 @@ describe('usePlannedDays', () => {
|
||||
expect(updateError?.message).toBe('Update failed')
|
||||
})
|
||||
|
||||
it('discards a stale response for a previous childId (P1 stale-response guard)', async () => {
|
||||
let resolveFirst: (value: ReturnType<typeof makePlannedLeaveDay>[]) => void = () => {}
|
||||
let resolveSecond: (value: ReturnType<typeof makePlannedLeaveDay>[]) => void = () => {}
|
||||
const firstPromise = new Promise<ReturnType<typeof makePlannedLeaveDay>[]>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const secondPromise = new Promise<ReturnType<typeof makePlannedLeaveDay>[]>((resolve) => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
|
||||
vi.mocked(api.getPlannedDays).mockReturnValueOnce(firstPromise).mockReturnValueOnce(secondPromise)
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ childId }: { childId: string }) => usePlannedDays(childId),
|
||||
{ initialProps: { childId: 'child1' } },
|
||||
)
|
||||
|
||||
// Switching childId while the first (slower) request is still in
|
||||
// flight fires a second request for the new child.
|
||||
rerender({ childId: 'child2' })
|
||||
|
||||
const child1Data = [
|
||||
makePlannedLeaveDay({
|
||||
id: 'p1',
|
||||
workspaceId: 'ws1',
|
||||
childId: 'child1',
|
||||
parentPersonalDataUuid: 'parent1',
|
||||
date: '2026-06-01',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
}),
|
||||
]
|
||||
const child2Data = [
|
||||
makePlannedLeaveDay({
|
||||
id: 'p2',
|
||||
workspaceId: 'ws1',
|
||||
childId: 'child2',
|
||||
parentPersonalDataUuid: 'parent1',
|
||||
date: '2026-07-01',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
}),
|
||||
]
|
||||
|
||||
// The newer (child2) request resolves first...
|
||||
await act(async () => {
|
||||
resolveSecond(child2Data)
|
||||
await secondPromise
|
||||
})
|
||||
expect(result.current.data).toEqual(child2Data)
|
||||
|
||||
// ...then the stale child1 response arrives late and must not overwrite it.
|
||||
await act(async () => {
|
||||
resolveFirst(child1Data)
|
||||
await firstPromise
|
||||
})
|
||||
expect(result.current.data).toEqual(child2Data)
|
||||
})
|
||||
|
||||
it('handles remove error gracefully', async () => {
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
||||
vi.mocked(api.deletePlannedDay).mockRejectedValue(new Error('Delete failed'))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
|
||||
interface UsePlannedDaysResult {
|
||||
@@ -20,8 +21,10 @@ export function usePlannedDays(childId?: string): UsePlannedDaysResult {
|
||||
const [data, setData] = useState<PlannedLeaveDay[] | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
const requestId = start()
|
||||
if (!childId) {
|
||||
// The child this data belongs to is gone (e.g. a logout cleared the
|
||||
// selection) — clear rather than leave the previous account's
|
||||
@@ -34,16 +37,18 @@ export function usePlannedDays(childId?: string): UsePlannedDaysResult {
|
||||
setError(null)
|
||||
try {
|
||||
const response = await api.getPlannedDays(childId)
|
||||
setData(response)
|
||||
if (isCurrent(requestId)) setData(response)
|
||||
return response
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch planned days')
|
||||
setData(null)
|
||||
if (isCurrent(requestId)) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch planned days')
|
||||
setData(null)
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
}
|
||||
}, [childId])
|
||||
}, [childId, start, isCurrent])
|
||||
|
||||
const add = useCallback(
|
||||
async (payload: api.CreatePlannedDayPayload) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useScraperFetch } from './useScraperFetch'
|
||||
import type { PollResponse } from './useScraperFetch'
|
||||
import * as api from '@/services/api'
|
||||
import * as errors from '@/services/errors'
|
||||
import { makeScraperSessionStartResponse, makePollResponse } from '../../tests/fixtures'
|
||||
@@ -335,6 +336,81 @@ describe('useScraperFetch', () => {
|
||||
expect(result.current.sessionId).toBeNull()
|
||||
})
|
||||
|
||||
it('skips a poll tick while the previous one is still in flight (P4 overlap guard)', async () => {
|
||||
vi.mocked(api.startScraperSession).mockResolvedValue(
|
||||
makeScraperSessionStartResponse({
|
||||
session_id: 'sess-123',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
})
|
||||
)
|
||||
|
||||
let resolvePoll: (value: PollResponse) => void = () => {}
|
||||
const pending = new Promise<PollResponse>((resolve) => {
|
||||
resolvePoll = resolve
|
||||
})
|
||||
vi.mocked(api.pollScraperSession).mockReturnValueOnce(pending)
|
||||
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
// First tick starts the still-pending poll.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(api.pollScraperSession).toHaveBeenCalledTimes(1)
|
||||
|
||||
// A second tick fires while the first poll is still in flight — it must
|
||||
// be skipped rather than stacking a second concurrent request.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(api.pollScraperSession).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Resolve the first poll; the next tick is then free to run normally.
|
||||
vi.mocked(api.pollScraperSession).mockResolvedValue(makePollResponse({ status: 'awaiting_scan' }))
|
||||
await act(async () => {
|
||||
resolvePoll(makePollResponse({ status: 'authenticating' }))
|
||||
await pending
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(api.pollScraperSession).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('clears the polling interval on unmount (P4)', async () => {
|
||||
vi.mocked(api.startScraperSession).mockResolvedValue(
|
||||
makeScraperSessionStartResponse({
|
||||
session_id: 'sess-123',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
})
|
||||
)
|
||||
vi.mocked(api.pollScraperSession).mockResolvedValue(makePollResponse({ status: 'awaiting_scan' }))
|
||||
|
||||
const { result, unmount } = renderHook(() => useScraperFetch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
unmount()
|
||||
vi.mocked(api.pollScraperSession).mockClear()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000)
|
||||
})
|
||||
|
||||
expect(api.pollScraperSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles poll error', async () => {
|
||||
vi.mocked(api.startScraperSession).mockResolvedValue(
|
||||
makeScraperSessionStartResponse({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { isUnauthorized } from '@/services/errors'
|
||||
|
||||
@@ -41,6 +41,10 @@ export function useScraperFetch(): UseScraperFetchResult {
|
||||
const [unauthorizedError, setUnauthorizedError] = useState(false)
|
||||
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
// Guards against overlapping ticks: if a poll response hasn't come back
|
||||
// yet when the next 1s tick fires (a slow/backed-up server), skip that
|
||||
// tick rather than stacking a second in-flight request on top of it.
|
||||
const pollInFlightRef = useRef(false)
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
@@ -55,6 +59,8 @@ export function useScraperFetch(): UseScraperFetchResult {
|
||||
const startPolling = useCallback((sid: string) => {
|
||||
stopPolling()
|
||||
intervalRef.current = setInterval(async () => {
|
||||
if (pollInFlightRef.current) return
|
||||
pollInFlightRef.current = true
|
||||
try {
|
||||
const data = await api.pollScraperSession(sid)
|
||||
setPollData(data as PollResponse)
|
||||
@@ -64,10 +70,19 @@ export function useScraperFetch(): UseScraperFetchResult {
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Poll error')
|
||||
stopPolling()
|
||||
} finally {
|
||||
pollInFlightRef.current = false
|
||||
}
|
||||
}, POLL_INTERVAL_MS)
|
||||
}, [stopPolling])
|
||||
|
||||
// The interval otherwise outlives the component: a poll response arriving
|
||||
// after unmount would still call setState, and the timer itself would
|
||||
// keep firing (and keep holding the closure alive) forever.
|
||||
useEffect(() => {
|
||||
return () => stopPolling()
|
||||
}, [stopPolling])
|
||||
|
||||
const start = useCallback(
|
||||
async (childId?: string) => {
|
||||
if (sessionId && pollData && !isTerminalStatus(pollData.status)) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { VacationDays, VacationDaysCreateRequest } from '@/types'
|
||||
|
||||
interface UseVacationDaysResult {
|
||||
@@ -23,20 +24,24 @@ export function useVacationDays(workspaceId: string): UseVacationDaysResult {
|
||||
const [data, setData] = useState<VacationDays[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
const requestId = start()
|
||||
if (!workspaceId) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.getVacationDaysByWorkspace(workspaceId)
|
||||
if (!isCurrent(requestId)) return
|
||||
setData(result)
|
||||
} catch (err: unknown) {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load vacation days')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId])
|
||||
}, [workspaceId, start, isCurrent])
|
||||
|
||||
useEffect(() => {
|
||||
void fetch()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { PersonalData } from '@/types'
|
||||
|
||||
interface UseWorkspaceMembersResult {
|
||||
@@ -14,20 +15,24 @@ export function useWorkspaceMembers(workspaceId: string | null): UseWorkspaceMem
|
||||
const [data, setData] = useState<PersonalData[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
const requestId = start()
|
||||
if (!workspaceId) return
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.listWorkspaceMembers(workspaceId)
|
||||
if (!isCurrent(requestId)) return
|
||||
setData(result)
|
||||
} catch (err: unknown) {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load workspace members')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId])
|
||||
}, [workspaceId, start, isCurrent])
|
||||
|
||||
useEffect(() => {
|
||||
void fetch()
|
||||
|
||||
@@ -1774,6 +1774,114 @@ describe('endpoint functions (representative coverage)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('apiFetch — 401 refresh-and-retry (S1)', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn()
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.setAccessToken(null)
|
||||
api.setUnauthorizedHandler(null)
|
||||
})
|
||||
|
||||
function unauthorizedResponse(detail = 'Unauthorized') {
|
||||
return { ok: false, status: 401, statusText: 'Unauthorized', json: async () => ({ detail }) }
|
||||
}
|
||||
|
||||
function tokenResponse(access_token: string) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ access_token, token_type: 'bearer', expires_in: 3600 }),
|
||||
}
|
||||
}
|
||||
|
||||
it('refreshes the token once and retries the original request after a 401', async () => {
|
||||
api.setAccessToken('old-token')
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(unauthorizedResponse())
|
||||
.mockResolvedValueOnce(tokenResponse('new-token'))
|
||||
.mockResolvedValueOnce({ ok: true, status: 200, json: async () => [] })
|
||||
|
||||
const result = await api.getTips('ws1')
|
||||
|
||||
expect(result).toEqual([])
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(fetchMock.mock.calls[1]![0]).toBe('/api/auth/refresh')
|
||||
const [retryUrl, retryOptions] = fetchMock.mock.calls[2] as [string, RequestInit]
|
||||
expect(retryUrl).toBe('/api/tips/ws1')
|
||||
expect((retryOptions.headers as Record<string, string>)['Authorization']).toBe('Bearer new-token')
|
||||
})
|
||||
|
||||
it('dedupes concurrent 401s from parallel requests into a single refresh call', async () => {
|
||||
api.setAccessToken('old-token')
|
||||
fetchMock.mockImplementation(async (url: string, options?: RequestInit) => {
|
||||
if (url === '/api/auth/refresh') {
|
||||
return tokenResponse('new-token')
|
||||
}
|
||||
const headers = options?.headers as Record<string, string> | undefined
|
||||
if (headers?.['Authorization'] === 'Bearer new-token') {
|
||||
return { ok: true, status: 200, json: async () => [] }
|
||||
}
|
||||
return unauthorizedResponse()
|
||||
})
|
||||
|
||||
const [r1, r2] = await Promise.all([api.getTips('ws1'), api.getTips('ws1')])
|
||||
|
||||
expect(r1).toEqual([])
|
||||
expect(r2).toEqual([])
|
||||
const refreshCalls = fetchMock.mock.calls.filter(([url]) => url === '/api/auth/refresh')
|
||||
expect(refreshCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('clears the token, notifies the unauthorized handler, and surfaces the original 401 when refresh fails', async () => {
|
||||
api.setAccessToken('old-token')
|
||||
const onUnauthorized = vi.fn()
|
||||
api.setUnauthorizedHandler(onUnauthorized)
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(unauthorizedResponse('session expired'))
|
||||
.mockResolvedValueOnce(unauthorizedResponse('refresh token expired'))
|
||||
|
||||
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
||||
status: 401,
|
||||
error: { message: 'session expired' },
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(onUnauthorized).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not loop when the retried request also 401s', async () => {
|
||||
api.setAccessToken('old-token')
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(unauthorizedResponse())
|
||||
.mockResolvedValueOnce(tokenResponse('new-token'))
|
||||
.mockResolvedValueOnce(unauthorizedResponse('still unauthorized'))
|
||||
|
||||
await expect(api.getTips('ws1')).rejects.toMatchObject({
|
||||
status: 401,
|
||||
error: { message: 'still unauthorized' },
|
||||
})
|
||||
|
||||
// Original + refresh + one retry — no second refresh attempt.
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('does not attempt a refresh-and-retry for the auth endpoints themselves', async () => {
|
||||
fetchMock.mockResolvedValue(unauthorizedResponse())
|
||||
|
||||
await expect(api.login({ email: 'a@b.com', password: 'x' })).rejects.toMatchObject({ status: 401 })
|
||||
await expect(api.refreshToken()).rejects.toMatchObject({ status: 401 })
|
||||
await expect(api.logout()).rejects.toMatchObject({ status: 401 })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('apiFetch — structured {message_key, data} errors', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
|
||||
@@ -520,9 +520,30 @@ export function setAccessToken(token: string | null): void {
|
||||
_accessToken = token
|
||||
}
|
||||
|
||||
// Endpoints that must never trigger a refresh-and-retry on 401 — retrying
|
||||
// them would either be nonsensical (the refresh call retrying itself) or
|
||||
// mask the real "not logged in" outcome (login/logout).
|
||||
const AUTH_NO_RETRY_PATHS = new Set(['/auth/login', '/auth/refresh', '/auth/logout'])
|
||||
|
||||
// Dedupe concurrent 401s (e.g. Dashboard's ~7 parallel requests all
|
||||
// expiring at once) into a single in-flight refresh call, shared across
|
||||
// every apiFetch caller.
|
||||
let refreshPromise: Promise<TokenResponse> | null = null
|
||||
|
||||
// AuthContext registers this so a refresh-and-retry failure can log the
|
||||
// user out immediately, instead of leaving `isAuthenticated: true` while
|
||||
// every subsequent request 401s. Kept out of this module so api.ts stays
|
||||
// framework-agnostic.
|
||||
let onUnauthorized: (() => void) | null = null
|
||||
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null): void {
|
||||
onUnauthorized = handler
|
||||
}
|
||||
|
||||
async function apiFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
isRetry = false,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -532,6 +553,31 @@ async function apiFetch<T>(
|
||||
headers['Authorization'] = `Bearer ${_accessToken}`
|
||||
}
|
||||
const response = await fetch(`${BASE}${path}`, { ...options, headers })
|
||||
|
||||
if (response.status === 401 && !isRetry && !AUTH_NO_RETRY_PATHS.has(path)) {
|
||||
let refreshed: TokenResponse | null = null
|
||||
try {
|
||||
refreshPromise ??= refreshToken().finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
refreshed = await refreshPromise
|
||||
} catch {
|
||||
// Refresh itself failed (expired/invalid refresh cookie, or a
|
||||
// network error) — clear the token and let the caller's original
|
||||
// 401 surface below so AuthContext can log the user out.
|
||||
setAccessToken(null)
|
||||
onUnauthorized?.()
|
||||
}
|
||||
if (refreshed) {
|
||||
setAccessToken(refreshed.access_token)
|
||||
// Retry exactly once, outside the try/catch above — `isRetry: true`
|
||||
// means a 401 on this attempt is final and must propagate as-is
|
||||
// rather than being caught here and re-reported as the original
|
||||
// (now stale) 401.
|
||||
return await apiFetch<T>(path, options, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let error: { code: string; message: string; details?: Record<string, unknown> } = {
|
||||
code: String(response.status),
|
||||
|
||||
Reference in New Issue
Block a user