mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 16:41:11 +00:00
2051088706
Implements 4 blackbox-test tasks for AZ-455 Phase A baseline:
- AZ-458 SSE lifecycle + bearer rotation: 9 fast tests (8 pass, 1
QUARANTINE for annotation-status); 4 e2e scenarios (gated by suite
stack). Uses tests/helpers/sse-mock.ts with globalThis.EventSource
monkey-patch per AC-3 (no stub of src/api/sse.ts). AC-2 bearer
rotation captured as documented drift via it.fails() — FlightsPage
useEffect deps do not include the token today.
- AZ-467 ProtectedRoute spinner + timeout + RBAC: 9 new fast tests
extending the AZ-457 file (6 pass, 3 QUARANTINE), plus 3 e2e
scenarios. FT-P-32 spinner a11y is it.fails() drift; FT-P-33 timeout
and FT-N-03/05 RBAC redirects are it.skip QUARANTINE (no production
behavior today). Positive control: admin_carol reaches /admin.
- AZ-468 Header flight-dropdown a11y: 6 fast tests (5 pass, 1
QUARANTINE). FT-P-30/31 are it.fails() drift (aria-expanded /
role=listbox / aria-activedescendant currently missing); FT-N-09
is it.skip QUARANTINE (no document keydown handler exists).
- AZ-482 Secrets + banned-libs + AC-N1 anti-criterion: 3 new static
checks (STC-SEC13 legacy integrations, STC-SEC14 concurrent-edit,
STC-SEC1B dist/ OWM key) plus refactor of 4 existing checks
(STC-N2/N4/S13/S6) to read from tests/security/banned-deps.json
via scripts/check-banned-deps.mjs per AZ-482 constraint
("deny-list lives in tests/security/banned-deps.json so additions
are visible in code review"). All 22 static checks PASS.
Totals: 57 fast tests pass + 9 skipped; 22/22 static checks pass.
Self-review verdict PASS_WITH_WARNINGS — all five findings are
documented drifts captured by it.fails() / it.skip QUARANTINE +
control tests. See _docs/03_implementation/batch_03_report.md
for the per-task / per-AC matrix and recommended Phase B follow-up
production tasks (Header a11y; ProtectedRoute spinner/timeout/RBAC;
SSE bearer-rotation reconnect; AnnotationsPage SSE).
Co-authored-by: Cursor <cursoragent@cursor.com>
247 lines
11 KiB
TypeScript
247 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { useEffect, useState } from 'react'
|
|
import { render, act, cleanup } from '@testing-library/react'
|
|
import { createSSE } from '../src/api/sse'
|
|
import { setToken } from '../src/api/client'
|
|
import { createFakeEventSource, type FakeEventSource } from './helpers/sse-mock'
|
|
|
|
// AZ-458 — SSE lifecycle + bearer-rotation reconnect.
|
|
//
|
|
// FT-P-09 — annotation-status SSE opens on <AnnotationsPage> mount (QUARANTINE)
|
|
// FT-P-10 — annotation-status SSE closes on unmount (QUARANTINE)
|
|
// FT-P-18 — live-GPS SSE opens within 5 s of flight select (fast)
|
|
// FT-P-19 — live-GPS SSE closes within 1 s of deselect (fast)
|
|
// NFT-PERF-03 — SSE bearer-rotation reconnect ≤ 5 s (e2e — see e2e/tests/sse_lifecycle.e2e.ts)
|
|
// NFT-PERF-04 — live-GPS SSE opens within 5 s of flight select (fast — same as FT-P-18)
|
|
// NFT-PERF-05 — live-GPS SSE closes within 1 s of deselect (fast — same as FT-P-19)
|
|
// NFT-PERF-06 — annotation-status SSE unsubscribes within 1 s on unmount (QUARANTINE)
|
|
// NFT-RES-02 — SSE bearer rotation — both streams reconnect within 5 s (e2e — see e2e companion)
|
|
//
|
|
// Black-box discipline: per AZ-458 AC-3 we do NOT stub `src/api/sse.ts`. We
|
|
// patch `globalThis.EventSource` so we observe what URLs the production
|
|
// module passes to the platform `new EventSource(url)` and when it calls
|
|
// `.close()`. The consumer pattern (`useEffect` + `createSSE` + cleanup) is
|
|
// reproduced by a small `<SseConsumer>` test harness that mirrors the shape
|
|
// in `src/features/flights/FlightsPage.tsx:65-68`.
|
|
//
|
|
// Production status notes (drift documentation):
|
|
// - AnnotationsPage today opens NO SSE — there is no annotation-status
|
|
// subscription in `src/features/annotations/AnnotationsPage.tsx`. The
|
|
// annotation-status scenarios are QUARANTINEd until the production path
|
|
// lands; the assertions below describe what the test will look like.
|
|
// - createSSE reads the bearer via `getToken()` at construction time but
|
|
// the FlightsPage `useEffect` deps are `[selectedFlight, mode]` only —
|
|
// the effect does NOT re-run when the bearer rotates. Bearer rotation
|
|
// therefore does NOT reconnect today; this is the AC-2 drift, captured
|
|
// via `it.fails()` against a `<SseConsumer>` that uses the same deps
|
|
// shape as the production consumer.
|
|
|
|
type EventSourceCtor = new (url: string) => EventSource
|
|
|
|
let constructed: Array<FakeEventSource & { closed: boolean }> = []
|
|
let originalEventSource: EventSourceCtor | undefined
|
|
|
|
function installFakeEventSource() {
|
|
constructed = []
|
|
originalEventSource = (globalThis as { EventSource?: EventSourceCtor }).EventSource
|
|
class StubEventSource extends EventTarget {
|
|
public url: string
|
|
public readyState: 0 | 1 | 2 = 0
|
|
public closed = false
|
|
constructor(url: string) {
|
|
super()
|
|
this.url = url
|
|
const fake = createFakeEventSource(url) as FakeEventSource & { closed: boolean }
|
|
fake.closed = false
|
|
const origClose = fake.close.bind(fake)
|
|
fake.close = () => {
|
|
fake.closed = true
|
|
origClose()
|
|
}
|
|
constructed.push(fake)
|
|
// Patch this instance to forward dispatch/close to the fake so
|
|
// production code's `source.close()` flows through.
|
|
this.close = () => fake.close()
|
|
const inst = this as unknown as { onmessage?: (e: MessageEvent) => void; onerror?: (e: Event) => void; readyState: number }
|
|
inst.readyState = 1
|
|
fake.addEventListener('message', (e) => inst.onmessage?.(e as MessageEvent))
|
|
fake.addEventListener('error', (e) => inst.onerror?.(e))
|
|
}
|
|
close() { /* replaced in constructor */ }
|
|
}
|
|
;(globalThis as { EventSource?: EventSourceCtor }).EventSource = StubEventSource as unknown as EventSourceCtor
|
|
}
|
|
|
|
function restoreEventSource() {
|
|
if (originalEventSource === undefined) {
|
|
delete (globalThis as { EventSource?: EventSourceCtor }).EventSource
|
|
} else {
|
|
;(globalThis as { EventSource?: EventSourceCtor }).EventSource = originalEventSource
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
installFakeEventSource()
|
|
})
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
restoreEventSource()
|
|
setToken(null)
|
|
})
|
|
|
|
// Consumer pattern mirror — same deps shape as FlightsPage.tsx:65-68.
|
|
function SseConsumer({ active, flightId, mode }: { active: boolean; flightId: string | null; mode: 'gps' | 'params' }) {
|
|
const [received, setReceived] = useState<unknown[]>([])
|
|
useEffect(() => {
|
|
if (!active || !flightId || mode !== 'gps') return
|
|
return createSSE<{ lat: number; lon: number }>(
|
|
`/api/flights/${flightId}/live-gps`,
|
|
(data) => setReceived((prev) => [...prev, data]),
|
|
)
|
|
}, [active, flightId, mode])
|
|
return <div data-testid="sse-events">{received.length}</div>
|
|
}
|
|
|
|
// Bearer-rotation consumer mirror — same deps shape (no token dep). This
|
|
// reproduces the production drift: rotating the bearer does NOT cause a
|
|
// reconnect because the effect dep array doesn't include the token.
|
|
function SseConsumerNoTokenDep({ flightId }: { flightId: string | null }) {
|
|
useEffect(() => {
|
|
if (!flightId) return
|
|
return createSSE(`/api/flights/${flightId}/live-gps`, () => { /* drop */ })
|
|
}, [flightId])
|
|
return null
|
|
}
|
|
|
|
describe('AZ-458 / createSSE — open/close lifecycle (FT-P-18/19, NFT-PERF-04/05)', () => {
|
|
describe('FT-P-18 / NFT-PERF-04 — open on flight select', () => {
|
|
it('opens exactly one EventSource when a flight is selected in gps mode', () => {
|
|
// Arrange
|
|
setToken('rot-token-A')
|
|
|
|
// Act — mount with selectedFlight=flight-1 + mode=gps
|
|
render(<SseConsumer active flightId="flight-1" mode="gps" />)
|
|
|
|
// Assert AC-1: exactly one EventSource constructed; URL targets the
|
|
// selected flight's live-gps endpoint and carries the bearer.
|
|
expect(constructed).toHaveLength(1)
|
|
expect(constructed[0].url).toContain('/api/flights/flight-1/live-gps')
|
|
expect(constructed[0].url).toContain('access_token=rot-token-A')
|
|
})
|
|
|
|
it('does NOT open an EventSource when mode != gps (negative control)', () => {
|
|
setToken('rot-token-A')
|
|
render(<SseConsumer active flightId="flight-1" mode="params" />)
|
|
expect(constructed).toHaveLength(0)
|
|
})
|
|
})
|
|
|
|
describe('FT-P-19 / NFT-PERF-05 — close on deselect', () => {
|
|
it('closes the EventSource when the flight is deselected', () => {
|
|
setToken('rot-token-A')
|
|
const { rerender } = render(<SseConsumer active flightId="flight-1" mode="gps" />)
|
|
expect(constructed).toHaveLength(1)
|
|
const opened = constructed[0]
|
|
expect(opened.closed).toBe(false)
|
|
|
|
// Act — deselect flight (flightId → null). The useEffect cleanup runs
|
|
// synchronously on the effect re-run, which is well under the 1 s budget.
|
|
rerender(<SseConsumer active flightId={null} mode="gps" />)
|
|
|
|
// Assert AC-1: EventSource closed.
|
|
expect(opened.closed).toBe(true)
|
|
// No new construction (the effect early-returns when flightId is null).
|
|
expect(constructed).toHaveLength(1)
|
|
})
|
|
|
|
it('closes on unmount (cleanup runs as part of teardown)', () => {
|
|
setToken('rot-token-A')
|
|
const { unmount } = render(<SseConsumer active flightId="flight-1" mode="gps" />)
|
|
expect(constructed).toHaveLength(1)
|
|
const opened = constructed[0]
|
|
|
|
unmount()
|
|
expect(opened.closed).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('AZ-458 / createSSE — bearer rotation (AC-2, NFT-PERF-03, NFT-RES-02)', () => {
|
|
it('captures the bearer that was current at construction time (sanity check)', () => {
|
|
setToken('boot-token')
|
|
render(<SseConsumer active flightId="flight-1" mode="gps" />)
|
|
expect(constructed[0].url).toContain('access_token=boot-token')
|
|
})
|
|
|
|
it.fails(
|
|
'AC-2 drift — when the bearer rotates AFTER the SSE is open, a new EventSource is created with the new token within 5 s (today the effect deps do not include the token, so this does NOT happen)',
|
|
async () => {
|
|
// Arrange — open the SSE with the bootstrap token.
|
|
setToken('boot-token')
|
|
render(<SseConsumerNoTokenDep flightId="flight-1" />)
|
|
expect(constructed).toHaveLength(1)
|
|
expect(constructed[0].url).toContain('access_token=boot-token')
|
|
|
|
// Act — rotate the bearer (as <AuthContext> would after a successful
|
|
// refresh).
|
|
await act(async () => {
|
|
setToken('rotated-token-B')
|
|
// Yield to the React scheduler so any token-dependent effect could fire.
|
|
await Promise.resolve()
|
|
})
|
|
|
|
// Assert AC-2: a second EventSource is opened with the new token.
|
|
// Today this assertion fails because the consumer's useEffect doesn't
|
|
// depend on the token — the old EventSource stays connected with the
|
|
// stale `access_token=boot-token`.
|
|
expect(constructed).toHaveLength(2)
|
|
expect(constructed[1].url).toContain('access_token=rotated-token-B')
|
|
},
|
|
)
|
|
|
|
it('control — bearer rotation today does NOT reconnect the live-GPS SSE (drift seen)', async () => {
|
|
setToken('boot-token')
|
|
render(<SseConsumerNoTokenDep flightId="flight-1" />)
|
|
expect(constructed).toHaveLength(1)
|
|
const stale = constructed[0]
|
|
await act(async () => {
|
|
setToken('rotated-token-B')
|
|
await Promise.resolve()
|
|
})
|
|
// QUARANTINE evidence: still only one EventSource; it still carries the
|
|
// stale token. The e2e companion exercises the real wire and will FAIL
|
|
// (correctly) once the spec is enforced suite-side.
|
|
expect(constructed).toHaveLength(1)
|
|
expect(stale.url).toContain('access_token=boot-token')
|
|
expect(stale.closed).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('AZ-458 / AnnotationsPage SSE (FT-P-09, FT-P-10, NFT-PERF-06)', () => {
|
|
it.skip(
|
|
'QUARANTINE (no production behavior): annotation-status SSE opens on <AnnotationsPage> mount and closes on unmount within 1 s',
|
|
() => {
|
|
// When AnnotationsPage gains an annotation-status subscription, the
|
|
// assertion shape (using the same EventSource stub as the live-GPS
|
|
// tests above) is:
|
|
// 1. mount <AnnotationsPage>
|
|
// 2. expect(constructed).toHaveLength(1) — one annotation-status SSE
|
|
// 3. expect(constructed[0].url).toContain('/api/annotations/.../status')
|
|
// 4. unmount; expect(constructed[0].closed).toBe(true)
|
|
// The test is skipped today because src/features/annotations/AnnotationsPage.tsx
|
|
// does not open any SSE; asserting against absent behavior would be noise.
|
|
expect(true).toBe(false) /* placeholder */
|
|
},
|
|
)
|
|
|
|
it('control — AnnotationsPage opens NO SSE today (drift evidence; the source does not call createSSE)', () => {
|
|
// We don't mount AnnotationsPage here (it pulls Leaflet-free but heavy
|
|
// canvas / video setup that has no bearing on the SSE assertion). The
|
|
// observable proof is structural: the only `createSSE` consumer today is
|
|
// FlightsPage. This test exists so the QUARANTINE state is visible in
|
|
// the test report rather than only in comments.
|
|
expect(constructed).toHaveLength(0)
|
|
})
|
|
})
|