[AZ-510] Auth bootstrap: POST refresh + chained /users/me

Replace the broken `GET /api/admin/auth/refresh` (no `credentials:'include'`)
mount-time bootstrap with `POST /api/admin/auth/refresh` (with credentials)
chained to `GET /api/admin/users/me`. Returning users with a valid HttpOnly
refresh cookie no longer flash through `/login`. Closes Finding B3 / Vision P3.

- Add module-scoped `bootstrapInflight` guard (StrictMode double-mount safety)
  + test-only reset hook exported via the `src/auth` barrel; `tests/setup.ts`
  resets it in `afterEach` to prevent pending-promise leakage between tests.
- Defensive `hasPermission` against legacy `/users/me` payloads omitting
  `permissions`; default MSW handler now seeds `permissions` explicitly.
- Add `endpoints.admin.usersMe()` builder (STC-ARCH-02 forbids the literal).
- Bulk-swap 15 test files from `http.get` -> `http.post` for the refresh
  override so intentional bootstrap-fail tests still fail correctly.
- Update auth component description; mark B3 closed.
- Code review verdict PASS; static + fast suites green (231 / 13 skipped).

Batch report: _docs/03_implementation/batch_13_cycle3_report.md

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-13 02:59:31 +03:00
parent 098a556460
commit 70fb452805
29 changed files with 471 additions and 92 deletions
@@ -16,7 +16,7 @@
| Export | Signature | Notes |
|--------|-----------|-------|
| `AuthProvider({ children })` | React component | Wraps the app below `BrowserRouter`. Bootstraps via `GET /api/admin/auth/refresh` on mount. |
| `AuthProvider({ children })` | React component | Wraps the app below `BrowserRouter`. Bootstraps via `POST /api/admin/auth/refresh` (with `credentials: 'include'`) chained with `GET /api/admin/users/me` on mount — same wire shape as the 401-retry path in `api/client.ts`. |
| `useAuth(): AuthContextValue` | hook | Read-only access to `{ user, permissions, login, logout, refresh, loading }`. |
**`AuthContextValue`** (output DTO):
@@ -51,19 +51,20 @@ Consumes only — does not expose. Endpoint set (from `_docs/02_document/modules
**State Management**: Single React context. Token lives in an HTTP-only cookie (server-managed); the React state holds only the parsed user + permissions. No `localStorage`.
**Bootstrap sequence**:
**Bootstrap sequence** (consolidated by AZ-510):
1. Mount → set `loading: true`.
2. `api.post('/api/admin/auth/refresh')` to ask the server "do I have a valid session?".
3. On 200 → store user + permissions, `loading: false`.
4. On 4xx → user stays `null`, `loading: false`. `ProtectedRoute` then redirects.
2. `fetch(getApiBase() + endpoints.admin.authRefresh(), { method: 'POST', credentials: 'include' })` to ask the server "do I have a valid session?". Direct `fetch` (not `api.post`) because `api.post` does not thread `credentials: 'include'` and widening it would change CORS posture for every authed callsite.
3. On 200 → `setToken(data.token)`, then `api.get(endpoints.admin.usersMe())` to fetch the user shape (the POST refresh response is `{ token }` only — no user payload). On `/users/me` 200 → `setUser(authUser)`, `loading: false`. On `/users/me` failure → `setToken(null)`, `setUser(null)`, `loading: false`, `console.error` carries the diagnostic (refresh OK / user GET failed).
4. On refresh 4xx or network failure → `setUser(null)`, `loading: false`. `ProtectedRoute` then redirects to `/login`.
5. **StrictMode**: a module-scoped in-flight promise deduplicates the bootstrap network round-trip across React 18+ StrictMode double-mounts so the backend cookie rotation does not race itself.
> **PRIORITY finding (B3, copied from state.json)**: the bootstrap call inside `AuthContext.tsx` does not pass `credentials: 'include'` consistently — the cookie is therefore not sent on the very first request and bootstrap silently fails on a fresh page load. Confirmed real bug; Step 4 fix.
Bootstrap and the 401-retry path in `api/client.ts:88` now share a single wire shape — `POST /api/admin/auth/refresh` with credentials. Finding **B3** (bootstrap missing `credentials: 'include'`) is closed.
**Spinner UX**: `ProtectedRoute` renders a centered spinner during `loading`. The spinner has **no** `role="status"` / no accessible label / no timeout. (Findings B4, joint with Step 4 client.ts timeout flag.)
## 7. Caveats & Edge Cases
- **Bootstrap missing `credentials: 'include'`** → users land on `/login` even with a valid cookie session. PRIORITY Step 4.
- ~~**Bootstrap missing `credentials: 'include'`**~~ — closed by AZ-510. Bootstrap now uses POST refresh + chained `/users/me` with credentials, matching the 401-retry path.
- **Spinner accessibility** — Step 4.
- **Token-rotation interaction with SSE** — see `01_api-transport`. Auth refresh works for fetch but breaks every active EventSource.
- **No idle-timeout / inactivity logout** — server-side concern; UI tolerates whatever the server enforces.
@@ -0,0 +1,108 @@
# Batch 13 — AZ-510 (Auth bootstrap refresh consolidation)
**Date**: 2026-05-13
**Cycle**: 3 — autodev Step 10 (Implement), batch 1 of 3 (fixes-first order: AZ-510 → AZ-511 → AZ-512)
**Tickets**: AZ-510 (Epic AZ-509)
**Verdict**: PASS
---
## Task Results
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|------|--------|----------------|-------|-------------|--------|
| AZ-510_auth_bootstrap_consolidation | Done | 25 files | 231 passed / 13 skipped (full fast suite) | 6/6 ACs covered | None |
## AC Test Coverage: 6/6 covered
- AC-1 → `AuthContext.test.tsx` FT-P-01 (POST + `credentials:'include'` + no GET refresh)
- AC-2 → FT-P-01 (chain to `/users/me`, bearer set, loading false)
- AC-3 → `ProtectedRoute.test.tsx` (failed bootstrap → spinner → `/login` once); also
exercised by NFT-SEC-01's intermediate state
- AC-4 → `AuthContext.test.tsx` "AC-4 (AZ-510)" test (new, lines 108-138)
- AC-5 → `ProtectedRoute.test.tsx` admin-route success cases (no `/login` on success bootstrap)
- AC-6 → `AuthContext.test.tsx` NFT-SEC-01 + FT-P-03 (401-retry path unchanged); plus existing
`src/api/client.test.ts` retry tests
## Code Review Verdict: PASS
- Report: `_docs/03_implementation/reviews/batch_13_review.md`
- 0 findings (Critical / High / Medium / Low)
- Resolved baseline finding **B3** (Auth bootstrap missing `credentials:'include'` — Vision P3 violation)
## Auto-Fix Attempts: 0
No auto-fix loop needed.
## Stuck Agents: 0
---
## Implementation Notes
### Changed Files
**Production code**:
- `src/auth/AuthContext.tsx` — replaced GET-refresh `useEffect` with `runBootstrap()` POST +
chained `/users/me`; added module-scoped `bootstrapInflight` for StrictMode safety; defensive
`hasPermission` against legacy `/users/me` payloads missing `permissions`.
- `src/auth/index.ts` — re-exports `__resetBootstrapInflightForTests` to keep tests off deep
imports (STC-ARCH-01).
- `src/api/endpoints.ts` — added `endpoints.admin.usersMe()` builder; STC-ARCH-02 forbids the
literal `/api/admin/users/me` outside `endpoints.ts`.
**Tests** (handler swaps + new AC-4 + setup hook):
- `src/auth/AuthContext.test.tsx` — un-quarantined FT-P-01 (now POST regression guard); updated
FT-P-03 / NFT-SEC-01 / NFT-SEC-02 to POST refresh + chained `/users/me`; added AC-4 (AZ-510)
test.
- `src/auth/ProtectedRoute.test.tsx``withUser` helper now uses POST refresh + GET `/users/me`;
all `http.get('/api/admin/auth/refresh', …)` mocks swapped to POST.
- `src/components/Header.test.tsx``wireAuthAndFlights` updated to POST refresh + `/users/me`.
- `src/api/endpoints.test.ts` — wire-contract assertion for `endpoints.admin.usersMe()`.
- `tests/msw/handlers/admin.ts` — default `GET /users/me` handler returns user with explicit
`permissions: seedPermissions[opAlice.id] ?? []` (was missing → caused
`TypeError: Cannot read properties of undefined (reading 'includes')`).
- `tests/setup.ts``afterEach` hook calls `__resetBootstrapInflightForTests` to prevent
module-scoped inflight promise leakage between tests.
- 15 broader test files (`tests/*.test.tsx`) — bulk swap of intentional-fail bootstrap
handlers from `http.get``http.post` for `/api/admin/auth/refresh`. Without the swap the
POST-based bootstrap would auto-authenticate from the default handler and break tests that
expect `user: null`.
**Documentation**:
- `_docs/02_document/components/02_auth/description.md` — bootstrap section rewritten to
describe POST + chained `/users/me`; Finding B3 marked closed.
### Resolved Finding
- **B3** (`_docs/02_document/04_verification_log.md`): Auth bootstrap missing
`credentials:'include'` — closed by AZ-510. Architecture Vision principle P3 ("bearer in
memory, refresh in HttpOnly cookie") now satisfied on the bootstrap path.
### Test Run
- Static profile: PASS (all gates including STC-ARCH-01 / STC-ARCH-02 green)
- Fast profile: 31 files, 231 passed / 13 skipped (quarantined). No new failures.
- Suite duration: ~30s (fast) + ~55s (static).
### Notable Failure-Then-Fix Path During Implementation
1. **`ProtectedRoute.test.tsx` hangs (3 tests)** — module-scoped `bootstrapInflight` leaked
the never-resolving promise from one test into subsequent renders. Fix: test-only export
+ afterEach reset hook.
2. **STC-ARCH-01 violation**`tests/setup.ts` initially imported the test helper directly
from `src/auth/AuthContext`. Fix: re-export through the `src/auth` barrel; switch import.
3. **Widespread test failures** (`flight_selection_persistence.test.tsx`,
`browser_support_responsive.test.tsx`, …) — default `/users/me` handler omitted
`permissions`, so `hasPermission` crashed on `undefined.includes`. Fix: defensive
`hasPermission` + handler now seeds `permissions` from `seedPermissions[opAlice.id]`.
4. **Bulk handler swap** — 15 test files mocked `http.get('/api/admin/auth/refresh', …)` to
force bootstrap fail. Production now uses POST so the GET override is ignored and bootstrap
auto-authenticates from defaults. Fixed via per-file `sed` in a `for` loop (single `sed`
with the full file list hit a shell command-line length issue and reported "No such file
or directory").
## Next Batch
**Batch 14 (cycle 3 / batch 2 of 3)** — AZ-511 classColors carve-out to `src/class-colors/`
(closes Finding F3 + 5-coupled-places exemption).
@@ -0,0 +1,122 @@
# Code Review Report — Batch 13
**Batch**: AZ-510 (Auth bootstrap refresh consolidation)
**Cycle**: 3
**Date**: 2026-05-13
**Verdict**: PASS
---
## Phase 1: Context Loading
- Task spec: `_docs/02_tasks/todo/AZ-510_auth_bootstrap_consolidation.md` — replace broken
`GET /api/admin/auth/refresh` (no `credentials:'include'`) with `POST /api/admin/auth/refresh`
(with credentials) chained to `GET /api/admin/users/me`. Closes Finding B3 / Vision P3.
- Architecture vision principle P3 (`bearer in memory, refresh in HttpOnly cookie`) requires the
bootstrap path to send the HttpOnly refresh cookie; the prior code violated this.
- Architecture compliance baseline (`_docs/02_document/architecture_compliance_baseline.md`)
carries B3 as the open downstream item AZ-510 was created to close.
## Phase 2: Spec Compliance
| AC | Mechanism | Test Evidence |
|----|-----------|---------------|
| AC-1 — POST refresh + `credentials:'include'`, no GET refresh | `runBootstrap()` direct `fetch(..., {method:'POST', credentials:'include'})` (`AuthContext.tsx:45-48`) | `AuthContext.test.tsx` FT-P-01 asserts method, credentials, chain |
| AC-2 — Successful refresh chains to `/users/me` and resolves `loading:false` | `setToken(refreshData.token)` then `api.get<AuthUser>(endpoints.admin.usersMe())` (`AuthContext.tsx:51-53`); `setUser(result)` + `setLoading(false)` (`:78-79`) | FT-P-01 asserts `usersMeHits === 1`; `getToken()` becomes `BEARER` in NFT-SEC-01 |
| AC-3 — Failed refresh → `/login` exactly once, no flash | `if (!refreshRes.ok) return null` (`:49`) → `setUser(null)` + `setLoading(false)` (`:78-79`) | `ProtectedRoute.test.tsx` covers spinner→`/login` paths under POST-refresh handlers |
| AC-4 — `/users/me` failure clears bearer + logs | `try/catch` around `api.get` calls `setToken(null)` + `console.error` + returns `null` (`:54-61`); top-level `.then` then sets `user:null` + `loading:false` | New `AC-4 (AZ-510)` test in `AuthContext.test.tsx:108-138` asserts `getToken()` becomes `null`, `console.error` carries `"/users/me failed"` |
| AC-5 — Returning user not bounced to `/login` | Successful bootstrap path sets `user` before `loading:false`; `ProtectedRoute` only redirects when `!loading && !user` | Implicit in `ProtectedRoute.test.tsx` admin-route success cases (no `/login` rendered) |
| AC-6 — 401-retry path unchanged | `runBootstrap` uses direct `fetch`, not `api`; `api/client.ts:73-98` unchanged | `NFT-SEC-01` exercises bootstrap → 401 on `/users/me` → POST refresh rotation → replay; `FT-P-03` covers refresh transparency |
**Constraints**:
- C1 `getApiBase()` is the only base-URL source — honored (`:45`).
- C2 No `api.post()` for refresh — honored; uses direct `fetch` per the same comment in `api/client.ts:88`.
- C3 MSW handlers exercise production paths — honored; no `vi.mock('api/client')`.
- C4 `setToken(null)` precedes `setUser(null)` on every failure path — honored:
- `/users/me` failure: `setToken(null)` (`:59`) → return `null` → top-level `setUser(null)` (`:78`).
- Outer fetch reject: `setToken(null)` (`:87`) → `setUser(null)` (`:88`).
**Risk 4 (StrictMode double-mount)**: addressed via module-scoped `bootstrapInflight` promise
(`AuthContext.tsx:25, 70-74`). Test-only escape hatch `__resetBootstrapInflightForTests`
exported via the `src/auth` barrel and called in `tests/setup.ts` afterEach to prevent
inter-test promise leakage (was the proximate cause of `ProtectedRoute.test.tsx` hangs during
implementation).
No spec-gap findings.
## Phase 3: Code Quality
- **SOLID / SRP**: `runBootstrap` has one responsibility (refresh + chain + clear-on-failure);
`AuthProvider`'s effect orchestrates the inflight guard and react state — clean separation.
- **Error handling**: explicit `try/catch` around `/users/me`; outer `.catch` handles network
errors on the POST refresh itself. Both log via `console.error` with diagnostic prefix.
No bare catches introduced. (Pre-existing `try { await api.post(authLogout()) } catch {}` in
`logout` is out of scope.)
- **Naming**: `bootstrapInflight`, `runBootstrap`, `__resetBootstrapInflightForTests` are
precise and self-documenting. Test export name carries the `__…ForTests` convention.
- **Defensive `hasPermission`**: `user?.permissions?.includes(perm) ?? false` — correctly
guards against legacy `/users/me` payloads that omit `permissions`. Required because
several existing test fixtures returned the bare `User` shape without `permissions`.
- **Comments**: comments explain *why* (StrictMode race, CORS posture for `api.post`,
Constraint #4 ordering) — not *what*. Conforms to coderule.mdc.
- **Test quality**: AC-4 test asserts `getToken() === null` AND that `console.error` was
called with the diagnostic prefix — meaningful state + log assertion, not just "no throw".
No findings.
## Phase 4: Security Quick-Scan
- No hardcoded secrets, no SQL/string-interp queries, no `eval`/`exec`.
- `console.error('[AuthContext] Refresh succeeded but /users/me failed:', err)` logs the error
object. The error object originates from `api.get` which throws a structured error without
bearer material; the bearer was set via `setToken` before the try block but is not in the
thrown error. No bearer leak.
- HttpOnly refresh cookie continues to flow via `credentials:'include'` — never touched in JS.
NFT-SEC-02 explicitly verifies `document.cookie` carries no refresh-prefixed cookie.
No findings.
## Phase 5: Performance
- Two sequential network calls (POST refresh → GET `/users/me`) on every cold mount. Spec NFR
budgets 200 ms p95 for the chain on dev compose; same nginx/auth/host. Within budget.
- Module-scoped inflight promise prevents double-bootstrap under StrictMode dev double-mount,
removing the wasted second round-trip.
No findings.
## Phase 6: Cross-Task Consistency
Single-task batch — N/A.
## Phase 7: Architecture Compliance
| Check | Result |
|-------|--------|
| Layer direction | `src/auth/AuthContext.tsx` imports from `../api` (barrel) and `../types` only — auth → api allowed per architecture |
| Public API respect | All cross-component imports go through `src/api/index.ts` and `src/types/index.ts` barrels; no deep imports |
| New cyclic deps | None introduced |
| Duplicate symbols | None |
| Cross-cutting in component dir | `bootstrapInflight` is auth-specific state; correctly lives in the auth component |
**STC-ARCH-01 (cross-component deep imports)** static gate: passed after fixing the
`tests/setup.ts → src/auth/AuthContext` deep import by re-exporting
`__resetBootstrapInflightForTests` from `src/auth/index.ts` (barrel) and switching the import
to `../src/auth`.
**STC-ARCH-02 (no hardcoded API literals)** static gate: passed; new `endpoints.admin.usersMe`
builder added (`src/api/endpoints.ts`) and used at the only callsite.
### Baseline Delta
| Status | Finding | Notes |
|--------|---------|-------|
| Resolved | B3 — Auth bootstrap missing `credentials:'include'` | Was open in `_docs/02_document/04_verification_log.md`; bootstrap now POST + `credentials:'include'` + chained `/users/me`. |
| Carried over | (none in this file's scope) | — |
| Newly introduced | (none) | — |
## Verdict
**PASS** — no Critical / High / Medium / Low findings. All ACs covered with tests; constraints
honored; static and fast profiles green (231 passed, 13 quarantined skips unchanged); Finding
B3 resolved.
+3 -3
View File
@@ -6,9 +6,9 @@ step: 10
name: Implement
status: in_progress
sub_step:
phase: 0
name: awaiting-invocation
detail: ""
phase: 14
name: loop
detail: "batch 2 of 3 (AZ-511 next)"
retry_count: 0
cycle: 3
tracker: jira
+5
View File
@@ -31,6 +31,11 @@ describe('AZ-486 endpoints — wire-contract URLs', () => {
expect(endpoints.admin.users()).toBe('/api/admin/users')
})
it('admin.usersMe (AZ-510 — bootstrap chain)', () => {
// Assert
expect(endpoints.admin.usersMe()).toBe('/api/admin/users/me')
})
it('admin.user(id) interpolates the id', () => {
// Assert
expect(endpoints.admin.user('abc')).toBe('/api/admin/users/abc')
+5
View File
@@ -23,6 +23,11 @@ export const endpoints = {
authLogin: () => '/api/admin/auth/login',
authLogout: () => '/api/admin/auth/logout',
users: () => '/api/admin/users',
// AZ-510 — chained from POST authRefresh() during AuthProvider bootstrap
// (the POST refresh response is `{ token }` only; the user shape comes
// from this GET). Keeps `01_api-transport` as the single source of truth
// for `/api/admin/...` literals (STC-ARCH-02).
usersMe: () => '/api/admin/users/me',
user: (id: string) => `/api/admin/users/${id}`,
classes: () => '/api/admin/classes',
// DetectionClass.id is `number` in the type system; widened to accept
+95 -44
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { act, useRef } from 'react'
import { server } from '../../tests/msw/server'
@@ -8,9 +8,10 @@ import { seedBearer, clearBearer } from '../../tests/helpers/auth'
// AZ-457 — Auth & token-handling at the React composition root.
// FT-P-01 / row 02 — bootstrap refresh sends credentials:'include'
// (currently `quarantined` — bootstrap goes through
// api.get which doesn't thread credentials; row 02
// in results_report.md flags Step 4 fix pending)
// (un-quarantined by AZ-510; bootstrap is now POST
// with credentials per the consolidation, so the
// `it.fails` wrapper is removed and the assertion
// runs as a regression guard)
// FT-P-03 / row 11 — refresh transparency — children don't unmount;
// re-render delta ≤ 1
// NFT-SEC-01 / row 04 — bearer never written to localStorage/sessionStorage
@@ -104,22 +105,58 @@ describe('AZ-457 / src/auth/AuthContext.tsx — bootstrap, refresh, storage disc
clearBearer()
})
describe('FT-P-01 (row 02) — bootstrap refresh', () => {
it.fails('AuthProvider mount sends credentials:\'include\' on the bootstrap refresh (quarantined — Step 4 fix pending)', async () => {
// Arrange — the production bootstrap path goes through `api.get(...)`,
// which does NOT thread credentials. Row 02 in results_report.md is
// `quarantined` until the bootstrap fetch is migrated to a path that
// sets credentials:'include'. The inverted assertion below documents the
// divergence next to its system-under-test; the day the production code
// sends credentials:'include' on bootstrap, this test starts failing
// and the it.fails wrapper is removed.
let bootstrapCredentials: RequestCredentials | null = null
describe('AC-4 (AZ-510) — /users/me failure after refresh success clears the bearer', () => {
it('POST refresh 200 then GET /users/me 401 → setToken(null) + setUser(null) + loading false; console.error fires', async () => {
// Arrange — refresh succeeds and seeds a bearer; chained /users/me
// returns 401 (e.g. user record gone server-side after a stale cookie
// hit). Constraint #4 says the bearer must be cleared so an in-flight
// re-render does not see (user: null) alongside an active accessToken.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { /* swallow during assert */ })
let usersMeHits = 0
server.use(
http.get('/api/admin/auth/refresh', ({ request }) => {
http.post('/api/admin/auth/refresh', () => HttpResponse.json({ token: 'mid-flight-bearer' })),
http.get('/api/admin/users/me', () => {
usersMeHits += 1
return new HttpResponse(null, { status: 401 })
}),
)
// Act
renderWithProviders(<div data-testid="app">app</div>)
await waitFor(() => expect(usersMeHits).toBeGreaterThanOrEqual(1))
await waitFor(() => expect(getToken()).toBeNull())
// Assert — bearer cleared, error logged with diagnostic shape.
expect(getToken()).toBeNull()
expect(errorSpy).toHaveBeenCalled()
const loggedAtLeastOnceWithRefreshOkUserFailed = errorSpy.mock.calls.some(args =>
typeof args[0] === 'string' && args[0].includes('/users/me failed'),
)
expect(loggedAtLeastOnceWithRefreshOkUserFailed).toBe(true)
errorSpy.mockRestore()
})
})
describe('FT-P-01 (row 02) — bootstrap refresh', () => {
it("AuthProvider mount sends POST /api/admin/auth/refresh with credentials:'include'", async () => {
// Arrange — AZ-510 consolidated the bootstrap onto the same wire shape
// as the 401-retry: POST refresh with credentials:'include', then a
// chained GET /users/me for the user payload. This test is the
// regression guard for the credentials:'include' contract and the
// wire-method (POST vs the previously-broken GET).
let bootstrapMethod: string | null = null
let bootstrapCredentials: RequestCredentials | null = null
let usersMeHits = 0
server.use(
http.post('/api/admin/auth/refresh', ({ request }) => {
bootstrapMethod = request.method
bootstrapCredentials = request.credentials
return HttpResponse.json({ token: 'bootstrap-bearer' })
}),
http.get('/api/admin/users/me', () => {
usersMeHits += 1
return HttpResponse.json({
user: { id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [] },
token: 'bootstrap-bearer',
id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [],
})
}),
)
@@ -127,9 +164,12 @@ describe('AZ-457 / src/auth/AuthContext.tsx — bootstrap, refresh, storage disc
// Act
renderWithProviders(<div data-testid="app-root">app</div>)
await waitFor(() => expect(bootstrapCredentials).not.toBeNull())
await waitFor(() => expect(usersMeHits).toBe(1))
// Assert — intentionally fails today.
// Assert — POST + credentials:'include' + chained /users/me.
expect(bootstrapMethod).toBe('POST')
expect(bootstrapCredentials).toBe('include')
expect(usersMeHits).toBe(1)
})
})
@@ -143,22 +183,26 @@ describe('AZ-457 / src/auth/AuthContext.tsx — bootstrap, refresh, storage disc
renderTimes.push(ref.current)
return <div data-testid="stable-child">child #{ref.current}</div>
}
// Bootstrap returns a logged-in session (so the AuthProvider settles
// immediately), then we trigger a 401-retry cycle on a downstream call.
// Bootstrap (AZ-510 wire shape): POST refresh -> { token }, chained GET
// /users/me -> user. Await bootstrap settlement BEFORE re-overriding
// /users/me below — otherwise the 401-retry handler would intercept
// bootstrap's chained call and the test would fight itself.
server.use(
http.get('/api/admin/auth/refresh', () =>
HttpResponse.json({
user: { id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [] },
token: 'bootstrap-bearer',
}),
http.post('/api/admin/auth/refresh', () =>
HttpResponse.json({ token: 'bootstrap-bearer' }),
),
http.get('/api/admin/users/me', () =>
HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [] }),
),
)
renderWithProviders(<StableChild />)
await screen.findByTestId('stable-child')
await waitFor(() => expect(getToken()).toBe('bootstrap-bearer'))
const renderCountAfterBootstrap = renderTimes.length
// Force a 401-retry cycle on a downstream authed call.
// Force a 401-retry cycle on a downstream authed call. New /users/me
// handler returns 401 once, then 200 — exercises api/client.ts:73.
let firstHit = true
let refreshHits = 0
server.use(
@@ -191,22 +235,29 @@ describe('AZ-457 / src/auth/AuthContext.tsx — bootstrap, refresh, storage disc
describe('NFT-SEC-01 (row 04) — bearer never in localStorage / sessionStorage', () => {
it('over the entire test lifetime: no setItem call, no key/value contains the bearer', async () => {
// Arrange — full bootstrap + refresh + downstream-authed call lifecycle.
// AZ-510 wire shape: bootstrap = POST refresh -> { token } + chained GET
// /users/me. The /users/me handler returns 200 the first time (bootstrap
// chain), 401 the second time (forces 401-retry), then 200 again (post-
// retry replay).
const BEARER = 'leak-trap-bearer-' + Date.now()
let firstUsersMe = true
let refreshCallCount = 0
let usersMeCallCount = 0
server.use(
http.get('/api/admin/auth/refresh', () =>
HttpResponse.json({
user: { id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [] },
token: BEARER,
}),
),
http.post('/api/admin/auth/refresh', () => HttpResponse.json({ token: BEARER + '-rotated' })),
http.post('/api/admin/auth/refresh', () => {
refreshCallCount += 1
// Call 1 = bootstrap; subsequent calls = 401-retry rotation. Both
// are credential-only (no Authorization header), so order is the
// only discriminator.
return HttpResponse.json({ token: refreshCallCount === 1 ? BEARER : BEARER + '-rotated' })
}),
http.get('/api/admin/users/me', () => {
if (firstUsersMe) {
firstUsersMe = false
usersMeCallCount += 1
// Bootstrap chain (call 1) -> success; downstream test call (call 2)
// -> 401 forces a refresh; post-refresh replay (call 3) -> success.
if (usersMeCallCount === 2) {
return new HttpResponse(null, { status: 401 })
}
return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' })
return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [] })
}),
)
@@ -239,15 +290,15 @@ describe('AZ-457 / src/auth/AuthContext.tsx — bootstrap, refresh, storage disc
// refresh material, it would surface in `document.cookie` here.
// (HttpOnly cookies set by the real admin/ service are invisible to JS;
// jsdom's MSW responses set no cookies at all unless the test does.)
// AZ-510 wire shape: bootstrap = POST refresh + chained /users/me; the
// explicit downstream /users/me call below succeeds without rotation
// (the rotated-bearer assertion below is a defence-in-depth check —
// the value never appears anywhere because no rotation is triggered).
server.use(
http.get('/api/admin/auth/refresh', () =>
HttpResponse.json({
user: { id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [] },
token: 'bootstrap-bearer-XYZ',
}),
http.post('/api/admin/auth/refresh', () => HttpResponse.json({ token: 'bootstrap-bearer-XYZ' })),
http.get('/api/admin/users/me', () =>
HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [] }),
),
http.post('/api/admin/auth/refresh', () => HttpResponse.json({ token: 'rotated-bearer-ABC' })),
http.get('/api/admin/users/me', () => HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' })),
)
// Act — bootstrap + an authed call.
+75 -8
View File
@@ -1,5 +1,5 @@
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
import { api, endpoints, setToken } from '../api'
import { api, endpoints, getApiBase, setToken } from '../api'
import type { AuthUser } from '../types'
interface AuthState {
@@ -16,18 +16,81 @@ export function useAuth() {
return useContext(AuthContext)
}
// React 18+ StrictMode double-invokes effects in dev (mount → cleanup → mount),
// and the backend rotates the refresh cookie on every successful POST. Two
// concurrent bootstraps would race the rotation and leave the second one with
// a stale cookie. The module-scoped in-flight promise lets the second mount
// await the first's network round-trip instead of duplicating it. Risk 4 in
// AZ-510 spec.
let bootstrapInflight: Promise<AuthUser | null> | null = null
/**
* Test-only hook to clear the module-scoped in-flight bootstrap promise
* between Vitest tests. Production never imports this — it exists because
* Vitest does not reset module state between tests, so a test that mocks the
* bootstrap to never-resolve would otherwise leak a permanently-pending
* promise that subsequent tests would await forever. Wired into
* `tests/setup.ts` afterEach. Safe-no-op when nothing is in flight.
*/
export function __resetBootstrapInflightForTests(): void {
bootstrapInflight = null
}
async function runBootstrap(): Promise<AuthUser | null> {
// POST refresh with credentials — the whole point of the consolidation. Goes
// through fetch() directly (not api.post) because api.post does not thread
// credentials:'include'; widening api.post would change CORS posture for
// every authenticated callsite. Same pattern lives in api/client.ts:88 for
// the 401-retry refresh path.
const refreshRes = await fetch(getApiBase() + endpoints.admin.authRefresh(), {
method: 'POST',
credentials: 'include',
})
if (!refreshRes.ok) return null
const refreshData = (await refreshRes.json()) as { token: string }
setToken(refreshData.token)
try {
return await api.get<AuthUser>(endpoints.admin.usersMe())
} catch (err) {
// Refresh succeeded but /users/me failed — clear the bearer so an in-flight
// re-render does not see (user: null) alongside an active accessToken
// (Constraint #4 in spec).
console.error('[AuthContext] Refresh succeeded but /users/me failed:', err)
setToken(null)
return null
}
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get<{ user: AuthUser; token: string }>(endpoints.admin.authRefresh())
.then(data => {
setToken(data.token)
setUser(data.user)
let cancelled = false
const inflight =
bootstrapInflight ??
(bootstrapInflight = runBootstrap().finally(() => {
bootstrapInflight = null
}))
inflight
.then(result => {
if (cancelled) return
setUser(result)
setLoading(false)
})
.catch(() => {})
.finally(() => setLoading(false))
.catch(err => {
// Network error on the POST refresh itself (the /users/me failure path
// is handled inside runBootstrap and resolves to null). Reliability NFR
// requires loading to flip to false on every failure path.
console.error('[AuthContext] Bootstrap failed:', err)
if (cancelled) return
setToken(null)
setUser(null)
setLoading(false)
})
return () => {
cancelled = true
}
}, [])
const login = useCallback(async (email: string, password: string) => {
@@ -43,7 +106,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}, [])
const hasPermission = useCallback((perm: string) => {
return user?.permissions.includes(perm) ?? false
// `permissions` is required by the AuthUser type but the runtime payload
// from `/users/me` may omit it (older backend builds, or test fixtures
// returning the bare User shape). Treat missing as "no permissions" rather
// than crashing the React tree.
return user?.permissions?.includes(perm) ?? false
}, [user])
return (
+13 -9
View File
@@ -49,9 +49,13 @@ function SettingsSentinel() {
}
function withUser(user: typeof opAlice) {
// AZ-510 wire shape: bootstrap = POST refresh -> { token } + chained GET
// /users/me -> user. The previous shape (GET refresh returning { user, token })
// was the broken bootstrap path the consolidation removed.
server.use(
http.get('/api/admin/auth/refresh', () =>
jsonResponse({ token: 'test-bearer-default', user: { ...user, permissions: seedPermissions[user.id] ?? [] } }),
http.post('/api/admin/auth/refresh', () => jsonResponse({ token: 'test-bearer-default' })),
http.get('/api/admin/users/me', () =>
jsonResponse({ ...user, permissions: seedPermissions[user.id] ?? [] }),
),
)
}
@@ -66,7 +70,7 @@ describe('AZ-457 / src/auth/ProtectedRoute.tsx — redirect to /login', () => {
// Arrange — bootstrap refresh returns 401 (no session), AuthProvider's
// catch arm leaves user=null and loading=false.
server.use(
http.get('/api/admin/auth/refresh', () => new HttpResponse(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new HttpResponse(null, { status: 401 })),
)
// Act
@@ -98,7 +102,7 @@ describe('AZ-457 / src/auth/ProtectedRoute.tsx — redirect to /login', () => {
resolver = r
})
server.use(
http.get('/api/admin/auth/refresh', async () => {
http.post('/api/admin/auth/refresh', async () => {
await gate
return new HttpResponse(null, { status: 401 })
}),
@@ -136,7 +140,7 @@ describe('AZ-457 / src/auth/ProtectedRoute.tsx — redirect to /login', () => {
it('failed bootstrap refresh routes the user to /login', async () => {
// Arrange — expired-cookie 401 + no user in context.
server.use(
http.get('/api/admin/auth/refresh', () => new HttpResponse(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new HttpResponse(null, { status: 401 })),
)
// Act
@@ -177,7 +181,7 @@ describe('AZ-467 / src/auth/ProtectedRoute.tsx — spinner, timeout, RBAC', () =
async () => {
// Arrange — keep bootstrap pending forever so the spinner stays mounted.
server.use(
http.get('/api/admin/auth/refresh', async () => {
http.post('/api/admin/auth/refresh', async () => {
await new Promise<void>(() => { /* never resolves */ })
return new HttpResponse(null, { status: 200 })
}),
@@ -210,7 +214,7 @@ describe('AZ-467 / src/auth/ProtectedRoute.tsx — spinner, timeout, RBAC', () =
it('control — spinner renders today as a bare animate-spin div with no aria role (drift seen)', async () => {
server.use(
http.get('/api/admin/auth/refresh', async () => {
http.post('/api/admin/auth/refresh', async () => {
await new Promise<void>(() => { /* never resolves */ })
return new HttpResponse(null, { status: 200 })
}),
@@ -248,7 +252,7 @@ describe('AZ-467 / src/auth/ProtectedRoute.tsx — spinner, timeout, RBAC', () =
// noise. Once the production path lands the assertion shape is below.
vi.useFakeTimers()
server.use(
http.get('/api/admin/auth/refresh', async () => {
http.post('/api/admin/auth/refresh', async () => {
await new Promise<void>(() => { /* never */ })
return new HttpResponse(null, { status: 200 })
}),
@@ -271,7 +275,7 @@ describe('AZ-467 / src/auth/ProtectedRoute.tsx — spinner, timeout, RBAC', () =
it('control — bootstrap stuck at >10s today shows ONLY the spinner; no fallback (drift seen)', async () => {
vi.useFakeTimers()
server.use(
http.get('/api/admin/auth/refresh', async () => {
http.post('/api/admin/auth/refresh', async () => {
await new Promise<void>(() => { /* never */ })
return new HttpResponse(null, { status: 200 })
}),
+4
View File
@@ -1,2 +1,6 @@
export { AuthProvider, useAuth } from './AuthContext'
// Test-only helper — see AuthContext.tsx jsdoc. Production callers MUST NOT
// import this (the underscore prefix flags the intent and ESLint
// `no-restricted-syntax` could be added later if abuse appears).
export { __resetBootstrapInflightForTests } from './AuthContext'
export { default as ProtectedRoute } from './ProtectedRoute'
+4 -2
View File
@@ -48,8 +48,10 @@ function mountHeader() {
function wireAuthAndFlights() {
server.use(
http.get('/api/admin/auth/refresh', () =>
jsonResponse({ token: 'test-bearer-default', user: { ...opAlice, permissions: seedPermissions[opAlice.id] ?? [] } }),
// AZ-510 — bootstrap = POST refresh -> { token } + chained GET /users/me.
http.post('/api/admin/auth/refresh', () => jsonResponse({ token: 'test-bearer-default' })),
http.get('/api/admin/users/me', () =>
jsonResponse({ ...opAlice, permissions: seedPermissions[opAlice.id] ?? [] }),
),
http.get('/api/flights', ({ request }) => {
const url = new URL(request.url)
+1 -1
View File
@@ -100,7 +100,7 @@ function captureSavePost(): { saves: CapturedSave[] } {
}),
http.get('/api/annotations/classes', () => jsonResponse([])),
http.get('/api/annotations/dataset/info', () => jsonResponse({ totalCount: 1, statusCounts: {} })),
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
)
return { saves }
}
+1 -1
View File
@@ -24,7 +24,7 @@ import { FlightProvider, Header } from '../src/components'
function rigHeaderEnv(): void {
server.use(
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.get('/api/flights', () => jsonResponse(paginate([], 1, 1000))),
http.get('/api/annotations/settings/user', () => new Response(null, { status: 404 })),
)
+1 -1
View File
@@ -78,7 +78,7 @@ function rigDatasetAndBulk(): SyncRig {
const validatedAfterPost = { current: false }
server.use(
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.get('/api/flights', () => jsonResponse(paginate([], 1, 1000))),
http.get('/api/annotations/settings/user', () => new Response(null, { status: 404 })),
http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
+1 -1
View File
@@ -184,7 +184,7 @@ describe('AZ-471 — CanvasEditor (draw / resize / multi-select / zoom / pan)',
// an unhandled request triggers MSW's onUnhandledRequest:'error'. A 401
// here keeps AuthProvider's `.catch` quiet (loading flips to false) and
// satisfies AC-3 of AZ-456.
server.use(http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })))
server.use(http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })))
// Force the container's clientWidth/Height (jsdom default = 0) so the
// CanvasEditor's `useEffect(isVideo)` populates `imgSize` to 640×480.
+1 -1
View File
@@ -60,7 +60,7 @@ function captureClassDelete(): { deletes: CapturedDelete[] } {
// AuthContext bootstraps with GET /api/admin/auth/refresh; tests using
// <ProtectedRoute>-less render still mount AuthProvider. Return 401 so
// the unauth path resolves quickly and bootstrap finishes.
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
)
return { deletes }
}
+1 -1
View File
@@ -56,7 +56,7 @@ function captureClassesGets(payload: DetectionClass[], opts?: { status?: number
// unhandled-request errors without affecting these tests (AuthProvider's
// .catch swallows the failure and DetectionClasses doesn't depend on auth
// user state).
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
)
return calls
}
+1 -1
View File
@@ -132,7 +132,7 @@ function captureDetectAndBootstrap(opts?: {
// Bootstrap — minimal handlers so <AnnotationsPage> mounts cleanly and
// <MediaList> shows the seeded media item.
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.get('/api/flights', () => jsonResponse(paginate([], 1, 1000))),
http.get('/api/annotations/settings/user', () => new Response(null, { status: 404 })),
http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
+1 -1
View File
@@ -40,7 +40,7 @@ function rigFlightEnv(opts?: { seedSelectedFlightId?: string | null }): FlightRi
server.use(
// AuthProvider GET — silence MSW unhandled warnings.
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.get('/api/flights', () => jsonResponse(paginate(seedFlights, 1, 1000))),
+1 -1
View File
@@ -76,7 +76,7 @@ function captureSettingsPut(): { puts: CapturedPut[] } {
}),
),
http.get('/api/flights/aircrafts', () => jsonResponse([])),
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
)
return { puts }
}
+8 -2
View File
@@ -1,6 +1,6 @@
import { http } from 'msw'
import { jsonResponse, noContent, paginate } from '../helpers'
import { seedUsers, opAlice } from '../../fixtures/seed_users'
import { seedUsers, opAlice, seedPermissions } from '../../fixtures/seed_users'
import { seedClasses } from '../../fixtures/seed_classes'
// Default `/api/admin/*` handlers — auth round-trip, users, classes-write,
@@ -28,7 +28,13 @@ export const adminHandlers = [
http.post('/api/admin/auth/logout', () => noContent()),
http.get('/api/admin/users/me', () => jsonResponse(opAlice)),
// AZ-510 chains GET /users/me after POST refresh during AuthProvider
// bootstrap. The default user shape includes `permissions` so production
// code paths (e.g., hasPermission, RBAC route gates) get a realistic
// payload without each test having to override.
http.get('/api/admin/users/me', () =>
jsonResponse({ ...opAlice, permissions: seedPermissions[opAlice.id] ?? [] }),
),
http.get('/api/admin/users', () => jsonResponse(paginate(seedUsers))),
+2 -2
View File
@@ -202,7 +202,7 @@ const seedAnnotation: AnnotationListItem = {
function rigDownloadEnv() {
server.use(
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.get('/api/flights', () => jsonResponse(paginate(seedFlights, 1, 1000))),
http.get('/api/flights/:id', ({ params }) => {
const f = seedFlights.find((x) => x.id === params.id)
@@ -516,7 +516,7 @@ describe('AZ-478 — AC-3 (NFT-RES-10): SSE disconnect surfaces a connection-los
beforeEach(() => {
restoreEs = installFakeEventSource()
server.use(
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
)
})
+1 -1
View File
@@ -44,7 +44,7 @@ function rigPanelEnv(opts?: { seedSettings?: boolean }): { puts: CapturedPut[] }
const puts: CapturedPut[] = []
server.use(
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.get('/api/flights', () => jsonResponse(paginate([], 1, 1000))),
// The user settings GET — when seedSettings is true, return a payload
// that includes both the legacy per-page width fields AND a `panelWidths`
+2 -2
View File
@@ -103,7 +103,7 @@ function makeHarnessState(): HarnessState {
function captureClassesGet(payload: DetectionClass[]) {
server.use(
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.get('/api/annotations/classes', () => jsonResponse(payload)),
)
}
@@ -262,7 +262,7 @@ interface PostCapture {
function rigSaveEnv(): PostCapture {
const classNums: number[][] = []
server.use(
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.get('/api/flights', () => jsonResponse(paginate(seedFlights, 1, 1000))),
http.get('/api/flights/:id', ({ params }) => {
const f = seedFlights.find((x) => x.id === params.id)
+1 -1
View File
@@ -66,7 +66,7 @@ function rigSettingsEnv(failure: SettingsFailure): SettingsRig {
const responseAt = { value: null as number | null }
server.use(
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.get('/api/annotations/settings/system', () => jsonResponse(SYSTEM_SEED)),
http.get('/api/annotations/settings/directories', () => jsonResponse(DIRS_SEED)),
http.get('/api/flights/aircrafts', () => jsonResponse(seedAircraft)),
+4
View File
@@ -3,6 +3,7 @@ import { afterAll, afterEach, beforeAll } from 'vitest'
import { cleanup } from '@testing-library/react'
import { server } from './msw/server'
import { setToken, setNavigateToLogin } from '../src/api'
import { __resetBootstrapInflightForTests } from '../src/auth'
// JSDOM polyfills for browser APIs production code touches at mount time.
// These are no-op stubs — tests that exercise the actual behavior install
@@ -57,6 +58,9 @@ afterEach(() => {
/* default no-op for tests; production accessor restored implicitly
on next module reload — tests must re-seed if they assert on it. */
})
// AZ-510 — clear AuthProvider's module-scoped in-flight bootstrap promise so
// a never-resolving fixture in test N does not leak into test N+1.
__resetBootstrapInflightForTests()
})
afterAll(() => {
+1 -1
View File
@@ -117,7 +117,7 @@ function datasetRowFromAnnotation(a: AnnotationListItem): DatasetItem {
beforeEach(() => {
seedBearer()
server.use(
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
// FlightProvider mounts a user-settings fetch when authenticated. The
// dataset surface does not depend on it; we satisfy MSW's unhandled-
// request gate with a 404 so the noise does not pollute the report.
+1 -1
View File
@@ -37,7 +37,7 @@ function rigUploadEnv(opts: { uploadStatus: number }): UploadRig {
const posts: { url: string; pathname: string; status: number }[] = []
server.use(
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.get('/api/flights', () => jsonResponse(paginate(seedFlights, 1, 1000))),
http.get('/api/flights/:id', ({ params }) => {
const f = seedFlights.find((x) => x.id === params.id)