Files
ui/e2e/tests/sse_lifecycle.e2e.ts
Oleksandr Bezdieniezhnykh 2051088706 [AZ-458] [AZ-467] [AZ-468] [AZ-482] Batch 3 - SSE/RBAC/Header/security tests
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>
2026-05-11 03:46:18 +03:00

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
})
})