mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 11:11:10 +00:00
[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:
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 })
|
||||
}),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user