Close the residual gaps the 2026-09-18 audit left open

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
This commit is contained in:
2026-09-21 06:16:23 +02:00
parent fc90cf9a5f
commit 88197060de
44 changed files with 1401 additions and 218 deletions
+11 -37
View File
@@ -70,6 +70,7 @@ const userContext: api.UserContext = {
{ 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', () => {
@@ -92,23 +93,26 @@ describe('WorkspaceContext', () => {
expect(result.current.isLoading).toBe(false)
})
it('loads user context and workspace on mount when authenticated', async () => {
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)
vi.mocked(api.getWorkspace).mockResolvedValue(workspace)
const { result } = renderHook(() => useWorkspace(), { wrapper })
await waitFor(() => expect(result.current.workspace).not.toBeNull())
expect(api.getUserContext).toHaveBeenCalled()
expect(api.getWorkspace).toHaveBeenCalledWith('ws1')
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(workspace)
expect(result.current.workspace).toEqual({ id: 'ws1', takePlOnRedDays: false })
expect(localStorage.getItem('workspaceId')).toBe('ws1')
expect(result.current.isLoading).toBe(false)
})
@@ -136,31 +140,15 @@ describe('WorkspaceContext', () => {
expect(result.current.error).toBe('Failed to load user context')
})
it('sets an error when the workspace fetch inside refreshContext fails', async () => {
vi.mocked(useAuth).mockReturnValue(authenticated)
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
vi.mocked(api.getWorkspace).mockRejectedValue(new Error('workspace boom'))
const { result } = renderHook(() => useWorkspace(), { wrapper })
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.error).toBe('workspace boom')
expect(result.current.workspace).toBeNull()
// But the rest of the context from getUserContext was still applied.
expect(result.current.workspaceId).toBe('ws1')
expect(result.current.selectedChildId).toBe('c1')
})
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)
vi.mocked(api.getWorkspace).mockResolvedValue(workspace)
const { result } = renderHook(() => useWorkspace(), { wrapper })
@@ -170,19 +158,6 @@ describe('WorkspaceContext', () => {
expect(result.current.selectedChildId).toBeNull()
})
it('sets a non-Error rejection to a generic message when the workspace fetch inside refreshContext fails', async () => {
vi.mocked(useAuth).mockReturnValue(authenticated)
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
vi.mocked(api.getWorkspace).mockRejectedValue('nope')
const { result } = renderHook(() => useWorkspace(), { wrapper })
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.error).toBe('Failed to load workspace')
expect(result.current.workspace).toBeNull()
})
it('refetch sets a non-Error rejection to a generic message', async () => {
vi.mocked(useAuth).mockReturnValue(authenticated)
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
@@ -311,15 +286,14 @@ describe('WorkspaceContext', () => {
rerender()
await waitFor(() => expect(result.current.workspace).toBeNull())
const otherWorkspace: Workspace = { ...workspace, id: 'ws2', name: 'Other workspace' }
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.getWorkspace).mockResolvedValue(otherWorkspace)
vi.mocked(api.getUserContext).mockClear()
vi.mocked(useAuth).mockReturnValue(authenticated)
@@ -330,7 +304,7 @@ describe('WorkspaceContext', () => {
expect(result.current.workspaceId).toBe('ws2')
expect(result.current.personalDataUuid).toBe('pd2')
expect(result.current.selectedChildId).toBe('c3')
expect(result.current.workspace).toEqual(otherWorkspace)
expect(result.current.workspace).toEqual({ id: 'ws2', takePlOnRedDays: true })
})
it('discards a refreshContext response that resolves after logout (S4)', async () => {