mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 09:51:11 +00:00
[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>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// AZ-467 — e2e variants of the RBAC scenarios that require the real
|
||||
// admin/ service to issue role-specific bearers and the suite's nginx to
|
||||
// route /admin and /settings.
|
||||
//
|
||||
// FT-N-03 — Operator → /admin redirects to /flights (or to /login if
|
||||
// permission middleware is unauthenticated-equivalent)
|
||||
// FT-N-05 — integrator-dave → /settings redirects (no SETTINGS perm)
|
||||
//
|
||||
// Profile: e2e (gated by docker compose). Skipped in fast/host runs.
|
||||
//
|
||||
// Production status: src/auth/ProtectedRoute.tsx does NOT check
|
||||
// permissions today (only `user != null`). These tests are wrapped in
|
||||
// `test.fail()` to capture the drift — they will start passing once
|
||||
// ProtectedRoute gains a `requirePermission` prop (or wrapping) and the
|
||||
// /admin and /settings routes opt in.
|
||||
|
||||
const OPERATOR_EMAIL = 'op_bob@test.local' // Operator without ADMIN_WRITE / SETTINGS
|
||||
const INTEGRATOR_EMAIL = 'integrator_dave@test.local' // SystemIntegrator without SETTINGS
|
||||
const ADMIN_EMAIL = 'admin_carol@test.local' // Admin with full perms
|
||||
const TEST_PASSWORD = 'TestPassword!23'
|
||||
|
||||
async function login(page: import('@playwright/test').Page, email: string) {
|
||||
await page.goto('/login')
|
||||
await page.getByLabel(/email/i).fill(email)
|
||||
await page.getByLabel(/password/i).fill(TEST_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-467 e2e — RBAC route gating', () => {
|
||||
test('FT-N-03 — Operator hitting /admin is redirected to /flights (AC-3 drift)', async ({ page }) => {
|
||||
test.fail(
|
||||
true,
|
||||
'AC-3 drift: src/auth/ProtectedRoute.tsx today checks only `user != null`. Test passes once route-level RBAC lands.',
|
||||
)
|
||||
await login(page, OPERATOR_EMAIL)
|
||||
await page.goto('/admin')
|
||||
await expect(page).toHaveURL(/\/flights$/)
|
||||
})
|
||||
|
||||
test('FT-N-05 — integrator-dave hitting /settings is redirected away (AC-3 drift)', async ({ page }) => {
|
||||
test.fail(
|
||||
true,
|
||||
'AC-3 drift: same as FT-N-03 — ProtectedRoute does not gate on permissions today.',
|
||||
)
|
||||
await login(page, INTEGRATOR_EMAIL)
|
||||
await page.goto('/settings')
|
||||
await expect(page).not.toHaveURL(/\/settings$/)
|
||||
})
|
||||
|
||||
test('Admin reaches /admin normally (positive control)', async ({ page }) => {
|
||||
await login(page, ADMIN_EMAIL)
|
||||
await page.goto('/admin')
|
||||
await expect(page).toHaveURL(/\/admin$/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user