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
+346 -13
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { createElement, StrictMode, type ReactNode } from 'react'
import { usePlannedDays } from './usePlannedDays'
import * as api from '@/services/api'
import { useWorkspace } from '@/context'
@@ -80,18 +81,43 @@ describe('usePlannedDays', () => {
expect(api.createPlannedDay).not.toHaveBeenCalled() // won't be called without user action
})
it('removes planned day and refreshes', async () => {
it('removes planned day, and the resulting planVersion bump triggers a background refetch on the next render', async () => {
vi.mocked(api.deletePlannedDay).mockResolvedValue(undefined)
vi.mocked(api.getPlannedDays).mockResolvedValue([])
// `remove` itself never fetches directly (see usePlannedDays.ts's doc
// comment on the `planVersion` effect) — only the bump it calls does,
// once this instance re-renders with the new value, exactly as the real
// `WorkspaceProvider`'s state update would. A bare `vi.fn()` for
// `bumpPlanVersion` (this file's default) never causes that re-render on
// its own, so this test wires up a real counter + explicit `rerender()`.
let planVersion = 0
vi.mocked(useWorkspace).mockImplementation(() =>
makeWorkspaceContextValue({
planVersion,
bumpPlanVersion: () => {
planVersion += 1
},
}),
)
const { result } = renderHook(() => usePlannedDays('child1'))
const { result, rerender } = renderHook(() => usePlannedDays('child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
const callCountAfterMount = vi.mocked(api.getPlannedDays).mock.calls.length
await act(async () => {
await result.current.remove('pd1')
})
expect(api.deletePlannedDay).toHaveBeenCalledWith('pd1')
expect(api.getPlannedDays).toHaveBeenCalled()
// Nothing has refetched yet — the bump only takes effect next render.
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount)
rerender()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBeGreaterThan(callCountAfterMount)
})
it('handles fetch error and sets error state', async () => {
@@ -122,7 +148,7 @@ describe('usePlannedDays', () => {
expect(result.current.data).toBeNull()
})
it('updates planned day and refreshes', async () => {
it('updates planned day, and the resulting planVersion bump triggers a background refetch on the next render', async () => {
const mockUpdated = makePlannedLeaveDay({
id: '1',
workspaceId: 'ws1',
@@ -135,15 +161,33 @@ describe('usePlannedDays', () => {
vi.mocked(api.updatePlannedDay).mockResolvedValue(mockUpdated)
vi.mocked(api.getPlannedDays).mockResolvedValue([mockUpdated])
let planVersion = 0
vi.mocked(useWorkspace).mockImplementation(() =>
makeWorkspaceContextValue({
planVersion,
bumpPlanVersion: () => {
planVersion += 1
},
}),
)
const { result } = renderHook(() => usePlannedDays('child1'))
const { result, rerender } = renderHook(() => usePlannedDays('child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
const callCountAfterMount = vi.mocked(api.getPlannedDays).mock.calls.length
await act(async () => {
await result.current.update('1', { day_type: 'lagsta' })
})
expect(api.updatePlannedDay).toHaveBeenCalledWith('1', { day_type: 'lagsta' })
expect(api.getPlannedDays).toHaveBeenCalled()
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount)
rerender()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBeGreaterThan(callCountAfterMount)
})
it('updates data after successful add', async () => {
@@ -172,8 +216,21 @@ describe('usePlannedDays', () => {
vi.mocked(api.getPlannedDays).mockResolvedValueOnce(initialData)
vi.mocked(api.createPlannedDay).mockResolvedValue(newData)
vi.mocked(api.getPlannedDays).mockResolvedValueOnce([...initialData, newData])
// `add` bumps `planVersion` but never fetches itself — the background
// refetch that lands the fresh data only happens once this instance
// re-renders with the new value, exactly as the real `WorkspaceProvider`
// update would (see usePlannedDays.ts's doc comment).
let planVersion = 0
vi.mocked(useWorkspace).mockImplementation(() =>
makeWorkspaceContextValue({
planVersion,
bumpPlanVersion: () => {
planVersion += 1
},
}),
)
const { result } = renderHook(() => usePlannedDays('child1'))
const { result, rerender } = renderHook(() => usePlannedDays('child1'))
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
@@ -184,6 +241,13 @@ describe('usePlannedDays', () => {
await act(async () => {
await result.current.add({ workspace_id: 'ws1', child_id: 'child1', date: '2026-06-16', day_type: 'vacation', parent_personal_data_uuid: 'parent1' })
})
// Nothing has refetched yet.
expect(result.current.data).toEqual(initialData)
rerender()
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(result.current.data).toEqual([...initialData, newData])
})
@@ -213,8 +277,17 @@ describe('usePlannedDays', () => {
vi.mocked(api.getPlannedDays).mockResolvedValueOnce(initialData)
vi.mocked(api.deletePlannedDay).mockResolvedValue(undefined)
vi.mocked(api.getPlannedDays).mockResolvedValueOnce([initialData[0]!])
let planVersion = 0
vi.mocked(useWorkspace).mockImplementation(() =>
makeWorkspaceContextValue({
planVersion,
bumpPlanVersion: () => {
planVersion += 1
},
}),
)
const { result } = renderHook(() => usePlannedDays('child1'))
const { result, rerender } = renderHook(() => usePlannedDays('child1'))
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
@@ -225,6 +298,13 @@ describe('usePlannedDays', () => {
await act(async () => {
await result.current.remove('2')
})
// Nothing has refetched yet — `remove` only bumps `planVersion`.
expect(result.current.data).toEqual(initialData)
rerender()
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0))
})
expect(result.current.data).toEqual([initialData[0]])
})
@@ -260,7 +340,256 @@ describe('usePlannedDays', () => {
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBeGreaterThan(callCount)
})
it('sets loading state during fetch', async () => {
it('M7 part 1 — a mutation results in exactly one background refetch, never isLoading (only isRefreshing flips)', async () => {
// This is the exact regression a browser verification pass caught: an
// earlier version had `add`/`update`/`remove` call `fetch({ background:
// true })` themselves AND rely on their own resulting `planVersion` bump
// to re-run this hook's effect a second time — and that second,
// effect-triggered fetch used the *blocking* default, so a save still
// blanked the page via a second GET. `add`/`update`/`remove` now only
// ever call `bumpPlanVersion()`; the effect below is the ONLY place that
// fetches, so there is exactly one fetch, and it decides for itself
// whether that's the (blocking) first load or a (background) mutation
// refetch — see usePlannedDays.ts's doc comment.
vi.mocked(api.getPlannedDays).mockResolvedValue([])
vi.mocked(api.createPlannedDay).mockResolvedValue(
makePlannedLeaveDay({ id: '1', date: '2026-06-16' }),
)
// A bare `vi.fn()` `bumpPlanVersion` (this file's default) never causes
// a re-render on its own — wire up a real counter so calling it (via
// `add`) and then `rerender()`ing genuinely reproduces what the real
// `WorkspaceProvider`'s state update would do.
let planVersion = 0
vi.mocked(useWorkspace).mockImplementation(() =>
makeWorkspaceContextValue({
planVersion,
bumpPlanVersion: () => {
planVersion += 1
},
}),
)
const { result, rerender } = renderHook(() => usePlannedDays('child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(result.current.isLoading).toBe(false)
const callCountAfterMount = vi.mocked(api.getPlannedDays).mock.calls.length
// The refetch that follows the mutation's bump should hang so the
// in-flight state is observable before it resolves.
let resolveRefetch: (value: ReturnType<typeof makePlannedLeaveDay>[]) => void = () => {}
vi.mocked(api.getPlannedDays).mockReturnValueOnce(
new Promise((resolve) => {
resolveRefetch = resolve
}),
)
await act(async () => {
await result.current.add({
workspace_id: 'ws1',
child_id: 'child1',
date: '2026-06-16',
day_type: 'vacation',
parent_personal_data_uuid: 'parent1',
})
})
// `add` itself never fetched — nothing has refetched yet.
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount)
rerender()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
// Exactly one refetch so far, and it's non-blocking.
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount + 1)
expect(result.current.isRefreshing).toBe(true)
expect(result.current.isLoading).toBe(false)
await act(async () => {
resolveRefetch([])
})
expect(result.current.isRefreshing).toBe(false)
expect(result.current.isLoading).toBe(false)
// Settling further (no further bump) raises no additional GET.
rerender()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount + 1)
})
describe('M7 follow-up — the StrictMode double-mount hang', () => {
const strictModeWrapper = ({ children }: { children: ReactNode }) =>
createElement(StrictMode, null, children)
it('reproduces the reported hang: navigating to a page that mounts with childId already known must not strand isLoading true', async () => {
// React 18 StrictMode double-invokes a fresh mount's effect,
// back-to-back, before either invocation's fetch has resolved. On a
// real SPA navigation `childId` is already known at mount (unlike a
// hard reload, where it's still `undefined` on the very first
// render) — this is exactly what the bug report's "works on hard
// reload, hangs on in-app navigation" symptom traced back to.
vi.mocked(api.getPlannedDays).mockResolvedValue([])
const { result } = renderHook(() => usePlannedDays('child1'), {
wrapper: strictModeWrapper,
})
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(result.current.isLoading).toBe(false)
expect(result.current.isRefreshing).toBe(false)
expect(result.current.data).toEqual([])
// The dedup fix means the double-invoked mount effect still issues
// exactly one real HTTP request, not two.
expect(api.getPlannedDays).toHaveBeenCalledTimes(1)
})
it('two overlapping calls for the same childId — one foreground, one background, started before either resolves — both end up cleared: isLoading false, isRefreshing false, exactly one request', async () => {
// The same hazard, exercised directly (per the coordinator's
// alternative to a StrictMode wrapper): two calls for the identical
// childId, racing, with DIFFERENT background flags — the exact shape
// that stranded `isLoading` before this fix (the stale call raised
// one flag, the current call raised the other, and only the current
// call's own — guarded — clear ever ran).
let resolveFetch: (value: ReturnType<typeof makePlannedLeaveDay>[]) => void = () => {}
vi.mocked(api.getPlannedDays).mockReturnValue(
new Promise((resolve) => {
resolveFetch = resolve
}),
)
const { result } = renderHook(() => usePlannedDays('child1'))
// The mount effect already started one (foreground) call — fire a
// second, differently-classified call for the SAME childId before it
// resolves.
let secondCall: Promise<unknown> = Promise.resolve()
act(() => {
secondCall = result.current.refresh({ background: true })
})
expect(result.current.isLoading).toBe(true)
await act(async () => {
resolveFetch([])
await secondCall
})
expect(result.current.isLoading).toBe(false)
expect(result.current.isRefreshing).toBe(false)
// Deduped into a single request.
expect(api.getPlannedDays).toHaveBeenCalledTimes(1)
})
})
describe('M7 part 2 — suppressing the per-call bumpPlanVersion (skipRefresh)', () => {
it('add: skipRefresh skips this call\'s own bumpPlanVersion', async () => {
vi.mocked(api.getPlannedDays).mockResolvedValue([])
vi.mocked(api.createPlannedDay).mockResolvedValue(
makePlannedLeaveDay({ id: '1', date: '2026-06-16' }),
)
const { result } = renderHook(() => usePlannedDays('child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
await act(async () => {
await result.current.add(
{
workspace_id: 'ws1',
child_id: 'child1',
date: '2026-06-16',
day_type: 'vacation',
parent_personal_data_uuid: 'parent1',
},
{ skipRefresh: true },
)
})
expect(api.createPlannedDay).toHaveBeenCalled()
expect(bumpPlanVersion).not.toHaveBeenCalled()
})
it('update: skipRefresh skips this call\'s own bumpPlanVersion', async () => {
vi.mocked(api.getPlannedDays).mockResolvedValue([])
vi.mocked(api.updatePlannedDay).mockResolvedValue(makePlannedLeaveDay({ id: '1' }))
const { result } = renderHook(() => usePlannedDays('child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
await act(async () => {
await result.current.update('1', { day_type: 'lagsta' }, { skipRefresh: true })
})
expect(api.updatePlannedDay).toHaveBeenCalledWith('1', { day_type: 'lagsta' })
expect(bumpPlanVersion).not.toHaveBeenCalled()
})
it('a batch of skipRefresh writes followed by one bumpPlanVersion() call (Calendar.tsx\'s multi-day save pattern) produces exactly one trailing background GET', async () => {
vi.mocked(api.getPlannedDays).mockResolvedValue([])
vi.mocked(api.createPlannedDay).mockResolvedValue(
makePlannedLeaveDay({ id: '1', date: '2026-06-16' }),
)
// A real counter (not this file's default inert `vi.fn()`) so calling
// `bump()` — standing in for `Calendar.tsx handleSavePinnedDay`'s
// trailing `bumpPlanVersion()` — then `rerender()`ing genuinely
// reproduces the real `WorkspaceProvider` state update that follows.
let planVersion = 0
const bump = () => {
planVersion += 1
}
vi.mocked(useWorkspace).mockImplementation(() =>
makeWorkspaceContextValue({ planVersion, bumpPlanVersion: bump }),
)
const { result, rerender } = renderHook(() => usePlannedDays('child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
const callCountAfterMount = vi.mocked(api.getPlannedDays).mock.calls.length
await act(async () => {
// Simulates Calendar.tsx's multi-day save loop: N writes, every one
// suppressing its own bump...
await result.current.add(
{ workspace_id: 'ws1', child_id: 'child1', date: '2026-06-16', day_type: 'vacation', parent_personal_data_uuid: 'parent1' },
{ skipRefresh: true },
)
await result.current.add(
{ workspace_id: 'ws1', child_id: 'child1', date: '2026-06-17', day_type: 'vacation', parent_personal_data_uuid: 'parent1' },
{ skipRefresh: true },
)
// ...then exactly ONE trailing bump for the whole batch (mirrors
// `handleSavePinnedDay`'s `finally`).
bump()
})
expect(api.createPlannedDay).toHaveBeenCalledTimes(2)
// The bump only takes effect on this instance's next render — nothing
// has refetched yet.
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount)
rerender()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
// Exactly one trailing GET for the whole batch, and it never blocked.
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount + 1)
expect(result.current.isLoading).toBe(false)
expect(result.current.isRefreshing).toBe(false)
})
})
it('sets loading state during fetch (M7 part 1 — the initial mount load DOES block, unlike a post-mutation refetch)', async () => {
let resolveGetPlannedDays: (value: Awaited<ReturnType<typeof api.getPlannedDays>>) => void
const getPlannedDaysPromise: ReturnType<typeof api.getPlannedDays> = new Promise(resolve => {
resolveGetPlannedDays = resolve
@@ -270,7 +599,10 @@ describe('usePlannedDays', () => {
const { result } = renderHook(() => usePlannedDays('child1'))
// The very first fetch for a childId has nothing to render yet — it
// must block, unlike every subsequent mutation-triggered refetch.
expect(result.current.isLoading).toBe(true)
expect(result.current.isRefreshing).toBe(false)
await act(async () => {
resolveGetPlannedDays!([])
@@ -550,8 +882,9 @@ describe('usePlannedDays', () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
// Own direct fetch() after add, plus one effect-triggered refetch from
// its own bump — not an unbounded/growing number of calls.
// `add` itself never fetches — only this one effect-triggered refetch
// from its own bump landing on the rerender above. Not an
// unbounded/growing number of calls on further settling.
const callCountAfterFirstSettle = vi.mocked(api.getPlannedDays).mock.calls.length
rerender()
await act(async () => {