diff --git a/_docs/02_document/components/02_auth/description.md b/_docs/02_document/components/02_auth/description.md index cfd11b5..d5aa8ce 100644 --- a/_docs/02_document/components/02_auth/description.md +++ b/_docs/02_document/components/02_auth/description.md @@ -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. diff --git a/_docs/02_tasks/todo/AZ-510_auth_bootstrap_consolidation.md b/_docs/02_tasks/done/AZ-510_auth_bootstrap_consolidation.md similarity index 100% rename from _docs/02_tasks/todo/AZ-510_auth_bootstrap_consolidation.md rename to _docs/02_tasks/done/AZ-510_auth_bootstrap_consolidation.md diff --git a/_docs/03_implementation/batch_13_cycle3_report.md b/_docs/03_implementation/batch_13_cycle3_report.md new file mode 100644 index 0000000..af753e8 --- /dev/null +++ b/_docs/03_implementation/batch_13_cycle3_report.md @@ -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). diff --git a/_docs/03_implementation/reviews/batch_13_review.md b/_docs/03_implementation/reviews/batch_13_review.md new file mode 100644 index 0000000..f16b2b5 --- /dev/null +++ b/_docs/03_implementation/reviews/batch_13_review.md @@ -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(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. diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 7adbbbe..d560b9a 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -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 diff --git a/src/api/endpoints.test.ts b/src/api/endpoints.test.ts index 78684ae..41aa38a 100644 --- a/src/api/endpoints.test.ts +++ b/src/api/endpoints.test.ts @@ -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') diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts index 9537901..04fdbae 100644 --- a/src/api/endpoints.ts +++ b/src/api/endpoints.ts @@ -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 diff --git a/src/auth/AuthContext.test.tsx b/src/auth/AuthContext.test.tsx index 77fed48..75a1065 100644 --- a/src/auth/AuthContext.test.tsx +++ b/src/auth/AuthContext.test.tsx @@ -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(
app
) + 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(
app
) 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
child #{ref.current}
} - // 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() 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. diff --git a/src/auth/AuthContext.tsx b/src/auth/AuthContext.tsx index 75393b7..47dad98 100644 --- a/src/auth/AuthContext.tsx +++ b/src/auth/AuthContext.tsx @@ -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 | 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 { + // 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(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(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 ( diff --git a/src/auth/ProtectedRoute.test.tsx b/src/auth/ProtectedRoute.test.tsx index e3ff8f0..1d918bb 100644 --- a/src/auth/ProtectedRoute.test.tsx +++ b/src/auth/ProtectedRoute.test.tsx @@ -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(() => { /* 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(() => { /* 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(() => { /* 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(() => { /* never */ }) return new HttpResponse(null, { status: 200 }) }), diff --git a/src/auth/index.ts b/src/auth/index.ts index 2559761..e31bb57 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -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' diff --git a/src/components/Header.test.tsx b/src/components/Header.test.tsx index 4ed072c..a8032cf 100644 --- a/src/components/Header.test.tsx +++ b/src/components/Header.test.tsx @@ -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) diff --git a/tests/annotations_endpoint.test.tsx b/tests/annotations_endpoint.test.tsx index e532f73..a45a1ab 100644 --- a/tests/annotations_endpoint.test.tsx +++ b/tests/annotations_endpoint.test.tsx @@ -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 } } diff --git a/tests/browser_support_responsive.test.tsx b/tests/browser_support_responsive.test.tsx index eb8e737..281a103 100644 --- a/tests/browser_support_responsive.test.tsx +++ b/tests/browser_support_responsive.test.tsx @@ -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 })), ) diff --git a/tests/bulk_validate.test.tsx b/tests/bulk_validate.test.tsx index 96d2326..278d558 100644 --- a/tests/bulk_validate.test.tsx +++ b/tests/bulk_validate.test.tsx @@ -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 })), diff --git a/tests/canvas_editor.test.tsx b/tests/canvas_editor.test.tsx index b7c1421..17ebd8c 100644 --- a/tests/canvas_editor.test.tsx +++ b/tests/canvas_editor.test.tsx @@ -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. diff --git a/tests/destructive_ux.test.tsx b/tests/destructive_ux.test.tsx index 5b00d30..cbdfefe 100644 --- a/tests/destructive_ux.test.tsx +++ b/tests/destructive_ux.test.tsx @@ -60,7 +60,7 @@ function captureClassDelete(): { deletes: CapturedDelete[] } { // AuthContext bootstraps with GET /api/admin/auth/refresh; tests using // -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 } } diff --git a/tests/detection_classes.test.tsx b/tests/detection_classes.test.tsx index c48aa19..84f63bd 100644 --- a/tests/detection_classes.test.tsx +++ b/tests/detection_classes.test.tsx @@ -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 } diff --git a/tests/detection_endpoints.test.tsx b/tests/detection_endpoints.test.tsx index d932d4f..22c82c5 100644 --- a/tests/detection_endpoints.test.tsx +++ b/tests/detection_endpoints.test.tsx @@ -132,7 +132,7 @@ function captureDetectAndBootstrap(opts?: { // Bootstrap — minimal handlers so mounts cleanly and // 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 })), diff --git a/tests/flight_selection_persistence.test.tsx b/tests/flight_selection_persistence.test.tsx index 314a6a4..477921f 100644 --- a/tests/flight_selection_persistence.test.tsx +++ b/tests/flight_selection_persistence.test.tsx @@ -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))), diff --git a/tests/form_hygiene.test.tsx b/tests/form_hygiene.test.tsx index 80e00b9..b0594b7 100644 --- a/tests/form_hygiene.test.tsx +++ b/tests/form_hygiene.test.tsx @@ -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 } } diff --git a/tests/msw/handlers/admin.ts b/tests/msw/handlers/admin.ts index ac0ad8b..43fe39e 100644 --- a/tests/msw/handlers/admin.ts +++ b/tests/msw/handlers/admin.ts @@ -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))), diff --git a/tests/network_resilience.test.tsx b/tests/network_resilience.test.tsx index 600b428..2cdacaf 100644 --- a/tests/network_resilience.test.tsx +++ b/tests/network_resilience.test.tsx @@ -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 })), ) }) diff --git a/tests/panel_width_persistence.test.tsx b/tests/panel_width_persistence.test.tsx index 5269ae3..3cd39b0 100644 --- a/tests/panel_width_persistence.test.tsx +++ b/tests/panel_width_persistence.test.tsx @@ -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` diff --git a/tests/photo_mode.test.tsx b/tests/photo_mode.test.tsx index 5c10123..8202ee7 100644 --- a/tests/photo_mode.test.tsx +++ b/tests/photo_mode.test.tsx @@ -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) diff --git a/tests/settings_resilience.test.tsx b/tests/settings_resilience.test.tsx index e44acc7..3e70d26 100644 --- a/tests/settings_resilience.test.tsx +++ b/tests/settings_resilience.test.tsx @@ -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)), diff --git a/tests/setup.ts b/tests/setup.ts index a64d109..bb2686f 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -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(() => { diff --git a/tests/tile_split_zoom.test.tsx b/tests/tile_split_zoom.test.tsx index 8350c85..df50024 100644 --- a/tests/tile_split_zoom.test.tsx +++ b/tests/tile_split_zoom.test.tsx @@ -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. diff --git a/tests/upload_size_cap.test.tsx b/tests/upload_size_cap.test.tsx index 94195e9..107f4b5 100644 --- a/tests/upload_size_cap.test.tsx +++ b/tests/upload_size_cap.test.tsx @@ -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)