88197060de
Re-checked every item of the audit's own outcome section against the tree.
Most held, but five items marked fixed had residual gaps — one of them a
live IDOR — and the deferred list was untouched.
Security:
- Analytics IDOR: /api/analytics/{ws}/overview and /projection took
child_id as an unchecked query param, and get_overview called
get_days_left_at_age_4 without workspace_id, so the ownership guard
never ran. Any member could read another workspace's child projection.
Fixed at both layers.
- Scraper: session ids no longer logged at INFO; a persist-callback
failure's raw exception text and the FK maintenance banner's scraped
text no longer reach the poll client.
- Email is now case-normalised at the API boundary, with migration 009
lowercasing existing rows (aborts naming colliding rows rather than
failing opaquely on the UNIQUE constraint).
Frontend correctness:
- usePlanProposal/usePlanSync gained the stale-response guard the other
hooks already had.
- PlanSyncProvider no longer fires POST /plan/sync before auth resolves
off a blindly-read localStorage workspaceId.
- Busy flags are cleared unconditionally across all 16 useLatestRequest
consumers. setBusy(true) was unconditional while the clear was guarded
by isCurrent, so a superseded call stranded its flag forever — under
StrictMode this hung every in-app navigation behind the PageSkeleton.
isCurrent decides which response's data wins, never whether a call
releases the flag it raised.
Performance:
- Calendar no longer blanks on a mutation, and a multi-day save issues N
writes plus one background refetch instead of 2N requests.
- take_pl_on_red_days moved onto GET /auth/context, dropping a redundant
GET /workspace/:id from every cold load.
CI and repo hygiene: least-privilege permissions block, actions pinned to
commit SHAs, npm ci, uv sync --locked, failure-only artifact upload,
dependabot; dead frontend/out.js removed; CSP added to the nginx template
(repeated into the location blocks that set their own add_header, since
nginx does not merge them across levels).
Left deferred by decision: refresh-token revocation, the phase-2 contract
change, M4, M5, L2, and H12's cross-route cache. Details and the smaller
follow-ups browser verification turned up are in the audit report.
Backend 901 passed, coverage 92%. Frontend 1322 tests, coverage
98.17/94.79/92.14/98.17.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PngVF38MLpdkgXcByKfdW6
367 lines
13 KiB
TypeScript
367 lines
13 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { renderHook, waitFor, act } from '@testing-library/react'
|
|
import type { ReactNode } from 'react'
|
|
import { WorkspaceProvider, useWorkspace } from './WorkspaceContext'
|
|
import * as api from '@/services/api'
|
|
import { useAuth } from '@/context/AuthContext'
|
|
import type { Workspace } from '@/types'
|
|
|
|
vi.mock('@/services/api')
|
|
vi.mock('@/context/AuthContext', () => ({
|
|
useAuth: vi.fn(),
|
|
}))
|
|
|
|
function wrapper({ children }: { children: ReactNode }) {
|
|
return <WorkspaceProvider>{children}</WorkspaceProvider>
|
|
}
|
|
|
|
// Node's own experimental `localStorage` global (active in newer Node runtimes
|
|
// without a valid --localstorage-file) shadows jsdom's working implementation
|
|
// and throws on every call. Stub a real in-memory Storage for these tests.
|
|
function createLocalStorageMock(): Storage {
|
|
let store: Record<string, string> = {}
|
|
return {
|
|
getItem: (key: string) => (key in store ? store[key]! : null),
|
|
setItem: (key: string, value: string) => {
|
|
store[key] = value
|
|
},
|
|
removeItem: (key: string) => {
|
|
delete store[key]
|
|
},
|
|
clear: () => {
|
|
store = {}
|
|
},
|
|
key: (index: number) => Object.keys(store)[index] ?? null,
|
|
get length() {
|
|
return Object.keys(store).length
|
|
},
|
|
}
|
|
}
|
|
|
|
const authenticated = {
|
|
accessToken: 'tok',
|
|
isAuthenticated: true,
|
|
isLoading: false,
|
|
login: vi.fn(),
|
|
register: vi.fn(),
|
|
logout: vi.fn(),
|
|
}
|
|
|
|
const unauthenticated = {
|
|
accessToken: null,
|
|
isAuthenticated: false,
|
|
isLoading: false,
|
|
login: vi.fn(),
|
|
register: vi.fn(),
|
|
logout: vi.fn(),
|
|
}
|
|
|
|
const workspace: Workspace = {
|
|
id: 'ws1',
|
|
name: 'Our workspace',
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
}
|
|
|
|
const userContext: api.UserContext = {
|
|
personal_data_uuid: 'pd1',
|
|
workspace_id: 'ws1',
|
|
parent_name: 'Anna',
|
|
children: [
|
|
{ id: 'c1', name: 'Child One', birth_date: '2024-01-01' },
|
|
{ id: 'c2', name: 'Child Two', birth_date: '2025-01-01' },
|
|
],
|
|
take_pl_on_red_days: false,
|
|
}
|
|
|
|
describe('WorkspaceContext', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.stubGlobal('localStorage', createLocalStorageMock())
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('does nothing when the user is not authenticated', async () => {
|
|
vi.mocked(useAuth).mockReturnValue(unauthenticated)
|
|
|
|
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
|
|
|
expect(api.getUserContext).not.toHaveBeenCalled()
|
|
expect(result.current.workspace).toBeNull()
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
it('loads user context on mount when authenticated, with no follow-up workspace fetch', async () => {
|
|
// H12 step 1: cold load used to be getUserContext() -> getWorkspace(id).
|
|
// The workspace state this provider needs (id + takePlOnRedDays) now
|
|
// comes straight from the context response, so getWorkspace must not
|
|
// be called at all on this path.
|
|
vi.mocked(useAuth).mockReturnValue(authenticated)
|
|
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
|
|
|
|
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
|
|
|
await waitFor(() => expect(result.current.workspace).not.toBeNull())
|
|
|
|
expect(api.getUserContext).toHaveBeenCalled()
|
|
expect(api.getWorkspace).not.toHaveBeenCalled()
|
|
expect(result.current.workspaceId).toBe('ws1')
|
|
expect(result.current.personalDataUuid).toBe('pd1')
|
|
expect(result.current.parentName).toBe('Anna')
|
|
expect(result.current.children).toHaveLength(2)
|
|
expect(result.current.selectedChildId).toBe('c1')
|
|
expect(result.current.workspace).toEqual({ id: 'ws1', takePlOnRedDays: false })
|
|
expect(localStorage.getItem('workspaceId')).toBe('ws1')
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
it('sets an error when loading the user context fails', async () => {
|
|
vi.mocked(useAuth).mockReturnValue(authenticated)
|
|
vi.mocked(api.getUserContext).mockRejectedValue(new Error('context boom'))
|
|
|
|
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
|
|
|
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
|
|
|
expect(result.current.error).toBe('context boom')
|
|
expect(result.current.workspace).toBeNull()
|
|
})
|
|
|
|
it('sets a non-Error rejection to a generic message when loading the user context fails', async () => {
|
|
vi.mocked(useAuth).mockReturnValue(authenticated)
|
|
vi.mocked(api.getUserContext).mockRejectedValue('nope')
|
|
|
|
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
|
|
|
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
|
|
|
expect(result.current.error).toBe('Failed to load user context')
|
|
})
|
|
|
|
it('defaults to an empty children list when the context response omits children', async () => {
|
|
vi.mocked(useAuth).mockReturnValue(authenticated)
|
|
const contextWithoutChildren = {
|
|
personal_data_uuid: userContext.personal_data_uuid,
|
|
workspace_id: userContext.workspace_id,
|
|
parent_name: userContext.parent_name,
|
|
take_pl_on_red_days: false,
|
|
} as unknown as api.UserContext
|
|
vi.mocked(api.getUserContext).mockResolvedValue(contextWithoutChildren)
|
|
|
|
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
|
|
|
await waitFor(() => expect(result.current.workspace).not.toBeNull())
|
|
|
|
expect(result.current.children).toEqual([])
|
|
expect(result.current.selectedChildId).toBeNull()
|
|
})
|
|
|
|
it('refetch sets a non-Error rejection to a generic message', async () => {
|
|
vi.mocked(useAuth).mockReturnValue(authenticated)
|
|
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
|
|
vi.mocked(api.getWorkspace).mockResolvedValue(workspace)
|
|
|
|
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
|
await waitFor(() => expect(result.current.workspace).not.toBeNull())
|
|
|
|
vi.mocked(api.getWorkspace).mockRejectedValue('nope')
|
|
|
|
act(() => {
|
|
result.current.refetch()
|
|
})
|
|
|
|
await waitFor(() => expect(result.current.error).toBe('Failed to load workspace'))
|
|
expect(result.current.workspace).toBeNull()
|
|
})
|
|
|
|
it('selectChild updates the selected child id', async () => {
|
|
vi.mocked(useAuth).mockReturnValue(authenticated)
|
|
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
|
|
vi.mocked(api.getWorkspace).mockResolvedValue(workspace)
|
|
|
|
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
|
await waitFor(() => expect(result.current.selectedChildId).toBe('c1'))
|
|
|
|
act(() => {
|
|
result.current.selectChild('c2')
|
|
})
|
|
|
|
expect(result.current.selectedChildId).toBe('c2')
|
|
})
|
|
|
|
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)
|
|
|
|
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 () => {
|
|
vi.mocked(useAuth).mockReturnValue(authenticated)
|
|
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
|
|
vi.mocked(api.getWorkspace).mockResolvedValue(workspace)
|
|
|
|
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
|
await waitFor(() => expect(result.current.workspace).not.toBeNull())
|
|
|
|
vi.mocked(api.getWorkspace).mockClear()
|
|
|
|
act(() => {
|
|
result.current.refetch()
|
|
})
|
|
|
|
await waitFor(() => expect(api.getWorkspace).toHaveBeenCalledWith('ws1'))
|
|
})
|
|
|
|
it('refetch is a no-op without a workspaceId', () => {
|
|
vi.mocked(useAuth).mockReturnValue(unauthenticated)
|
|
|
|
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
|
|
|
act(() => {
|
|
result.current.refetch()
|
|
})
|
|
|
|
expect(api.getWorkspace).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('clears identity/workspace state on logout so it cannot leak into the next session', async () => {
|
|
vi.mocked(useAuth).mockReturnValue(authenticated)
|
|
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
|
|
vi.mocked(api.getWorkspace).mockResolvedValue(workspace)
|
|
|
|
const { result, rerender } = renderHook(() => useWorkspace(), { wrapper })
|
|
await waitFor(() => expect(result.current.workspace).not.toBeNull())
|
|
expect(result.current.parentName).toBe('Anna')
|
|
|
|
vi.mocked(useAuth).mockReturnValue(unauthenticated)
|
|
rerender()
|
|
|
|
await waitFor(() => expect(result.current.workspace).toBeNull())
|
|
expect(result.current.workspaceId).toBeNull()
|
|
expect(result.current.parentName).toBeNull()
|
|
expect(result.current.personalDataUuid).toBeNull()
|
|
expect(result.current.children).toEqual([])
|
|
expect(result.current.selectedChildId).toBeNull()
|
|
expect(localStorage.getItem('workspaceId')).toBeNull()
|
|
})
|
|
|
|
it('refetches on a fresh login even when stale data from a previous session is already present', async () => {
|
|
// Regression for a login-swap bug: a previous guard only refetched when
|
|
// workspaceId/children looked empty, so logging in as a *different*
|
|
// user after a previous session's data was already cached here never
|
|
// refetched at all, and the new session silently rendered the old
|
|
// account's workspace/name/children.
|
|
vi.mocked(useAuth).mockReturnValue(authenticated)
|
|
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
|
|
vi.mocked(api.getWorkspace).mockResolvedValue(workspace)
|
|
|
|
const { result, rerender } = renderHook(() => useWorkspace(), { wrapper })
|
|
await waitFor(() => expect(result.current.parentName).toBe('Anna'))
|
|
|
|
vi.mocked(useAuth).mockReturnValue(unauthenticated)
|
|
rerender()
|
|
await waitFor(() => expect(result.current.workspace).toBeNull())
|
|
|
|
const otherUserContext: api.UserContext = {
|
|
personal_data_uuid: 'pd2',
|
|
workspace_id: 'ws2',
|
|
parent_name: 'Alex',
|
|
children: [{ id: 'c3', name: 'Child Three', birth_date: '2026-01-01' }],
|
|
take_pl_on_red_days: true,
|
|
}
|
|
vi.mocked(api.getUserContext).mockResolvedValue(otherUserContext)
|
|
vi.mocked(api.getUserContext).mockClear()
|
|
|
|
vi.mocked(useAuth).mockReturnValue(authenticated)
|
|
rerender()
|
|
|
|
await waitFor(() => expect(result.current.parentName).toBe('Alex'))
|
|
expect(api.getUserContext).toHaveBeenCalled()
|
|
expect(result.current.workspaceId).toBe('ws2')
|
|
expect(result.current.personalDataUuid).toBe('pd2')
|
|
expect(result.current.selectedChildId).toBe('c3')
|
|
expect(result.current.workspace).toEqual({ id: 'ws2', takePlOnRedDays: true })
|
|
})
|
|
|
|
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)
|
|
|
|
expect(() => renderHook(() => useWorkspace())).toThrow(
|
|
'useWorkspace must be used inside <WorkspaceProvider>',
|
|
)
|
|
})
|
|
|
|
it('bumpPlanVersion increments planVersion, giving hooks watching it a reason to refetch', () => {
|
|
vi.mocked(useAuth).mockReturnValue(unauthenticated)
|
|
|
|
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
|
expect(result.current.planVersion).toBe(0)
|
|
|
|
act(() => {
|
|
result.current.bumpPlanVersion()
|
|
})
|
|
expect(result.current.planVersion).toBe(1)
|
|
|
|
act(() => {
|
|
result.current.bumpPlanVersion()
|
|
})
|
|
expect(result.current.planVersion).toBe(2)
|
|
})
|
|
})
|