mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 14:31:10 +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>
161 lines
6.8 KiB
TypeScript
161 lines
6.8 KiB
TypeScript
import { test, expect } from '@playwright/test'
|
|
|
|
// AZ-458 — e2e variants of the SSE-lifecycle and bearer-rotation scenarios
|
|
// that require the real suite stack (live-GPS simulator embedded in the
|
|
// `flights/:test` image; annotation-status generator in `annotations/:test`).
|
|
//
|
|
// FT-P-09 — annotation-status SSE opens on <AnnotationsPage> mount
|
|
// (QUARANTINE — production AnnotationsPage opens no SSE today)
|
|
// FT-P-18 — live-GPS SSE opens within 5 s of flight select
|
|
// NFT-PERF-03 — bearer-rotation reconnect ≤ 5 s after a refresh
|
|
// NFT-RES-02 — bearer rotation reconnects both live-GPS and annotation-status
|
|
// within 5 s (QUARANTINE for annotation-status; live-GPS portion
|
|
// documents the AC-2 drift — passes once production reconnects
|
|
// the EventSource on token rotation).
|
|
//
|
|
// Profile: e2e (gated by docker compose). Skipped in fast/host runs.
|
|
//
|
|
// Black-box discipline: assertions inspect the network surface (which
|
|
// `text/event-stream` requests opened/closed and when) and the DOM where
|
|
// live-GPS values land. The tests do NOT import production modules.
|
|
|
|
const ALICE_EMAIL = 'op_alice@test.local'
|
|
const ALICE_PASSWORD = 'TestPassword!23'
|
|
|
|
async function login(page: import('@playwright/test').Page) {
|
|
await page.goto('/login')
|
|
await page.getByLabel(/email/i).fill(ALICE_EMAIL)
|
|
await page.getByLabel(/password/i).fill(ALICE_PASSWORD)
|
|
await Promise.all([
|
|
page.waitForResponse(
|
|
(r) => r.url().includes('/api/admin/auth/login') && r.request().method() === 'POST',
|
|
),
|
|
page.getByRole('button', { name: /sign in/i }).click(),
|
|
])
|
|
}
|
|
|
|
test.describe('AZ-458 e2e — SSE lifecycle + bearer rotation', () => {
|
|
test('FT-P-18 / NFT-PERF-04 — live-GPS SSE opens within 5 s of flight select', async ({ page }) => {
|
|
test.setTimeout(20_000)
|
|
await login(page)
|
|
await page.goto('/flights')
|
|
|
|
// Switch the side panel to GPS mode and select a flight.
|
|
await page.getByRole('button', { name: /gps/i }).click()
|
|
|
|
const ssePromise = page.waitForRequest(
|
|
(req) => /\/api\/flights\/[^/]+\/live-gps/.test(req.url()),
|
|
{ timeout: 5_000 },
|
|
)
|
|
await page.getByRole('button', { name: /select flight/i }).click()
|
|
await page.getByRole('button', { name: /flight-1|recon alpha/i }).first().click()
|
|
|
|
const req = await ssePromise
|
|
|
|
// Assert AC-1: bearer is in the URL per ADR-008 (?access_token=...).
|
|
expect(req.url()).toMatch(/[?&]access_token=[A-Za-z0-9._-]+/)
|
|
})
|
|
|
|
test('FT-P-19 / NFT-PERF-05 — live-GPS SSE closes within 1 s of deselect', async ({ page }) => {
|
|
test.setTimeout(20_000)
|
|
await login(page)
|
|
await page.goto('/flights')
|
|
await page.getByRole('button', { name: /gps/i }).click()
|
|
await page.getByRole('button', { name: /select flight/i }).click()
|
|
await page.getByRole('button', { name: /flight-1|recon alpha/i }).first().click()
|
|
|
|
// Capture the EventSource on the page so the test can observe close().
|
|
const closedAt = await page.evaluate(async () => {
|
|
const original = window.EventSource
|
|
let lastClosed = -1
|
|
const proxy = new Proxy(original, {
|
|
construct(target, args) {
|
|
const inst = new target(...(args as ConstructorParameters<typeof EventSource>))
|
|
const origClose = inst.close.bind(inst)
|
|
inst.close = () => {
|
|
lastClosed = performance.now()
|
|
origClose()
|
|
}
|
|
return inst
|
|
},
|
|
})
|
|
window.EventSource = proxy as unknown as typeof EventSource
|
|
return new Promise<number>((resolve) => {
|
|
// Wait up to 5 s for the close to land.
|
|
const start = performance.now()
|
|
const tick = () => {
|
|
if (lastClosed > 0) resolve(lastClosed - start)
|
|
else if (performance.now() - start > 5000) resolve(-1)
|
|
else requestAnimationFrame(tick)
|
|
}
|
|
// Trigger the deselect from the test side via DOM.
|
|
const evt = new CustomEvent('e2e-deselect')
|
|
window.dispatchEvent(evt)
|
|
tick()
|
|
})
|
|
})
|
|
|
|
// Simulate "deselect" — for the contract test we go back to the params
|
|
// tab which makes the FlightsPage useEffect tear down the SSE (per
|
|
// FlightsPage.tsx:65-68 — the effect deps include `mode`).
|
|
await page.getByRole('button', { name: /params/i }).click()
|
|
|
|
expect(closedAt, 'EventSource close() should fire within 1 s of deselect').toBeLessThan(1000)
|
|
})
|
|
|
|
test('NFT-PERF-03 / NFT-RES-02 — live-GPS SSE reconnects with the new bearer within 5 s of rotation (AC-2 drift)', async ({ page }) => {
|
|
test.setTimeout(30_000)
|
|
test.fail(
|
|
true,
|
|
'AC-2 drift: FlightsPage useEffect deps do not include the bearer, so SSE does not reconnect on token rotation. Test passes once the production effect re-runs on token change.',
|
|
)
|
|
|
|
await login(page)
|
|
await page.goto('/flights')
|
|
await page.getByRole('button', { name: /gps/i }).click()
|
|
await page.getByRole('button', { name: /select flight/i }).click()
|
|
await page.getByRole('button', { name: /flight-1|recon alpha/i }).first().click()
|
|
|
|
const firstReq = await page.waitForRequest(
|
|
(req) => /\/api\/flights\/[^/]+\/live-gps/.test(req.url()),
|
|
{ timeout: 5_000 },
|
|
)
|
|
const firstUrl = firstReq.url()
|
|
|
|
// Trigger a refresh via the test-only endpoint that rotates the bearer.
|
|
// The admin/:test image exposes /api/admin/test-only/rotate-bearer (matches
|
|
// the convention used by /api/admin/test-only/reset). If absent, this is
|
|
// the moment to surface a stack-side gap.
|
|
const rotated = await page.request.post('/api/admin/test-only/rotate-bearer').catch(() => null)
|
|
expect(rotated?.ok(), 'admin/:test must expose /test-only/rotate-bearer').toBeTruthy()
|
|
|
|
// Drive AuthContext to absorb the new bearer (refresh path).
|
|
await page.evaluate(async () => {
|
|
await fetch('/api/admin/auth/refresh', { credentials: 'include' })
|
|
})
|
|
|
|
const secondReq = await page.waitForRequest(
|
|
(req) => /\/api\/flights\/[^/]+\/live-gps/.test(req.url()) && req.url() !== firstUrl,
|
|
{ timeout: 5_000 },
|
|
)
|
|
|
|
expect(secondReq.url()).toMatch(/[?&]access_token=[A-Za-z0-9._-]+/)
|
|
expect(secondReq.url()).not.toEqual(firstUrl)
|
|
})
|
|
|
|
test.skip('FT-P-09 / NFT-PERF-06 — annotation-status SSE opens on mount + closes within 1 s of unmount', () => {
|
|
// QUARANTINE: src/features/annotations/AnnotationsPage.tsx does not open
|
|
// any SSE today. Once an annotation-status subscription is added, this
|
|
// test follows the same shape as FT-P-18/FT-P-19 above but targets
|
|
// /api/annotations/.../status (or whatever the production URL ends up
|
|
// being). Leaving the assertion shape here as a planning anchor:
|
|
//
|
|
// await login(page)
|
|
// const annSsePromise = page.waitForRequest(
|
|
// (req) => /\/api\/annotations\/.*\/status/.test(req.url()),
|
|
// )
|
|
// await page.goto('/annotations')
|
|
// await annSsePromise
|
|
})
|
|
})
|