Files
ui/tests/network_resilience.test.tsx
T
Oleksandr Bezdieniezhnykh 70fb452805 [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>
2026-05-13 02:59:31 +03:00

576 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react'
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from './msw/server'
import { jsonResponse, paginate } from './msw/helpers'
import {
renderWithProviders,
screen,
fireEvent,
waitFor,
userEvent,
} from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import { createSSE } from '../src/api'
import App from '../src/App'
import { AnnotationsPage } from '../src/features/annotations'
import { FlightProvider, useFlight } from '../src/components'
import { seedFlights } from './fixtures/seed_flights'
import {
AnnotationSource,
AnnotationStatus,
Affiliation,
CombatReadiness,
MediaType,
MediaStatus,
} from '../src/types'
import type { Media, AnnotationListItem } from '../src/types'
// AZ-478 — network-resilience contracts.
//
// AC-1 (NFT-RES-03): all `/api/*` requests fail with network errors at boot.
// The SPA must:
// a) render an error state (not silently degrade), and
// b) NOT register a service worker / offline cache.
// Today (drift): the AuthProvider's refresh fails →
// `user` stays null → ProtectedRoute redirects to /login;
// the LoginPage form renders, NOT a network-error
// indicator. (b) is enforced statically (STC-N3) AND
// asserted at runtime here for defence in depth.
// AC-2 (NFT-RES-09): annotation download via `canvas.toBlob` on a tainted
// canvas throws SecurityError. The page must NOT crash;
// a user-visible fallback (alternative download path or
// an in-DOM error) must be rendered.
// Today (drift): `AnnotationsPage.handleDownload` calls
// `canvas.toBlob` without a try/catch — the SecurityError
// escapes as an unhandled rejection from the async
// handleDownload. No fallback UI is rendered.
// AC-3 (NFT-RES-10): when an SSE EventSource fires `error` with
// `readyState === 2` (CLOSED), within 2 s a
// connection-lost indicator must appear in the DOM with
// an i18n-keyed text.
// Today (drift): `src/api/sse.ts` calls `onError?.(e)`
// but no consumer renders any user-visible indicator,
// and there is no `connection-lost` i18n key.
// ---------------------------------------------------------------------------
// AC-1 — offline at boot.
// ---------------------------------------------------------------------------
describe('AZ-478 — AC-1 (NFT-RES-03): network offline at boot', () => {
let originalServiceWorker: PropertyDescriptor | undefined
let unhandledHandler: ((reason: unknown) => void) | null = null
const swallowed: unknown[] = []
beforeEach(() => {
// Every /api/* request errors at the network layer (DNS/conn refused).
// This drives AuthProvider's refresh down its `.catch` branch.
server.use(
http.all('/api/*', () => HttpResponse.error()),
)
// Provide a minimal `navigator.serviceWorker` so we can assert
// registrations stays empty. JSDOM has no SW by default.
originalServiceWorker = Object.getOwnPropertyDescriptor(navigator, 'serviceWorker')
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
get() {
return {
getRegistrations: async () => [],
register: vi.fn(),
}
},
})
// Catch the deliberate refresh failure so vitest doesn't error on the
// unhandled rejection.
swallowed.length = 0
unhandledHandler = (reason: unknown) => {
swallowed.push(reason)
}
process.on('unhandledRejection', unhandledHandler)
})
afterEach(() => {
if (unhandledHandler) {
process.off('unhandledRejection', unhandledHandler)
unhandledHandler = null
}
if (originalServiceWorker) {
Object.defineProperty(navigator, 'serviceWorker', originalServiceWorker)
} else {
// navigator.serviceWorker is non-configurable in some envs; deletion
// may silently no-op — that's fine for cleanup.
try {
delete (navigator as unknown as { serviceWorker?: unknown }).serviceWorker
} catch {
// ignore
}
}
})
it('SPA does NOT register a service worker (defence in depth, also enforced statically as STC-N3)', async () => {
// Arrange / Act — boot the app at "/".
renderWithProviders(<App />, { withoutAuth: true, initialEntries: ['/'] })
// Allow the AuthProvider's refresh promise to reject.
await new Promise((r) => setTimeout(r, 50))
// Assert — no SW was registered. STC-N3 already gates this at the source
// tree, but the runtime check catches a future regression where the
// registration is moved to a dynamically-imported module that grep
// misses.
const regs = await navigator.serviceWorker.getRegistrations()
expect(regs).toEqual([])
})
it.fails(
'SPA renders a user-visible network-error indicator when boot APIs are offline',
async () => {
// Drift: today the fall-through behaviour is "redirect to /login".
// The LoginPage form renders; no error banner / offline indicator
// exists. Spec requires an in-DOM indicator (e.g., role="alert" with
// an i18n-keyed message such as "common.networkError").
renderWithProviders(<App />, { withoutAuth: true, initialEntries: ['/'] })
const banner = await screen.findByRole('alert', {}, { timeout: 2000 })
expect(banner.textContent ?? '').toMatch(/offline|network|connection/i)
},
)
it('control: today the SPA falls through to /login (drift snapshot)', async () => {
// Pins current behaviour. When AC-1 lands and the SPA shows a network
// banner instead, this test becomes flaky — the redirect may not happen
// — and the snapshot has to be updated alongside the AC fix.
renderWithProviders(<App />, { withoutAuth: true, initialEntries: ['/'] })
// The login form's i18n header text is "AZAION".
await waitFor(() => expect(screen.getByText('AZAION')).toBeInTheDocument(), {
timeout: 2000,
})
// The login form is rendered (Sign In submit button is the i18n key
// login.submit). LoginPage doesn't wire htmlFor between <label> and
// <input>, so getByLabelText doesn't resolve — match via the submit
// button text instead.
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// AC-2 — tainted-canvas fallback.
// ---------------------------------------------------------------------------
const FLIGHT = seedFlights[0]
const imageMedia: Media = {
id: 'media-az478',
name: 'tainted-fixture.jpg',
path: '/media/tainted-fixture.jpg',
mediaType: MediaType.Image,
mediaStatus: MediaStatus.New,
duration: null,
annotationCount: 1,
waypointId: null,
userId: 'user-az478',
}
const seedAnnotation: AnnotationListItem = {
id: 'ann-az478',
mediaId: imageMedia.id,
time: null,
createdDate: '2026-05-11T00:00:00Z',
userId: 'user-az478',
source: AnnotationSource.Manual,
status: AnnotationStatus.Created,
isSplit: false,
splitTile: null,
detections: [
{
id: 'det-az478',
classNum: 0,
label: 'class-0',
confidence: 0.9,
affiliation: Affiliation.Hostile,
combatReadiness: CombatReadiness.NotReady,
centerX: 0.5,
centerY: 0.5,
width: 0.1,
height: 0.1,
},
],
}
function rigDownloadEnv() {
server.use(
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)
return f ? jsonResponse(f) : new Response(null, { status: 404 })
}),
http.get('/api/annotations/settings/user', () =>
jsonResponse({
id: 'user-settings-az478',
userId: 'user-az478',
selectedFlightId: FLIGHT.id,
annotationsLeftPanelWidth: null,
annotationsRightPanelWidth: null,
datasetLeftPanelWidth: null,
datasetRightPanelWidth: null,
}),
),
http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
http.get('/api/annotations/media', () => jsonResponse(paginate([imageMedia], 1, 1000))),
http.get('/api/annotations/annotations', () =>
jsonResponse(paginate([seedAnnotation], 1, 1000)),
),
http.get('/api/annotations/classes', () => jsonResponse([])),
http.get('/api/annotations/dataset/info', () =>
jsonResponse({ totalCount: 0, statusCounts: {} }),
),
)
}
function FlightSeed({ children }: { children: React.ReactNode }): React.ReactElement {
const { selectFlight, selectedFlight } = useFlight()
useEffect(() => {
if (!selectedFlight) selectFlight(FLIGHT)
}, [selectFlight, selectedFlight])
return <>{children}</>
}
describe('AZ-478 — AC-2 (NFT-RES-09): tainted-canvas annotation download fallback', () => {
let originalToBlob: typeof HTMLCanvasElement.prototype.toBlob
let originalImage: typeof globalThis.Image
let toBlobCalls: number
let unhandledHandler: ((reason: unknown) => void) | null = null
const swallowed: unknown[] = []
let originalCreateObjectURL: typeof URL.createObjectURL | undefined
let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined
beforeEach(() => {
seedBearer()
rigDownloadEnv()
toBlobCalls = 0
// JSDOM lacks URL.createObjectURL / revokeObjectURL; AnnotationsPage's
// download path uses both for the .txt blob that's emitted BEFORE the
// canvas-to-PNG path. Patch the methods on the URL constructor directly
// (see _docs/LESSONS.md "Don't replace URL via vi.stubGlobal('URL', ...)"
// for why).
originalCreateObjectURL = (URL as unknown as { createObjectURL?: typeof URL.createObjectURL })
.createObjectURL
originalRevokeObjectURL = (URL as unknown as { revokeObjectURL?: typeof URL.revokeObjectURL })
.revokeObjectURL
;(URL as unknown as { createObjectURL: typeof URL.createObjectURL }).createObjectURL =
((blob: Blob) => `blob:az478-${(blob as { type?: string }).type ?? 'unknown'}`) as unknown as typeof URL.createObjectURL
;(URL as unknown as { revokeObjectURL: typeof URL.revokeObjectURL }).revokeObjectURL =
(() => {/* noop */ }) as unknown as typeof URL.revokeObjectURL
// Stub `Image` so handleDownload's `await new Promise(res => { img.onload = res })`
// resolves synchronously with a 640×480 frame. Production code requires
// `naturalWidth` / `naturalHeight` to be populated for `drawImage` to fire.
originalImage = globalThis.Image
globalThis.Image = class FakeImage extends EventTarget {
onload: ((e: Event) => unknown) | null = null
onerror: ((e: Event) => unknown) | null = null
crossOrigin: string | null = null
naturalWidth = 640
naturalHeight = 480
private _src = ''
get src(): string { return this._src }
set src(v: string) {
this._src = v
// Fire onload on next microtask so the await Promise sees a resolution.
queueMicrotask(() => this.onload?.(new Event('load')))
}
} as unknown as typeof globalThis.Image
// Make canvas.getContext return a working stub so handleDownload reaches
// the `canvas.toBlob` line. JSDOM's default returns null, which would
// short-circuit the function before the SecurityError path is exercised.
HTMLCanvasElement.prototype.getContext = vi.fn(
() =>
({
clearRect: vi.fn(), save: vi.fn(), restore: vi.fn(),
drawImage: vi.fn(), fillRect: vi.fn(), strokeRect: vi.fn(),
fillText: vi.fn(), arc: vi.fn(), beginPath: vi.fn(), fill: vi.fn(),
setLineDash: vi.fn(),
measureText: vi.fn(() => ({ width: 10 } as TextMetrics)),
fillStyle: '', strokeStyle: '', font: '', globalAlpha: 1, lineWidth: 1,
}) as unknown as CanvasRenderingContext2D,
) as unknown as typeof HTMLCanvasElement.prototype.getContext
// Force `toBlob` to throw SecurityError — this simulates the canvas
// having been tainted by a cross-origin draw without CORS headers
// (browsers throw `SecurityError` synchronously on `toBlob` /
// `toDataURL` against a tainted canvas).
originalToBlob = HTMLCanvasElement.prototype.toBlob
HTMLCanvasElement.prototype.toBlob = function tainted(): void {
toBlobCalls += 1
throw new DOMException(
'The canvas has been tainted by cross-origin data',
'SecurityError',
)
}
// Capture the resulting unhandled rejection (production lacks try/catch
// around toBlob — see AnnotationsPage.tsx:139). Without this, vitest
// exits non-zero even when test assertions pass.
swallowed.length = 0
unhandledHandler = (reason: unknown) => {
const msg = (reason instanceof Error ? reason.message : String(reason)) ?? ''
if (/tainted|SecurityError/i.test(msg)) {
swallowed.push(reason)
return
}
// Re-throw anything we didn't expect.
throw reason
}
process.on('unhandledRejection', unhandledHandler)
})
afterEach(() => {
clearBearer()
if (unhandledHandler) {
process.off('unhandledRejection', unhandledHandler)
unhandledHandler = null
}
HTMLCanvasElement.prototype.toBlob = originalToBlob
globalThis.Image = originalImage
if (originalCreateObjectURL === undefined) {
delete (URL as unknown as { createObjectURL?: typeof URL.createObjectURL }).createObjectURL
} else {
;(URL as unknown as { createObjectURL: typeof URL.createObjectURL }).createObjectURL =
originalCreateObjectURL
}
if (originalRevokeObjectURL === undefined) {
delete (URL as unknown as { revokeObjectURL?: typeof URL.revokeObjectURL }).revokeObjectURL
} else {
;(URL as unknown as { revokeObjectURL: typeof URL.revokeObjectURL }).revokeObjectURL =
originalRevokeObjectURL
}
})
it.fails(
'tainted-canvas download surfaces an in-DOM fallback (alt download path or role="alert")',
async () => {
// Drift: today, `canvas.toBlob` throws and the error escapes the async
// handleDownload. No alert / fallback link is rendered. Test passes
// once production wires a try/catch around toBlob and renders either:
// - an `<a download="...txt">` fallback, OR
// - a `role="alert"` carrying an i18n-keyed message (e.g.,
// "annotations.downloadTaintedCanvas").
renderWithProviders(
<FlightProvider>
<FlightSeed>
<AnnotationsPage />
</FlightSeed>
</FlightProvider>,
)
const mediaItem = await screen.findByText(/tainted-fixture\.jpg/)
await userEvent.click(mediaItem)
// Wait for the annotation to render in the right sidebar, then click it
// (only a selected annotation enables the download button).
const annRow = await waitFor(() => {
const rows = screen.getAllByText('—')
if (rows.length === 0) throw new Error('annotation row not yet visible')
return rows[0]
})
await userEvent.click(annRow)
const downloadBtn = await screen.findByTitle(/download/i)
await waitFor(() => expect(downloadBtn).not.toBeDisabled())
await userEvent.click(downloadBtn)
// Assert — `toBlob` was hit (we reached the tainted-canvas branch).
await waitFor(() => expect(toBlobCalls).toBeGreaterThan(0), { timeout: 2000 })
// Assert — fallback UI rendered.
const banner = await screen.findByRole('alert', {}, { timeout: 2000 })
expect(banner.textContent ?? '').toMatch(/download|tainted|cross.?origin/i)
},
)
it('control: page does NOT crash even though toBlob throws (drift snapshot)', async () => {
// Pins the current behaviour: the SecurityError propagates as an
// unhandled rejection, captured by our process listener; the React tree
// stays mounted (the alternative — a thrown SecurityError taking down
// the page — would be a critical regression and would surface as an
// uncaught error in the test runner).
renderWithProviders(
<FlightProvider>
<FlightSeed>
<AnnotationsPage />
</FlightSeed>
</FlightProvider>,
)
const mediaItem = await screen.findByText(/tainted-fixture\.jpg/)
await userEvent.click(mediaItem)
const annRow = await waitFor(() => {
const rows = screen.getAllByText('—')
if (rows.length === 0) throw new Error('annotation row not yet visible')
return rows[0]
})
await userEvent.click(annRow)
const downloadBtn = await screen.findByTitle(/download/i)
await waitFor(() => expect(downloadBtn).not.toBeDisabled())
await userEvent.click(downloadBtn)
await waitFor(() => expect(toBlobCalls).toBeGreaterThan(0), { timeout: 2000 })
// Tree still mounted — the media list header (i18n key annotations.title
// → "Annotations") is still present.
await waitFor(() => {
// Any element still under document.body proves the page didn't crash.
expect(document.body.contains(mediaItem)).toBe(true)
})
// The unhandled-rejection listener captured exactly one SecurityError.
expect(swallowed.length).toBeGreaterThan(0)
expect(String(swallowed[0])).toMatch(/SecurityError|tainted/i)
})
})
// ---------------------------------------------------------------------------
// AC-3 — SSE disconnect indicator.
// ---------------------------------------------------------------------------
interface FakeES extends EventTarget {
url: string
readyState: 0 | 1 | 2
close(): void
fireError(): void
}
let constructedEs: FakeES[] = []
function installFakeEventSource(): () => void {
const origCtor = (globalThis as { EventSource?: typeof EventSource }).EventSource
constructedEs = []
class FakeEventSource extends EventTarget {
public url: string
public readyState: 0 | 1 | 2 = 0
public onmessage: ((e: MessageEvent) => void) | null = null
public onerror: ((e: Event) => void) | null = null
public onopen: ((e: Event) => void) | null = null
constructor(url: string) {
super()
this.url = url
this.readyState = 1
const fakeRef = this as unknown as FakeES
fakeRef.fireError = () => {
this.readyState = 2 // CLOSED
const ev = new Event('error')
// Production SSE wiring uses `source.onerror` directly (not addEventListener).
this.onerror?.(ev)
this.dispatchEvent(ev)
}
constructedEs.push(fakeRef)
}
close(): void {
this.readyState = 2
}
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSED = 2
}
;(globalThis as { EventSource?: unknown }).EventSource = FakeEventSource as unknown as typeof EventSource
return () => {
if (origCtor) {
;(globalThis as { EventSource?: typeof EventSource }).EventSource = origCtor
} else {
delete (globalThis as { EventSource?: typeof EventSource }).EventSource
}
}
}
// Minimal consumer mirroring `AnnotationsSidebar`'s production SSE pattern
// (createSSE → onMessage → no error UI). This is the most direct boundary
// for asserting "no connection-lost indicator is rendered today".
function SseProbe(): React.ReactElement {
const [errored, setErrored] = useState(false)
useEffect(() => {
return createSSE(
'/api/annotations/annotations/events',
() => { /* drop */ },
() => setErrored(true),
)
}, [])
// The probe deliberately renders only the error TEST hook — production
// does NOT render any user-visible "connection-lost" banner today. The
// AC-3 it.fails() asserts on a banner; this probe's `errored` flag
// proves the SSE error path fired (control: spy hit), so the it.fails()
// is failing on UI, not on event plumbing.
return <div data-testid="sse-probe-errored">{errored ? 'errored' : 'open'}</div>
}
describe('AZ-478 — AC-3 (NFT-RES-10): SSE disconnect surfaces a connection-lost indicator', () => {
let restoreEs: (() => void) | null = null
beforeEach(() => {
restoreEs = installFakeEventSource()
server.use(
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
)
})
afterEach(() => {
restoreEs?.()
restoreEs = null
})
it.fails(
'within 2s of SSE error+CLOSED, a user-visible connection-lost indicator with i18n-keyed text is rendered',
async () => {
// Drift: production has no consumer that maps `onError` → user UI.
// `src/api/sse.ts` calls `onError?.(e)` and individual consumers (today
// only AnnotationsSidebar / FlightsPage) ignore that callback. There is
// no `connection-lost` i18n key (parity sweep returned 0 hits).
// Test passes when production wires a `<ConnectionStatus>` (or any
// component) that surfaces the disconnected state.
renderWithProviders(<SseProbe />)
// Wait for the SSE to be constructed (production opens it on mount).
await waitFor(() => expect(constructedEs.length).toBeGreaterThan(0))
// Trigger the disconnect — error fires with readyState=CLOSED.
const es = constructedEs[0]
es.fireError()
// Spec: indicator must appear within 2 s with an i18n-keyed text.
const banner = await screen.findByRole('alert', {}, { timeout: 2000 })
expect(banner.textContent ?? '').toMatch(/connection|disconnect|offline/i)
},
)
it('control: SSE error path fires (probe records errored=true) but no banner is rendered today', async () => {
// Pins the current behaviour: the SSE consumer correctly observes the
// error/CLOSED transition (the probe's local state flips), but no DOM
// node carries an i18n-keyed connection-lost message. Removing this
// test alongside the AC-3 fix is the migration path.
renderWithProviders(<SseProbe />)
await waitFor(() => expect(constructedEs.length).toBeGreaterThan(0))
const es = constructedEs[0]
// Sanity — before the error, the probe renders "open".
expect(screen.getByTestId('sse-probe-errored').textContent).toBe('open')
// Fire the disconnect, then yield React's rerender.
fireEvent.error(window) // no-op event so the next microtask fires
es.fireError()
await waitFor(() =>
expect(screen.getByTestId('sse-probe-errored').textContent).toBe('errored'),
)
// Drift: no role="alert" exists.
expect(screen.queryByRole('alert')).toBeNull()
})
})