import { afterEach, describe, expect, it, vi } from 'vitest' import { http, HttpResponse } from 'msw' import { server } from '../../tests/msw/server' import { api, getToken, setToken } from './client' import { seedBearer, clearBearer } from '../../tests/helpers/auth' import { seedNavigateToLogin } from '../../tests/helpers/navigate' // AZ-457 — Auth & token-handling at the apiClient surface. // FT-P-02 / rows 03, 12 — 401 → refresh → retry sequence (fast profile) // NFT-SEC-04 / row 06 — credentials:'include' on the cookie-bound refresh // NFT-PERF-02 / row 12 — exactly one /auth/refresh per refresh cycle // NFT-RES-01 / rows 03, 11 — apiClient half of transparent recovery // (render stability lives in AuthContext.test.tsx) // NFT-RES-08 — expired refresh cookie → setNavigateToLogin spy fires // // FT-P-01 (bootstrap refresh credentials:'include') exercises the React // composition root ('s mount-time fetch) and lives in // src/auth/AuthContext.test.tsx — the bootstrap path goes through api.get() // today, which does NOT thread credentials. Row 02 is `quarantined` until // the Step 4 bootstrap fix lands; the inverted assertion lives next to its // system-under-test. // // Black-box discipline (per AZ-457 AC-2): we only import the three public // accessors `setToken / getToken / setNavigateToLogin` plus the `api` wrapper // itself. We do NOT reach into request() / refreshToken() / handleResponse(). // Outbound requests are observed via MSW `server.use(...)` interception. describe('AZ-457 / src/api/client.ts — auth & token handling', () => { afterEach(() => { clearBearer() }) describe('FT-P-02 (rows 03, 12) — 401 → refresh → retry sequence', () => { it('refreshes once, retries the original request, returns 200', async () => { // Arrange seedBearer('expiring-bearer') let getHits = 0 let refreshHits = 0 const observedAuthHeaders: Array = [] server.use( http.get('/api/admin/users/me', ({ request }) => { getHits += 1 observedAuthHeaders.push(request.headers.get('Authorization')) if (getHits === 1) { return new HttpResponse(null, { status: 401 }) } return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' }) }), http.post('/api/admin/auth/refresh', () => { refreshHits += 1 return HttpResponse.json({ token: 'rotated-bearer' }) }), ) // Act const me = await api.get<{ id: string; email: string }>('/api/admin/users/me') // Assert — sequence per row 03 + count per row 12. expect(refreshHits).toBe(1) expect(getHits).toBe(2) // first 401, then retry expect(observedAuthHeaders[0]).toBe('Bearer expiring-bearer') expect(observedAuthHeaders[1]).toBe('Bearer rotated-bearer') expect(me.id).toBe('user-alice') expect(getToken()).toBe('rotated-bearer') }) it('does NOT refresh when there is no bearer (no in-flight session)', async () => { // Arrange — no seedBearer call → getToken() === null. let refreshHits = 0 server.use( http.get('/api/admin/users/me', () => new HttpResponse(null, { status: 401 })), http.post('/api/admin/auth/refresh', () => { refreshHits += 1 return HttpResponse.json({ token: 'should-not-be-issued' }) }), ) // Act + Assert — request() short-circuits the refresh branch when // accessToken is null (refreshing without a session would be a security // anti-pattern). await expect(api.get('/api/admin/users/me')).rejects.toThrow(/^401:/) expect(refreshHits).toBe(0) }) }) describe('NFT-SEC-04 (row 06) — credentials:\'include\' on the cookie-bound refresh', () => { it('the 401-recovery POST /auth/refresh carries credentials:\'include\'', async () => { // Arrange seedBearer('expiring-bearer') let refreshCredentials: RequestCredentials | null = null let refreshMethod: string | null = null server.use( http.get('/api/admin/users/me', () => new HttpResponse(null, { status: 401 })), http.post('/api/admin/auth/refresh', ({ request }) => { refreshCredentials = request.credentials refreshMethod = request.method return new HttpResponse(null, { status: 401 }) }), ) const navSpy = seedNavigateToLogin() // Act await expect(api.get('/api/admin/users/me')).rejects.toThrow('Session expired') // Assert expect(refreshMethod).toBe('POST') expect(refreshCredentials).toBe('include') expect(navSpy).toHaveBeenCalledTimes(1) }) // The broader "every authed fetch" claim is `quarantined` per row 02 in // results_report.md. Today the apiClient does NOT thread credentials onto // non-refresh requests; only the cookie-bound refreshToken() helper does. // The inverted assertion below documents the divergence so a future fix // is a deliberate decision (the test starts failing the day every authed // fetch carries credentials:'include' — at which point the it.fails // wrapper is removed in the same commit as the production fix). it.fails('every authed apiClient fetch carries credentials:\'include\' (quarantined — Step 4 bootstrap fix pending)', async () => { // Arrange seedBearer('test-bearer-default') const credentialsByUrl: Record = {} server.use( http.all('/api/admin/*', ({ request }) => { credentialsByUrl[request.url] = request.credentials return new HttpResponse(null, { status: 204 }) }), ) // Act await api.get('/api/admin/users/me') // Assert — intentionally fails today; row 02 quarantined. expect(Object.values(credentialsByUrl).every((c) => c === 'include')).toBe(true) }) }) describe('NFT-PERF-02 (row 12) — exactly one refresh round trip per cycle', () => { it('a single 401-retry cycle issues exactly one /auth/refresh', async () => { // Arrange seedBearer('expiring-bearer') let refreshHits = 0 const status401Once = vi.fn<() => Response>(() => new HttpResponse(null, { status: 401 }) as Response) let firstGet = true server.use( http.get('/api/admin/users/me', () => { if (firstGet) { firstGet = false return status401Once() } return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' }) }), http.post('/api/admin/auth/refresh', () => { refreshHits += 1 return HttpResponse.json({ token: 'one-time-rotation' }) }), ) // Act await api.get('/api/admin/users/me') // Assert expect(refreshHits).toBe(1) expect(status401Once).toHaveBeenCalledTimes(1) }) it('two parallel authed requests, both 401 → still one refresh per call (no global de-dupe expected)', async () => { // Arrange — Today's client.ts does NOT coalesce parallel refreshes. Each // 401 in-flight produces its own refresh round trip. This test pins the // current behavior so a future "race-coalescing" change is a deliberate // decision (one-cycle == one-refresh per row 12; cycles are per call). seedBearer('expiring-bearer') const tokens = ['rot1', 'rot2'] let refreshHits = 0 const seenAuthHeaders: string[] = [] server.use( http.get('/api/admin/users/me', ({ request }) => { const h = request.headers.get('Authorization') seenAuthHeaders.push(h ?? '') if (h === 'Bearer expiring-bearer') return new HttpResponse(null, { status: 401 }) return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' }) }), http.get('/api/admin/users', ({ request }) => { const h = request.headers.get('Authorization') seenAuthHeaders.push(h ?? '') if (h === 'Bearer expiring-bearer') return new HttpResponse(null, { status: 401 }) return HttpResponse.json({ items: [], totalCount: 0, page: 1, pageSize: 10 }) }), http.post('/api/admin/auth/refresh', () => { const t = tokens[refreshHits] ?? 'rot-fallback' refreshHits += 1 return HttpResponse.json({ token: t }) }), ) // Act await Promise.all([api.get('/api/admin/users/me'), api.get('/api/admin/users')]) // Assert — two independent cycles → two refreshes (no coalescing). expect(refreshHits).toBe(2) // Both retries reissued with a non-stale bearer. expect(seenAuthHeaders.filter((h) => h === 'Bearer expiring-bearer')).toHaveLength(2) }) }) describe('NFT-RES-01 (rows 03, 11) — apiClient half of transparent recovery', () => { it('callers observe a 200 response with no error surface after refresh+retry', async () => { // Arrange — render-stability half lives in AuthContext.test.tsx; this is // strictly the apiClient observable: from the caller's perspective, the // 401 was invisible. seedBearer('expiring-bearer') let firstHit = true server.use( http.get('/api/admin/users/me', () => { if (firstHit) { firstHit = false return new HttpResponse(null, { status: 401 }) } return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' }) }), http.post('/api/admin/auth/refresh', () => HttpResponse.json({ token: 'transparent-recovery' }), ), ) // Act const result = await api.get<{ id: string }>('/api/admin/users/me') // Assert — caller saw success, never an exception. expect(result.id).toBe('user-alice') // Side-effect: bearer was silently rotated (rows 03, 11). expect(getToken()).toBe('transparent-recovery') }) }) describe('NFT-RES-08 — expired refresh cookie → setNavigateToLogin spy fires', () => { it('clears bearer and invokes navigateToLogin spy when refresh returns 401', async () => { // Arrange seedBearer('expired-bearer') const navSpy = seedNavigateToLogin() server.use( http.get('/api/admin/users/me', () => new HttpResponse(null, { status: 401 })), http.post('/api/admin/auth/refresh', () => new HttpResponse(null, { status: 401 })), ) // Act + Assert await expect(api.get('/api/admin/users/me')).rejects.toThrow('Session expired') expect(navSpy).toHaveBeenCalledTimes(1) expect(navSpy).toHaveBeenCalledWith() // AC-4 — no args expect(getToken()).toBeNull() // bearer cleared }) it('does not navigate or clear bearer when 401 occurs without a session', async () => { // Arrange — refresh-bridge invariant: navigate only after a refresh // attempt failed (not on every 401). const navSpy = seedNavigateToLogin() server.use( http.get('/api/admin/users/me', () => new HttpResponse(null, { status: 401 })), ) // Act + Assert await expect(api.get('/api/admin/users/me')).rejects.toThrow(/^401:/) expect(navSpy).not.toHaveBeenCalled() expect(getToken()).toBeNull() }) }) }) // FT-P-01 (row 02) bootstrap-credentials assertion lives in // src/auth/AuthContext.test.tsx because it observes 's // mount-time fetch. Splitting it out keeps the system-under-test obvious. // Compile-time guard — the test never imports a private symbol from client.ts. type _PublicSurface = typeof setToken extends (t: string | null) => void ? true : never const _checkPublicSurface: _PublicSurface = true void _checkPublicSurface