[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
+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)