[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
@@ -0,0 +1,145 @@
# Consolidate AuthContext bootstrap onto POST refresh + /users/me chain
**Task**: AZ-510_auth_bootstrap_consolidation
**Name**: Auth bootstrap refresh consolidation
**Description**: Replace the broken `GET /api/admin/auth/refresh` bootstrap path in `AuthContext.tsx` with the same `POST /api/admin/auth/refresh` (credentials-included) path the 401-retry already uses, chaining `GET /api/admin/users/me` to fetch the user shape. Closes the long-standing Finding B3 logged against Architecture Vision principle P3.
**Complexity**: 3 points
**Dependencies**: None (POST refresh path already lives in `api/client.ts:88` and is exercised by tests)
**Component**: 02_auth (primary); 03_shared-ui (Header.test.tsx MSW handlers); 01_api-transport (no source change, but tests reference `api/client.ts`)
**Tracker**: AZ-510
**Epic**: AZ-509
## Problem
The SPA has two refresh-token paths and they disagree:
- **Bootstrap (broken)** — `src/auth/AuthContext.tsx:24` issues `GET /api/admin/auth/refresh` WITHOUT `credentials: 'include'`. The `Secure HttpOnly` refresh cookie set by `POST /api/admin/auth/login` is therefore never sent on the bootstrap call; the server cannot recognise the session; the request fails; the `.catch(() => {})` swallows the error; `setLoading(false)` resolves to "no user"; `ProtectedRoute` redirects to `/login`. A returning user with a perfectly valid refresh cookie is silently bounced to login on every page load.
- **401-retry (works)** — `src/api/client.ts:88` issues `POST /api/admin/auth/refresh` WITH `credentials: 'include'`. This path runs only when a subsequent authenticated request hits a 401; it does NOT run on bootstrap because line 73's `if (res.status === 401 && accessToken)` short-circuits when `accessToken` is null (which it always is on cold boot).
The broken path was flagged in the architecture documentation review (Architecture Vision principle P3 — "bearer in memory, refresh in HttpOnly cookie") and again in `_docs/02_document/architecture_compliance_baseline.md` as downstream item B3. Step 4 (Testability) chose to leave it for a behaviour cycle because the fix changes the bootstrap response handling, not just hardcoded strings — outside the testability-revision allowed-changes list.
Observable failure mode today: every page reload by an authenticated user shows a brief `/login` redirect followed by a forced re-login. Operators have learned to ignore it; the behaviour normalises a UX regression that violates P3.
## Outcome
- A returning user with a valid refresh cookie loads any URL (`/`, `/flights`, `/dataset`, …) and lands on the intended route without redirecting through `/login`.
- A returning user with an expired/invalid refresh cookie sees `/login` exactly once — no flash of the protected shell, no infinite redirect loop.
- The `GET /api/admin/auth/refresh` request disappears from network traces in the bootstrap window.
- `POST /api/admin/auth/refresh` (with credentials) followed by `GET /api/admin/users/me` (with bearer) appears in network traces on every successful bootstrap.
- Existing MSW tests pass against the new code path; no test handler relies on the deprecated GET bootstrap.
## Scope
### Included
- `src/auth/AuthContext.tsx` — rewrite the `useEffect` mount handler to:
1. `await fetch(getApiBase() + endpoints.admin.authRefresh(), { method: 'POST', credentials: 'include' })` — direct call (not `api.post()`, because `api.post` does not carry `credentials: 'include'` and adding it there would change every callsite's CORS posture).
2. On `!res.ok` → set `user: null` + `loading: false` + return.
3. On success → `setToken(data.token)`, then `api.get<AuthUser>('/api/admin/users/me')` to fetch the user shape, `setUser(authUser)`, `setLoading(false)`.
4. On the `/users/me` failure path → `setToken(null)`, `setUser(null)`, `setLoading(false)`. Do not throw silently — a 401 here is a genuine "refresh succeeded but the user record is gone" edge case worth surfacing through console.error.
- Tests (in-task; not deferred to a separate `test-spec sync` ticket):
- `src/auth/AuthContext.test.tsx` — update bootstrap tests to assert `POST /api/admin/auth/refresh` then `GET /api/admin/users/me`. Drop GET-bootstrap expectations.
- `src/auth/ProtectedRoute.test.tsx` — same MSW handler swap.
- `src/components/Header.test.tsx` — same MSW handler swap (the test fires a full app render that exercises bootstrap).
- New i18n strings: NONE (the user-visible behaviour change is the absence of the spurious redirect, not new copy).
- A small note added to `_docs/02_document/components/02_auth/description.md` recording that bootstrap and 401-retry now share a single wire shape.
### Excluded
- Refresh-cookie rotation backend changes — server keeps its existing rotate-on-refresh policy unchanged.
- SSE bearer-rotation hardening (ADR-008 consequences) — separate ticket scope; the `?token=...` query-string refresh problem is not addressed here.
- Changing `api.post` to default `credentials: 'include'` — out of scope; would expand the test matrix to every POST callsite.
- Embedding the user payload in the POST refresh response — would be a backend wire-contract change; the chained `/users/me` GET is intentional and matches existing semantics.
## Acceptance Criteria
**AC-1: Bootstrap uses POST refresh with credentials**
Given a fresh app mount (no in-memory bearer)
When `AuthProvider` renders
Then exactly one outbound request is made to `POST /api/admin/auth/refresh` with `credentials: 'include'`; no `GET /api/admin/auth/refresh` request occurs.
**AC-2: Successful refresh chains to /users/me**
Given the POST refresh returns 200 with `{ token: '<bearer>' }`
When the response resolves
Then `setToken('<bearer>')` is called, then `GET /api/admin/users/me` is requested with `Authorization: Bearer <bearer>`; on its 200 response the returned `AuthUser` is exposed via `useAuth().user`; `loading` flips to `false`.
**AC-3: Failed refresh shows /login without flash**
Given the POST refresh returns 401 (no valid cookie) or a network error occurs
When the response is handled
Then `setUser(null)` + `setLoading(false)` are called; `ProtectedRoute` renders the spinner during the in-flight bootstrap and then renders `/login` exactly once; no protected route component renders even momentarily; no second redirect fires.
**AC-4: /users/me failure after refresh success clears the bearer**
Given the POST refresh returns 200 but the subsequent `GET /users/me` returns 401 or fails
When the failure is handled
Then `setToken(null)` is called, `setUser(null)` + `setLoading(false)` are called, the user lands on `/login`, and `console.error` carries a diagnostic message identifying the edge case (refresh OK / user GET failed).
**AC-5: Returning user is not bounced through /login**
Given a refresh cookie that the backend considers valid
When the user reloads any protected URL (e.g. `/flights`)
Then no `/login` route is rendered (verified via a Playwright e2e check or via the React-Router history not containing a `/login` entry); the user sees the protected route immediately after the bootstrap spinner.
**AC-6: No regression in the 401-retry path**
Given an authenticated session with an expired bearer (`accessToken` non-null but server-side expired)
When the user makes any API call from a feature page
Then the existing `api/client.ts:73` 401-retry path is unchanged, calls `POST /api/admin/auth/refresh` with credentials, rotates the bearer, and replays the original request — behaviour identical to today.
## Non-Functional Requirements
**Performance**: bootstrap latency added by the chained `/users/me` GET is observable but acceptable — both calls hit the same nginx, same auth, same machine in prod; budget: under 200 ms p95 for the chain on the suite dev compose stack.
**Compatibility**: no change to the backend contract. The chained `/users/me` GET already exists and is the only source of user shape today; tests prove it.
**Reliability**: every failure mode (refresh 401, refresh network error, refresh 200 + users/me 401, refresh 200 + users/me network error) must resolve `loading` to `false` and put the user on `/login`. No path may leave `loading: true` indefinitely.
## Unit Tests
| AC Ref | What to Test | Required Outcome |
|--------|--------------|------------------|
| AC-1 | `AuthContext` mount with no prior bearer | exactly one POST `/api/admin/auth/refresh` is made; no GET refresh |
| AC-2 | POST refresh 200 → users/me 200 | bearer set + user set + `loading: false` |
| AC-3 | POST refresh 401 | `setUser(null)` + `loading: false` + no further requests |
| AC-3 | POST refresh network error (MSW `HttpResponse.error()`) | same as 401 case |
| AC-4 | POST refresh 200 → users/me 401 | `setToken(null)` + `setUser(null)` + `loading: false`; console.error called |
| AC-6 | request → 401 → POST refresh 200 → replay → 200 | unchanged 401-retry behaviour (regression guard) |
## Blackbox Tests
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|--------|------------------------|--------------|-------------------|----------------|
| AC-1 | Browser with valid refresh cookie | Reload `/flights` | DevTools Network panel shows POST `/api/admin/auth/refresh` followed by GET `/users/me` — no GET refresh | — |
| AC-5 | Browser with valid refresh cookie | Reload `/flights` | `/flights` renders directly; no `/login` is visible at any point | — |
| AC-3 | Browser with expired refresh cookie | Reload `/` | Spinner briefly visible; then `/login`; no flash of the protected shell | Reliability |
## Constraints
- The `getApiBase()` helper is the ONLY source for the base URL — do not bypass it.
- The new bootstrap path must NOT use `api.post()` because that helper does not carry `credentials: 'include'`. Direct `fetch(..., { method: 'POST', credentials: 'include' })` is intentional; the comment in `api/client.ts:88` documents the same pattern.
- The MSW test handlers must run against the **production** code paths — no `vi.mock('api/client')` or equivalent allowed.
- `setToken(null)` must precede `setUser(null)` on every failure path so that an in-flight component re-render does not see a partial state where `user: null` but `accessToken: <stale-bearer>`.
## Risks & Mitigation
**Risk 1: POST refresh response shape varies across environments**
- *Risk*: The 401-retry path assumes `{ token }`; production may also return `{ token, user }` (unverified). If so, the chained `/users/me` GET is wasted work.
- *Mitigation*: Inspect the live response shape during implementation; if `user` is present, skip the chained GET. The contract is single-source in the backend Admin API spec — verify there first, not by guessing.
**Risk 2: Tests assume GET-bootstrap fail-soft behaviour**
- *Risk*: Some current tests may assert the broken behaviour as the expected outcome ("when bootstrap fails the user lands on /login"). Re-pointing those tests at the POST path may surface assertion bugs that have been masking real regressions.
- *Mitigation*: Read each test's assertions before swapping the handler; if the test was asserting the broken behaviour as a feature, replace the assertion with the AC-3 behaviour from this spec. Do not preserve a test that documents the bug.
**Risk 3: Bootstrap latency regression**
- *Risk*: Two sequential GETs on every page load is more network than one. For very slow refresh cookies (e.g., over slow links), the user perceives a longer spinner.
- *Mitigation*: NFR Performance budget (200 ms p95 on dev compose) is the gate. If a real-world deployment exceeds it, the next iteration may embed user in the POST refresh response (Excluded scope above).
**Risk 4: Concurrent `<StrictMode>` double-mount fires bootstrap twice**
- *Risk*: React 18+ StrictMode dev mode mounts effects twice; two concurrent POST refresh requests could race the cookie rotation (the backend rotates on every refresh).
- *Mitigation*: Add a module-scoped in-flight guard (a `Promise<void> | null` ref) so the second mount awaits the first. The guard is small enough to live inside `AuthContext.tsx` without a new helper.
## References
- `src/auth/AuthContext.tsx:23-31` — broken bootstrap path being replaced.
- `src/api/client.ts:88-98` — working POST refresh path that informs the new bootstrap.
- `_docs/02_document/components/02_auth/description.md` — component spec; F2 (two refresh paths) is the documented finding this task closes.
- `_docs/02_document/architecture_compliance_baseline.md` — downstream item B3 (will move to RESOLVED).
- `_docs/02_document/architecture.md` Architecture Vision P3 — "bearer in memory, refresh in HttpOnly cookie".