mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 08:11:10 +00:00
70fb452805
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>
576 lines
22 KiB
TypeScript
576 lines
22 KiB
TypeScript
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()
|
||
})
|
||
})
|