Merge branch 'worktree-check-security-audit'

Security and frontend-correctness hardening: analytics IDOR fix, scraper
log scrubbing, lowercase email normalisation (migration 009), stale-response
guards in the plan hooks, busy flags cleared unconditionally across all
useLatestRequest consumers, Calendar no longer blanks on mutation, plus
dependabot/CI config and removal of the stray frontend/out.js.

Conflicts came from main's later "day assertions" restructure, which made
usePlannedDays read-only and replaced the calendar's per-day add/update with
useDayAssertions.assertDay. Both intents kept:

- usePlannedDays: branch's fetch machinery (isRefreshing, in-flight dedup,
  unguarded busy clear); mutators dropped as on main.
- usePlanSync: branch's useLatestRequest guard also applied to main's new
  syncWithWish.
- useDayAssertions / useLatestBalance (new on main): busy flag now cleared
  unconditionally, as the branch did for every other consumer.
- useDayAssertions.assertDay gains the branch's `skipRefresh` option and a
  `refresh`, so Calendar's multi-day save keeps the batch shape (N writes,
  one trailing bumpPlanVersion + one assertions refresh) on main's
  assertDay path. Branch's M7 tests ported from the removed add/update API
  to assertDay.
- PlanSyncProvider: branch's isAuthenticated gate kept; eviction-preview
  state stays removed as on main.
- usePlannedDayExclusions.ts stays deleted (superseded by useDayAssertions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
This commit is contained in:
2026-09-21 07:40:09 +02:00
47 changed files with 1271 additions and 190 deletions
+191 -1
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'
@@ -116,7 +117,193 @@ 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 this hook's own mutators 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. The writers (`useDayAssertions`,
// `usePlanSync`) 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([])
// 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 (as a
// writer hook would) and then `rerender()`ing genuinely reproduces what
// the real `WorkspaceProvider`'s state update would do.
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))
})
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 () => {
// Stands in for a writer hook's post-mutation `bumpPlanVersion()`.
bump()
})
// 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 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 — one trailing bump for a whole batch of writes', () => {
// The per-write `skipRefresh` option itself lives on the writer hook
// now (`useDayAssertions.assertDay` — see its tests); this covers the
// reader's side of that contract.
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([])
// 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 through
// `useDayAssertions.assertDay(..., { skipRefresh: true })`, none of
// which touch this hook, then exactly ONE trailing bump for the
// whole batch (mirrors `handleSavePinnedDay`'s `finally`).
bump()
})
// 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
@@ -126,7 +313,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!([])