diff --git a/_docs/02_tasks/todo/AZ-458_test_sse_lifecycle.md b/_docs/02_tasks/done/AZ-458_test_sse_lifecycle.md similarity index 100% rename from _docs/02_tasks/todo/AZ-458_test_sse_lifecycle.md rename to _docs/02_tasks/done/AZ-458_test_sse_lifecycle.md diff --git a/_docs/02_tasks/todo/AZ-467_test_protected_route_rbac.md b/_docs/02_tasks/done/AZ-467_test_protected_route_rbac.md similarity index 100% rename from _docs/02_tasks/todo/AZ-467_test_protected_route_rbac.md rename to _docs/02_tasks/done/AZ-467_test_protected_route_rbac.md diff --git a/_docs/02_tasks/todo/AZ-468_test_header_dropdown.md b/_docs/02_tasks/done/AZ-468_test_header_dropdown.md similarity index 100% rename from _docs/02_tasks/todo/AZ-468_test_header_dropdown.md rename to _docs/02_tasks/done/AZ-468_test_header_dropdown.md diff --git a/_docs/02_tasks/todo/AZ-482_test_secrets_and_banned_libs.md b/_docs/02_tasks/done/AZ-482_test_secrets_and_banned_libs.md similarity index 100% rename from _docs/02_tasks/todo/AZ-482_test_secrets_and_banned_libs.md rename to _docs/02_tasks/done/AZ-482_test_secrets_and_banned_libs.md diff --git a/_docs/03_implementation/batch_03_report.md b/_docs/03_implementation/batch_03_report.md new file mode 100644 index 0000000..77872b3 --- /dev/null +++ b/_docs/03_implementation/batch_03_report.md @@ -0,0 +1,211 @@ +# Batch Report + +**Batch**: 03 +**Tasks**: AZ-458 (SSE lifecycle), AZ-467 (ProtectedRoute spinner/timeout/RBAC), AZ-468 (Header dropdown a11y), AZ-482 (secrets/banned-libs/AC-N1) +**Date**: 2026-05-11 +**Cycle**: Phase A baseline, Step 6 — Implement Tests +**Total complexity**: 14 pts (5 + 4 + 2 + 3) + +## Task Results + +| Task | Status | Files Modified | Tests | AC Coverage | Issues | +|------|--------|---------------|-------|-------------|--------| +| AZ-458_test_sse_lifecycle | Done | 2 created (1 fast + 1 e2e) | 9 fast (8 pass, 1 skipped); 4 e2e (1 expected-fail, 1 skipped) | 3 / 3 ACs covered | 2 documented drifts (AC-2 bearer rotation `it.fails()`; annotation-status QUARANTINE `it.skip`) | +| AZ-467_test_protected_route_rbac | Done | 1 modified (extends batch-2 file) + 1 e2e created | 9 new fast (6 pass, 3 skipped); 3 e2e (2 expected-fail, 1 pass) | 4 / 4 ACs covered | 4 documented drifts (FT-P-32 `it.fails()`; FT-P-33/N-03/N-05 `it.skip` QUARANTINE) | +| AZ-468_test_header_dropdown | Done | 1 created (fast) | 6 fast (5 pass, 1 skipped) | 3 / 3 ACs covered | 3 documented drifts (FT-P-30/31 `it.fails()`; FT-N-09 `it.skip` QUARANTINE) | +| AZ-482_test_secrets_and_banned_libs | Done | 2 created (deny-list JSON + checker) + 1 modified (run-tests.sh) | 3 new static checks (STC-SEC13/14/1B); 4 existing checks refactored | 6 / 6 ACs covered | None — all checks PASS today (the production code is clean wrt the deny-lists; the value is in the future-proofing) | + +## AC Test Coverage: All covered (16 / 16 ACs across the four tasks) + +### AZ-458 — SSE lifecycle + bearer rotation (9 scenarios, 3 ACs) + +| Scenario | Where | Profile | Status | +|----------|-------|---------|--------| +| FT-P-09 (annotation-status SSE opens on mount) | `tests/sse_lifecycle.test.tsx` + `e2e/tests/sse_lifecycle.e2e.ts` | fast + e2e | `it.skip` QUARANTINE (AnnotationsPage opens no SSE today) | +| FT-P-10 (annotation-status SSE closes on unmount) | same | fast + e2e | covered by FT-P-09 quarantine entry | +| FT-P-18 (live-GPS opens within 5s of select) | `tests/sse_lifecycle.test.tsx` | fast + e2e | PASS (fast); e2e gated by suite stack | +| FT-P-19 (live-GPS closes within 1s of deselect) | same | fast + e2e | PASS (fast); e2e gated | +| NFT-PERF-03 (bearer-rotation reconnect ≤5s) | `e2e/tests/sse_lifecycle.e2e.ts` | e2e | `test.fail(true)` — AC-2 drift; gated | +| NFT-PERF-04/05 (mirror FT-P-18/19) | `tests/sse_lifecycle.test.tsx` | fast | PASS | +| NFT-PERF-06 (annotation-status unsubscribes ≤1s) | `tests/sse_lifecycle.test.tsx` | fast | `it.skip` QUARANTINE | +| NFT-RES-02 (bearer rotation, both streams ≤5s) | `e2e/tests/sse_lifecycle.e2e.ts` | e2e | `test.fail` for live-GPS half; annotation-status half implicitly QUARANTINE | + +**AC summary**: +- AC-1 Open/close timing → 4 fast tests cover live-GPS half (PASS); 2 QUARANTINE for annotation-status +- AC-2 Bearer rotation → `it.fails()` drift fast + `test.fail` e2e (both gated) +- AC-3 No internal stubs → satisfied by patching `globalThis.EventSource` (not `src/api/sse.ts`) + +### AZ-467 — ProtectedRoute spinner + timeout + RBAC (7 scenarios, 4 ACs) + +| Scenario | Where | Profile | Status | +|----------|-------|---------|--------| +| FT-P-32 (spinner a11y) | `src/auth/ProtectedRoute.test.tsx` | fast | `it.fails()` — aria attrs missing today | +| FT-P-33 (10s timeout fallback) | same | fast | `it.skip` QUARANTINE (no timeout path) | +| FT-N-03 (Operator → /admin redirects to /flights) | same + `e2e/tests/protected_route.e2e.ts` | fast + e2e | `it.skip` + `test.fail` (no RBAC gate today) | +| FT-N-05 (integrator-dave → /settings redirects) | same | fast + e2e | `it.skip` + `test.fail` | +| NFT-SEC-05 (`/admin` blocks non-admins) | same | fast | covered by FT-N-03 | +| NFT-SEC-06 (`/settings` route gate) | same | fast | covered by FT-N-05 | +| NFT-RES-04 (10s loading timeout fallback) | same | fast | covered by FT-P-33 | + +**AC summary**: +- AC-1 Spinner a11y → `it.fails()` + control test asserting the gap +- AC-2 Timeout fallback → `it.skip` QUARANTINE + control test asserting the gap +- AC-3 RBAC redirects → `it.skip` QUARANTINE + control tests asserting the gap + positive control (Admin reaches /admin) +- AC-4 Both fast + e2e → fast tests (12 total; 9 new) + e2e file (3 tests; 2 gated as `test.fail`) + +### AZ-468 — Header flight dropdown a11y (3 scenarios, 3 ACs) + +| Scenario | Where | Profile | Status | +|----------|-------|---------|--------| +| FT-P-30 (closed-state a11y: aria-expanded=false) | `src/components/Header.test.tsx` | fast | `it.fails()` + control test | +| FT-P-31 (open-state a11y: aria-expanded=true + role=listbox + aria-activedescendant) | same | fast | `it.fails()` + control test | +| FT-N-09 (Escape close + handler detach) | same | fast | `it.skip` QUARANTINE + control test | + +**AC summary**: +- AC-1 Closed state → `it.fails()` drift + control +- AC-2 Open state → `it.fails()` drift + control +- AC-3 Escape detach → `it.skip` QUARANTINE (no production keydown handler today) + control proving Escape is a no-op + +### AZ-482 — Secrets/banned-libs/anti-criterion (6 scenarios, 6 ACs) + +| Scenario | Where | Profile | Status | +|----------|-------|---------|--------| +| NFT-SEC-09 (OWM key absent from source) | `scripts/run-tests.sh::STC-SEC1` (existing) | static | PASS | +| NFT-SEC-09 (OWM key absent from dist/) | `scripts/run-tests.sh::STC-SEC1B` (new) → `scripts/check-banned-deps.mjs --kind=owm_key_in_dist` | static (post-build) | PASS | +| NFT-SEC-10 (no ML libs) | `STC-N2` refactored → `check-banned-deps.mjs --kind=ml_libs` reading `tests/security/banned-deps.json` | static | PASS | +| NFT-SEC-11 (no JOSE/signature libs) | `STC-N4` refactored → `--kind=signature_libs` | static | PASS | +| NFT-SEC-12 (no service worker — source) | `STC-N3` (existing) | static | PASS | +| NFT-SEC-12 (no service worker — runtime) | e2e companion deferred to suite stack — `navigator.serviceWorker.getRegistrations() === []` would assert at runtime | e2e | not implemented in fast (gated by suite browser); STC-N3 source check is the gating signal in CI today | +| NFT-SEC-13 (no dropped legacy integrations) | `STC-SEC13` (new) → `--kind=legacy_integrations` (WhatsApp/Telegram/D-Bus/libsignal) | static | PASS | +| NFT-SEC-14 (AC-N1 anti-criterion: no concurrent-edit reconcile) | `STC-SEC14` (new) → `--kind=concurrent_edit_patterns` | static | PASS | + +**AC summary**: +- AC-1 OWM key absence (src + dist) → STC-SEC1 + STC-SEC1B +- AC-2 No ML libs → STC-N2 (now reads JSON) +- AC-3 No JOSE/signature libs → STC-N4 (now reads JSON) +- AC-4 No service worker → STC-N3 (source check); runtime e2e portion documented as gated +- AC-5 Dropped features absent → STC-SEC13 +- AC-6 AC-N1 anti-criterion → STC-SEC14 + +**Constraint compliance**: deny-list lives in `tests/security/banned-deps.json` per AZ-482 constraint; additions to the JSON are visible in code review. + +## Code Review Verdict: PASS_WITH_WARNINGS + +Self-review walked inline per `.cursor/skills/code-review/SKILL.md` phases 1–7. + +- **Phase 1 (Context)**: 4 task specs re-read; `_docs/02_document/module-layout.md` Blackbox Tests envelope respected; reuses helpers from AZ-456 (`tests/helpers/{render,auth,sse-mock}.ts`) and fixtures (`seed_users`, `seed_flights`). No new shared helpers introduced — the Header test inlines its FlightProvider wrapper (small one-off). +- **Phase 2 (Spec compliance)**: every AC across the four task specs has at least one test (running, `it.fails()`, or `it.skip` with QUARANTINE reason). Drift handling uniform with batch 2: `it.fails()` for documented production drift (attribute missing where the element exists), `it.skip` with QUARANTINE for behavior wholly absent (no Escape handler, no timeout logic, no RBAC check, no annotation-status SSE). +- **Phase 3 (Code quality)**: `check-banned-deps.mjs` has one function per concern (`checkPackageJson`, `checkSourceTree`, `checkDistTree`); test helpers (`withUser`, `wireAuthAndFlights`, `HeaderHarness`, `SseConsumer`, `SseConsumerNoTokenDep`) each carry one responsibility and are named for what they do; no bare catches; arrange/act/assert structure preserved across new tests. +- **Phase 4 (Security)**: no new secrets in test fixtures (reuses AZ-457's `test-bearer-default`); the AZ-482 changes strengthen security posture (more deny-lists enforced; checker is a single source of truth); no `eval` / `shell=True`; the `check-banned-deps.mjs` walks files and runs regex/literal checks only — no execution of test inputs. +- **Phase 5 (Performance)**: fast suite ~4.4 s wall-clock for 57 + 9-skipped tests (was 3 s for 38 + 4 skipped in batch 2 — +1.4 s for 19 new tests; well under 5 min budget). Static profile ~12 s for 22 checks (was 19 in batch 2; +3 from batch 3; STC-T1 + STC-B1 dominate at ~8 s combined and are unchanged). FT-P-32 takes ~1 s due to React Testing Library's default 1 s `findByRole` timeout while the `it.fails()` assertion waits — acceptable given the test count. +- **Phase 6 (Cross-task consistency)**: the four tasks touch **disjoint** subsystems (SSE vs ProtectedRoute vs Header vs deny-list checker). Shared surface = `tests/helpers/`, `tests/fixtures/`, `tests/msw/` — all consumed read-only. No contract collisions; no duplicate symbols. The `withUser()` helper in `ProtectedRoute.test.tsx` is local to that file by design (the role/permission seed-binding logic isn't reused yet — promotable to `tests/helpers/auth.ts` in a future batch if a third task needs it). +- **Phase 7 (Architecture compliance)**: + - Test files import only public seams: + - `tests/sse_lifecycle.test.tsx`: `createSSE` (public export of `src/api/sse.ts`); `setToken` (testability accessor on `src/api/client.ts`, landed by AZ-454). + - `src/auth/ProtectedRoute.test.tsx`: `ProtectedRoute` default export; React-router primitives. + - `src/components/Header.test.tsx`: `Header` default export; `FlightProvider` (public symbol on `FlightContext.tsx`). + - No imports of `*.internal.*` files, no reaching into other components' private files. + - E2E tests don't import any production modules — Playwright primitives only (consistent with AZ-457's e2e pattern). + - No new cyclic module dependencies introduced (test files remain leaves in the import graph). + +### Findings + +1. **Low / Maintainability / Drift** — AZ-468 FT-P-30/31 use `it.fails()` to track the three missing aria attributes on `Header`'s flight-dropdown trigger (`aria-expanded`, `role=listbox`, `aria-activedescendant`); FT-N-09 is `it.skip` because the Header has no keydown handler at all. **Recommendation**: file a follow-up production task (`feat(header): flight-dropdown a11y + keyboard-Escape`) to flip these three drifts to passing. + +2. **Low / Maintainability / Drift** — AZ-467 FT-P-32 uses `it.fails()` for missing spinner role + aria attrs; FT-P-33 / FT-N-03 / FT-N-05 are `it.skip` QUARANTINE because `src/auth/ProtectedRoute.tsx` has no timeout path and no RBAC gate today. **Recommendation**: three follow-up production tasks — (a) spinner a11y attributes (`role="status"`, `aria-live="polite"`, localized label); (b) 10 s timeout fallback with retry affordance; (c) `requirePermission` prop + opt-ins on `/admin` and `/settings` routes. The last task is the biggest — the suite already enforces RBAC server-side, so this is defence-in-depth. + +3. **Low / Maintainability / Drift** — AZ-458 AC-2 bearer rotation uses `it.fails()` because `src/features/flights/FlightsPage.tsx:65-68` `useEffect` deps are `[selectedFlight, mode]` only (no token). The same drift applies to any future SSE consumer that omits the token dep. **Recommendation**: lift the bearer reactivity into a `useBearer()` hook (or take it from `useAuth()`) and include it in every SSE consumer's `useEffect` deps. Single follow-up production task. + +4. **Low / Architecture / Quarantine** — AZ-458 FT-P-09/10/NFT-PERF-06 (annotation-status SSE) are `it.skip` QUARANTINE because `src/features/annotations/AnnotationsPage.tsx` does not call `createSSE` today. **Recommendation**: a Phase B feature task ("annotation-status live updates") to add the subscription. The test shape is already documented in the QUARANTINE comments. + +5. **Low / Architecture / Interpretation (carried over from batches 1 & 2)** — Test helpers (`tests/helpers/{render,auth,sse-mock}.ts`) and test-only consumer harnesses (`SseConsumer`, `SseConsumerNoTokenDep` in `tests/sse_lifecycle.test.tsx`) import production accessors. Reaffirmed per the batch-1 / batch-2 rule: "Black-box discipline applies to test bodies, not to test setup helpers / composition-root wrappers / consumer-pattern mirrors". + +## Auto-Fix Attempts: 0 +## Stuck Agents: None + +## Files Changed (8) + +### Created — `tests/` (2) +``` +tests/security/banned-deps.json # AZ-482 deny-list source of truth (7 sections) +tests/sse_lifecycle.test.tsx # AZ-458 fast — 9 tests (1 skipped) +``` + +### Created — `src/` (1) +``` +src/components/Header.test.tsx # AZ-468 fast — 6 tests (1 skipped) +``` + +### Created — `e2e/tests/` (2) +``` +e2e/tests/sse_lifecycle.e2e.ts # AZ-458 e2e — 4 scenarios (1 skipped, 1 expected-fail) +e2e/tests/protected_route.e2e.ts # AZ-467 e2e — 3 scenarios (2 expected-fail, 1 pass) +``` + +### Created — `scripts/` (1) +``` +scripts/check-banned-deps.mjs # AZ-482 unified checker (kinds: ml_libs, signature_libs, persistence_libs, ws_graphql_ssr_libs, legacy_integrations, concurrent_edit_patterns, owm_key_in_dist) +``` + +### Modified (3) +``` +scripts/run-tests.sh # Refactor STC-N2/N4/S13/S6 to delegate to check-banned-deps.mjs; add STC-SEC13, STC-SEC14, STC-SEC1B +src/auth/ProtectedRoute.test.tsx # Extend batch-2 file with AZ-467 describe block (9 new tests; 6 new sentinels/helpers) +_docs/_autodev_state.md # Batch 3 sub_step pointer + notes +``` + +## Verification Run (host) + +``` +$ bun run test:fast + ✓ mission-planner/src/test/jsonImport.test.ts (6 tests) 6ms + ✓ tests/wire_contract.test.ts (11 tests | 2 skipped) 19ms + ✓ tests/infrastructure.test.ts (5 tests) 37ms + ✓ tests/sse_lifecycle.test.tsx (9 tests | 1 skipped) 46ms + ✓ src/api/client.test.ts (9 tests) 74ms + ✓ tests/i18n.test.tsx (4 tests | 2 skipped) 4ms + ✓ src/auth/AuthContext.test.tsx (4 tests) 234ms + ✓ src/components/Header.test.tsx (6 tests | 1 skipped) 236ms + ✓ src/auth/ProtectedRoute.test.tsx (12 tests | 3 skipped) 1176ms + Test Files 9 passed (9) + Tests 57 passed | 9 skipped (66) + +$ ./scripts/run-tests.sh --static-only +[run-tests] static profile PASSED — 22/22 checks (was 19 in batch 2; +3 from batch 3) + +$ ./scripts/run-tests.sh +[run-tests] static profile : ran (PASS) +[run-tests] fast profile : ran (PASS) +[run-tests] e2e profile : skipped (host) +[run-tests] exit code : 0 +``` + +E2E profile not exercised in this batch — same Risk 4 as batches 1 and 2 (requires `docker compose -f e2e/docker-compose.suite-e2e.yml up -d` plus parent-suite `:test` images). The e2e companion files (`e2e/tests/sse_lifecycle.e2e.ts`, `e2e/tests/protected_route.e2e.ts`) will run on the suite stack and exercise the real-wire portions of FT-P-18/19 + NFT-PERF-03 + NFT-RES-02 (AZ-458) and FT-N-03/05 (AZ-467). + +## Next Batch + +Remaining: 18 test-implementation tasks in `_docs/02_tasks/todo/`: +- AZ-460 (annotation save URL + payload, 2pts) +- AZ-461 (detection endpoints sync/async/long-video, 2pts) +- AZ-462 (overlay window membership, 2pts) +- AZ-463 (flight selection persistence + memory soaks, 3pts) +- AZ-464 (bulk-validate URL + body + UI sync, 2pts) +- AZ-466 (destructive UX + ConfirmDialog + no-alert, 4pts) +- AZ-469 (browser support + responsive variants, 2pts) +- AZ-470 (panel-width debounced PUT + rehydration, 2pts) +- AZ-471 (CanvasEditor draw/resize/multi-select/zoom/pan, 5pts) +- AZ-472 (DetectionClasses load + hotkeys + click + fallback, 3pts) +- AZ-473 (PhotoMode switch + auto-select + yoloId wire, 2pts) — soft dep on AZ-472 +- AZ-474 (Tile-split + YOLO parser + auto-zoom + indicator, 3pts) +- AZ-475 (Numeric form hygiene, 2pts) +- AZ-476 (Upload 501 MB → 413 → user-visible error, 2pts) +- AZ-477 (Settings save 500/network resilience, 3pts) +- AZ-478 (Network offline + SSE disconnect + tainted-canvas, 3pts) +- AZ-479 (Bundle ≤2 MB + mission-planner excluded + FCP + soak, 3pts) +- AZ-480 (Prod image nginx:alpine + 500M + 9 routes + edge RAM, 3pts) + +All carry **Component**: `Blackbox Tests` and **Dependencies**: `AZ-456` (✓ done). Soft cross-dep: AZ-473 needs AZ-472's DetectionClasses fixtures. + +Suggested next batch (4 tasks, ~10 pts, dependency-disjoint at the file level): AZ-466 (destructive UX, 4pts — lands the `data-destructive` marker + `` wrapper used by other tasks); AZ-475 (numeric form hygiene, 2pts); AZ-462 (overlay window membership, 2pts); AZ-460 (annotation save URL + payload, 2pts). + +Recommendation: continue in a new conversation. Batch 3 added 5 new files + 3 new static checks + 19 new fast tests; the next batch will load distinct task specs and ConfirmDialog / overlay / annotations / numeric-form subsystems. diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index 6f1f4be..2b4afed 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -8,7 +8,7 @@ status: in_progress sub_step: phase: 14 name: batch-loop - detail: "batch 3 next: AZ-458 + AZ-467 + AZ-468 + 1 small parallel" + detail: "batch 4 next: 18 tasks remaining (AZ-460/461/462/463/464/466/469/470/471/472/473/474/475/476/477/478/479/480)" retry_count: 0 cycle: 1 tracker: jira @@ -35,3 +35,16 @@ step_3_ac_gap_handling: rollback-to-6c (option A) pass. AZ-456 → In Testing; report at `_docs/03_implementation/batch_01_report.md`. Next batch picks up AZ-457..AZ-482 (26 tasks remaining). +- 2026-05-11 batch 2 (AZ-457/459/465/481) shipped: 38 fast tests pass + + 4 skipped; 19 static checks pass. Reports at + `_docs/03_implementation/batch_02_report.md`. 22 tasks remain. +- 2026-05-11 batch 3 (AZ-458/467/468/482) shipped: 57 fast tests pass + + 9 skipped (drifts/quarantines); 22 static checks pass. Reports at + `_docs/03_implementation/batch_03_report.md`. 18 tasks remain. + Drifts documented (production follow-ups for Phase B): Header + flight-dropdown a11y (FT-P-30/31/N-09); ProtectedRoute spinner a11y + + 10s timeout + route RBAC (FT-P-32/33, FT-N-03/05); SSE bearer- + rotation reconnect (AC-2 / NFT-PERF-03); AnnotationsPage annotation- + status SSE (FT-P-09/10/NFT-PERF-06). New deny-list source + `tests/security/banned-deps.json` + checker + `scripts/check-banned-deps.mjs` introduced (AZ-482 constraint). diff --git a/e2e/tests/protected_route.e2e.ts b/e2e/tests/protected_route.e2e.ts new file mode 100644 index 0000000..b9838bc --- /dev/null +++ b/e2e/tests/protected_route.e2e.ts @@ -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$/) + }) +}) diff --git a/e2e/tests/sse_lifecycle.e2e.ts b/e2e/tests/sse_lifecycle.e2e.ts new file mode 100644 index 0000000..811858e --- /dev/null +++ b/e2e/tests/sse_lifecycle.e2e.ts @@ -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 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)) + 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((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 + }) +}) diff --git a/scripts/check-banned-deps.mjs b/scripts/check-banned-deps.mjs new file mode 100755 index 0000000..1de7efe --- /dev/null +++ b/scripts/check-banned-deps.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +// AZ-482 — static deny-list enforcement driven by tests/security/banned-deps.json. +// +// One canonical implementation that the run-tests.sh static profile delegates to, +// so adding or removing a banned dependency / pattern is a one-file change visible +// in code review (per AZ-482 constraint). +// +// Usage: +// node scripts/check-banned-deps.mjs --kind= [--root=] +// +// Exit code 0 on PASS (no hits); non-zero on FAIL (writes the hit list to stderr). + +import { readFileSync, statSync, readdirSync } from 'node:fs' +import { join, dirname, resolve, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +function parseArgs(argv) { + const out = { kind: null, root: resolve(__dirname, '..') } + for (const a of argv.slice(2)) { + if (a.startsWith('--kind=')) out.kind = a.slice('--kind='.length) + else if (a.startsWith('--root=')) out.root = resolve(a.slice('--root='.length)) + else if (a === '-h' || a === '--help') { + // eslint-disable-next-line no-console + console.error('Usage: check-banned-deps.mjs --kind= [--root=]') + process.exit(0) + } + } + if (!out.kind) { + process.stderr.write('check-banned-deps: --kind is required\n') + process.exit(2) + } + return out +} + +function loadDenylist(root) { + const path = join(root, 'tests', 'security', 'banned-deps.json') + return JSON.parse(readFileSync(path, 'utf8')) +} + +function loadPackageJson(root) { + return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) +} + +function namesFromPackageJson(pkg) { + return Object.keys({ ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }) +} + +function checkPackageJson(section, root) { + const pkg = loadPackageJson(root) + const names = namesFromPackageJson(pkg) + const regexes = section.patterns.map((p) => new RegExp(p, 'i')) + const hits = [] + for (const name of names) { + for (const re of regexes) { + if (re.test(name)) { + hits.push(`${name} matched /${re.source}/i`) + break + } + } + } + return hits +} + +// File walker — yields paths under `dir` that match the included extensions. +// Skips dist/, node_modules/, test-output/, and any `*.test.{ts,tsx}` / +// `*.spec.{ts,tsx}` files (production source only, mirrors run-tests.sh src_grep). +const IGNORED_DIRS = new Set([ + 'node_modules', 'dist', 'build', 'test-output', 'test-results', + 'coverage', '.git', '.cache', 'playwright-report', 'blob-report', +]) +const SOURCE_EXT = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'] +const TEST_NAME_RE = /\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs)$/i + +function* walkSourceFiles(rootDir) { + let entries + try { + entries = readdirSync(rootDir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = join(rootDir, entry.name) + if (entry.isDirectory()) { + if (IGNORED_DIRS.has(entry.name)) continue + yield* walkSourceFiles(full) + } else if (entry.isFile()) { + const ext = '.' + entry.name.split('.').pop() + if (!SOURCE_EXT.includes(ext)) continue + if (TEST_NAME_RE.test(entry.name)) continue + yield full + } + } +} + +function checkSourceTree(section, root, subdirs) { + const regexes = section.patterns.map((p) => new RegExp(p, 'i')) + const hits = [] + for (const sub of subdirs) { + const full = join(root, sub) + try { statSync(full) } catch { continue } + for (const file of walkSourceFiles(full)) { + let text + try { text = readFileSync(file, 'utf8') } catch { continue } + const lines = text.split('\n') + lines.forEach((line, idx) => { + for (const re of regexes) { + if (re.test(line)) { + hits.push(`${relative(root, file)}:${idx + 1}: ${line.trim().slice(0, 200)} (matched /${re.source}/i)`) + break + } + } + }) + } + } + return hits +} + +function* walkAnyFiles(rootDir) { + let entries + try { + entries = readdirSync(rootDir, { withFileTypes: true }) + } catch { + return + } + for (const entry of entries) { + const full = join(rootDir, entry.name) + if (entry.isDirectory()) { + if (IGNORED_DIRS.has(entry.name)) continue + yield* walkAnyFiles(full) + } else if (entry.isFile()) { + yield full + } + } +} + +function checkDistTree(section, root) { + const dist = join(root, 'dist') + try { statSync(dist) } catch { + process.stderr.write('dist/ missing — run `bun run build` before this check\n') + process.exit(1) + } + const hits = [] + for (const file of walkAnyFiles(dist)) { + let text + try { text = readFileSync(file, 'utf8') } catch { continue } + for (const literal of section.patterns) { + if (text.includes(literal)) { + hits.push(`${relative(root, file)} contains banned literal: ${literal}`) + } + } + } + return hits +} + +function main() { + const { kind, root } = parseArgs(process.argv) + const denylist = loadDenylist(root) + const section = denylist[kind] + if (!section) { + process.stderr.write(`unknown --kind=${kind}; available: ${Object.keys(denylist).filter((k) => !k.startsWith('$')).join(', ')}\n`) + process.exit(2) + } + + let hits = [] + if (kind === 'owm_key_in_dist') { + hits = checkDistTree(section, root) + } else if (kind === 'legacy_integrations' || kind === 'concurrent_edit_patterns') { + hits = checkSourceTree(section, root, ['src', 'mission-planner']) + } else { + hits = checkPackageJson(section, root) + } + + if (hits.length) { + process.stderr.write(`banned (${kind} / ${section.ac}):\n`) + for (const h of hits) process.stderr.write(` ${h}\n`) + process.exit(1) + } + process.exit(0) +} + +main() diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 6790de6..747a4a4 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -166,44 +166,40 @@ if [ "$RUN_STATIC" = "true" ]; then ' } + # AZ-482 — package.json deny-lists routed through the shared + # banned-deps.json source-of-truth. Each kind maps 1:1 to a JSON section. static_check_no_ml_libs() { - node -e ' - const p = require("./package.json"); - const all = Object.assign({}, p.dependencies || {}, p.devDependencies || {}); - const re = /(onnxruntime|tensorflow|tflite|coreml|tfjs|@tensorflow\/|@huggingface\/|transformers\.js)/i; - const hits = Object.keys(all).filter(n => re.test(n)); - if (hits.length) { console.error("banned ML deps:", hits.join(", ")); process.exit(1); } - ' + node "$PROJECT_ROOT/scripts/check-banned-deps.mjs" --kind=ml_libs } static_check_no_signature_libs() { - node -e ' - const p = require("./package.json"); - const all = Object.assign({}, p.dependencies || {}, p.devDependencies || {}); - const re = /(jsrsasign|tweetnacl|@noble\/|^jose$)/i; - const hits = Object.keys(all).filter(n => re.test(n)); - if (hits.length) { console.error("signature libs:", hits.join(", ")); process.exit(1); } - ' + node "$PROJECT_ROOT/scripts/check-banned-deps.mjs" --kind=signature_libs } static_check_no_persistence_libs() { - node -e ' - const p = require("./package.json"); - const all = Object.assign({}, p.dependencies || {}, p.devDependencies || {}); - const re = /^(localforage|idb|dexie)$/i; - const hits = Object.keys(all).filter(n => re.test(n)); - if (hits.length) { console.error("persistence libs:", hits.join(", ")); process.exit(1); } - ' + node "$PROJECT_ROOT/scripts/check-banned-deps.mjs" --kind=persistence_libs } static_check_no_ws_graphql() { - node -e ' - const p = require("./package.json"); - const all = Object.assign({}, p.dependencies || {}, p.devDependencies || {}); - const re = /^(ws|socket\.io|graphql|apollo|@apollo\/|grpc-web|react-dom\/server)$/i; - const hits = Object.keys(all).filter(n => re.test(n)); - if (hits.length) { console.error("banned deps:", hits.join(", ")); process.exit(1); } - ' + node "$PROJECT_ROOT/scripts/check-banned-deps.mjs" --kind=ws_graphql_ssr_libs + } + + # AZ-482 — NFT-SEC-13 dropped legacy integrations and NFT-SEC-14 AC-N1 + # anti-criterion. Source-tree scans gated on production code only (the + # banned-deps script applies the same `*.test.{ts,tsx}` exclusion src_grep + # uses below; tests are allowed to mention these tokens as documentation). + static_check_no_legacy_integrations() { + node "$PROJECT_ROOT/scripts/check-banned-deps.mjs" --kind=legacy_integrations + } + + static_check_no_concurrent_edit() { + node "$PROJECT_ROOT/scripts/check-banned-deps.mjs" --kind=concurrent_edit_patterns + } + + # AZ-482 — NFT-SEC-09 AC-1 dist/ portion. The src/ counterpart is STC-SEC1 + # below; this check runs AFTER `bun run build` (STC-B1) so dist/ exists. + static_check_no_owm_key_in_dist() { + node "$PROJECT_ROOT/scripts/check-banned-deps.mjs" --kind=owm_key_in_dist } # Source-tree text search. Prefer ripgrep when available (much faster on @@ -378,9 +374,12 @@ if [ "$RUN_STATIC" = "true" ]; then run_static "STC-FP22" "i18n key parity en vs ua" "AC-12" "45" static_check_i18n_parity run_static "STC-FP23" "no raw user strings outside t()" "AC-12" "46" static_check_i18n_coverage run_static "STC-CI11" "CI image tag + OCI labels (woodpecker)" "AC-32" "70" static_check_ci_image_labels + run_static "STC-SEC13" "no legacy integrations in src/" "SEC-13" "n/a" static_check_no_legacy_integrations + run_static "STC-SEC14" "no concurrent-edit reconcile (AC-N1)" "SEC-14" "n/a" static_check_no_concurrent_edit run_static "STC-T1" "tsc --noEmit (test config)" "AC-6" "n/a" static_check_typecheck run_static "STC-B1" "vite build succeeds" "AC-6" "n/a" static_check_vite_build run_static "STC-S5" "mission-planner not in dist/" "AC-31" "n/a" static_check_dist_no_mission_planner + run_static "STC-SEC1B" "no literal OWM key in dist/" "SEC-09" "63" static_check_no_owm_key_in_dist if [ "$STATIC_FAIL" = "1" ]; then echo "[run-tests] static profile FAILED — see $STATIC_REPORT" diff --git a/src/auth/ProtectedRoute.test.tsx b/src/auth/ProtectedRoute.test.tsx index e9caf09..e3ff8f0 100644 --- a/src/auth/ProtectedRoute.test.tsx +++ b/src/auth/ProtectedRoute.test.tsx @@ -1,10 +1,12 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { http, HttpResponse } from 'msw' import { Routes, Route } from 'react-router-dom' import { server } from '../../tests/msw/server' +import { jsonResponse } from '../../tests/msw/helpers' import { renderWithProviders, screen, waitFor } from '../../tests/helpers/render' import ProtectedRoute from './ProtectedRoute' import { clearBearer } from '../../tests/helpers/auth' +import { opAlice, opBob, adminCarol, integratorDave, seedPermissions } from '../../tests/fixtures/seed_users' // AZ-457 — behavior at the React boundary. // FT-N-04 / row 09 — unauthenticated /admin → redirect to /login @@ -12,10 +14,23 @@ import { clearBearer } from '../../tests/helpers/auth' // (apiClient half lives in src/api/client.test.ts; this // file asserts the React-router-level redirect path) // +// AZ-467 — Spinner a11y, 10s timeout fallback, and RBAC route gating. +// FT-P-32 / NFT-SEC-05 — spinner role=status + aria-live=polite + label +// FT-P-33 / NFT-RES-04 — 10s timeout fallback (Vitest fake-timers) +// FT-N-03 / NFT-SEC-05 — Operator → /admin redirects to /flights +// FT-N-05 / NFT-SEC-06 — integrator-dave → /settings redirects (no SETTINGS perm) +// +// Production status (today): renders a plain spinner div +// without any aria-* attributes, has no timeout fallback, and does NOT check +// route-level permissions (it only gates on `user != null`). Those four ACs +// therefore fail today; the spinner a11y test uses `it.fails()` to track the +// drift, and the timeout / RBAC tests are `it.skip` (QUARANTINE) because the +// behavior is entirely absent. +// // Black-box discipline: we import only the public ProtectedRoute component // and react-router primitives; no internal state of is read. -// Assertions are observable on the rendered DOM — the /login route renders -// a sentinel that lets us confirm the redirect happened. +// Assertions are observable on the rendered DOM — sentinel components let us +// confirm which route the router settled on. function LoginSentinel() { return
login-route
@@ -25,6 +40,22 @@ function AdminSentinel() { return
admin-route
} +function FlightsSentinel() { + return
flights-route
+} + +function SettingsSentinel() { + return
settings-route
+} + +function withUser(user: typeof opAlice) { + server.use( + http.get('/api/admin/auth/refresh', () => + jsonResponse({ token: 'test-bearer-default', user: { ...user, permissions: seedPermissions[user.id] ?? [] } }), + ), + ) +} + describe('AZ-457 / src/auth/ProtectedRoute.tsx — redirect to /login', () => { afterEach(() => { clearBearer() @@ -115,7 +146,7 @@ describe('AZ-457 / src/auth/ProtectedRoute.tsx — redirect to /login', () => { path="/flights" element={ -
flights-route
+
} /> @@ -130,3 +161,239 @@ describe('AZ-457 / src/auth/ProtectedRoute.tsx — redirect to /login', () => { }) }) }) + +describe('AZ-467 / src/auth/ProtectedRoute.tsx — spinner, timeout, RBAC', () => { + beforeEach(() => { + // Each test wires its own auth response; nothing global needed. + }) + afterEach(() => { + clearBearer() + vi.useRealTimers() + }) + + describe('FT-P-32 / NFT-SEC-05 — spinner a11y while bootstrap is loading', () => { + it.fails( + 'spinner element carries role="status" + aria-live="polite" + an accessible name (drift: aria attributes currently missing)', + async () => { + // Arrange — keep bootstrap pending forever so the spinner stays mounted. + server.use( + http.get('/api/admin/auth/refresh', async () => { + await new Promise(() => { /* never resolves */ }) + return new HttpResponse(null, { status: 200 }) + }), + ) + + // Act + renderWithProviders( + + + +
+ } + /> + , + { initialEntries: ['/flights'] }, + ) + + // Assert AC-1: the loading element advertises its status role and a + // localized accessible name (i18n key TBD; for the drift assertion we + // accept any non-empty accessible name). + const status = await screen.findByRole('status') + expect(status).toHaveAttribute('aria-live', 'polite') + const name = status.getAttribute('aria-label') ?? status.textContent ?? '' + expect(name.trim().length).toBeGreaterThan(0) + }, + ) + + it('control — spinner renders today as a bare animate-spin div with no aria role (drift seen)', async () => { + server.use( + http.get('/api/admin/auth/refresh', async () => { + await new Promise(() => { /* never resolves */ }) + return new HttpResponse(null, { status: 200 }) + }), + ) + const { container } = renderWithProviders( + +
} + /> + , + { initialEntries: ['/flights'] }, + ) + + // Assert AC-1 evidence: the spinner exists, but is NOT a status role today. + const spinner = container.querySelector('.animate-spin') + expect(spinner).not.toBeNull() + expect(screen.queryByRole('status')).toBeNull() + }) + }) + + describe('FT-P-33 / NFT-RES-04 — 10s loading timeout fallback', () => { + it.skip( + 'QUARANTINE (no production behavior): after 10s the spinner is replaced with a fallback that offers a retry affordance', + async () => { + // When ProtectedRoute gains a timeout (`useEffect` + setTimeout, or a + // useTimeout hook) and a fallback render path, this test: + // 1. Mocks bootstrap to never resolve. + // 2. Renders the ProtectedRoute tree. + // 3. Advances Vitest fake-timers by 10_000 ms. + // 4. Asserts the fallback element is present with a retry affordance + // (a button / link whose accessible name matches /retry|reload/i). + // The test is skipped today because no timeout / fallback path exists + // in src/auth/ProtectedRoute.tsx — asserting absent UI would produce + // noise. Once the production path lands the assertion shape is below. + vi.useFakeTimers() + server.use( + http.get('/api/admin/auth/refresh', async () => { + await new Promise(() => { /* never */ }) + return new HttpResponse(null, { status: 200 }) + }), + ) + renderWithProviders( + + } + /> + , + { initialEntries: ['/flights'] }, + ) + vi.advanceTimersByTime(10_000) + const retry = await screen.findByRole('button', { name: /retry|reload/i }) + expect(retry).toBeInTheDocument() + }, + ) + + it('control — bootstrap stuck at >10s today shows ONLY the spinner; no fallback (drift seen)', async () => { + vi.useFakeTimers() + server.use( + http.get('/api/admin/auth/refresh', async () => { + await new Promise(() => { /* never */ }) + return new HttpResponse(null, { status: 200 }) + }), + ) + const { container } = renderWithProviders( + + } + /> + , + { initialEntries: ['/flights'] }, + ) + vi.advanceTimersByTime(10_000) + + // QUARANTINE evidence: still showing the spinner; no retry surface. + expect(container.querySelector('.animate-spin')).not.toBeNull() + expect(screen.queryByRole('button', { name: /retry|reload/i })).toBeNull() + }) + }) + + describe('FT-N-03 / NFT-SEC-05 — Operator → /admin redirects to /flights', () => { + it.skip( + 'QUARANTINE (no production behavior): an authenticated Operator hitting /admin is redirected to /flights', + async () => { + // When ProtectedRoute gains a `requirePermission` prop (or wrapper) and + // the /admin route opts in, this test: + // 1. Boots auth as op_alice (Operator) with seedPermissions['user-alice'] + // (which intentionally lacks 'ADMIN_WRITE'). + // 2. Navigates to /admin. + // 3. Asserts the router settled on /flights, not /admin or /login. + withUser(opAlice) + renderWithProviders( + + } + /> + } /> + } /> + , + { initialEntries: ['/admin'] }, + ) + await waitFor(() => expect(screen.getByTestId('flights-route')).toBeInTheDocument()) + expect(screen.queryByTestId('admin-route')).toBeNull() + }, + ) + + it('control — an authenticated Operator reaches /admin today (no RBAC gate; drift seen)', async () => { + withUser(opBob) // op_bob lacks 'ADMIN_WRITE' and 'SETTINGS' + renderWithProviders( + + } + /> + } /> + } /> + , + { initialEntries: ['/admin'] }, + ) + + // Today the admin sentinel renders — ProtectedRoute does not check + // permissions, only `user != null`. + await waitFor(() => expect(screen.getByTestId('admin-route')).toBeInTheDocument()) + expect(screen.queryByTestId('flights-route')).toBeNull() + }) + + it('Admin reaches /admin normally (positive control — same path, role permitted)', async () => { + withUser(adminCarol) + renderWithProviders( + + } + /> + } /> + , + { initialEntries: ['/admin'] }, + ) + await waitFor(() => expect(screen.getByTestId('admin-route')).toBeInTheDocument()) + }) + }) + + describe('FT-N-05 / NFT-SEC-06 — integrator-dave → /settings redirects', () => { + it.skip( + 'QUARANTINE (no production behavior): an authenticated user without SETTINGS is redirected away from /settings', + async () => { + // When ProtectedRoute gains permission gating, this test: + // 1. Boots auth as integrator_dave (whose seedPermissions lacks SETTINGS). + // 2. Navigates to /settings. + // 3. Asserts the router settled on /flights (or wherever policy says). + withUser(integratorDave) + renderWithProviders( + + } + /> + } /> + } /> + , + { initialEntries: ['/settings'] }, + ) + await waitFor(() => expect(screen.getByTestId('flights-route')).toBeInTheDocument()) + expect(screen.queryByTestId('settings-route')).toBeNull() + }, + ) + + it('control — integrator-dave reaches /settings today (no RBAC gate; drift seen)', async () => { + withUser(integratorDave) + renderWithProviders( + + } + /> + } /> + , + { initialEntries: ['/settings'] }, + ) + await waitFor(() => expect(screen.getByTestId('settings-route')).toBeInTheDocument()) + }) + }) +}) diff --git a/src/components/Header.test.tsx b/src/components/Header.test.tsx new file mode 100644 index 0000000..4ed072c --- /dev/null +++ b/src/components/Header.test.tsx @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { http } from 'msw' +import { Route, Routes } from 'react-router-dom' +import userEvent from '@testing-library/user-event' +import type { ReactNode } from 'react' +import { server } from '../../tests/msw/server' +import { jsonResponse, paginate } from '../../tests/msw/helpers' +import { renderWithProviders, screen, waitFor } from '../../tests/helpers/render' +import { seedBearer, clearBearer } from '../../tests/helpers/auth' +import { seedFlights } from '../../tests/fixtures/seed_flights' +import { opAlice, seedPermissions } from '../../tests/fixtures/seed_users' +import { FlightProvider } from './FlightContext' +import Header from './Header' + +// AZ-468 — Header flight-dropdown a11y + Escape handler. +// FT-P-30 closed-state a11y — aria-expanded=false, accessible trigger name +// FT-P-31 open-state a11y — aria-expanded=true, role=listbox/menu, +// aria-activedescendant points to a real id +// FT-N-09 Escape close + detach — Escape closes the dropdown and the +// document-level Escape handler is removed +// (no leakage into other components). +// +// Production status (today): src/components/Header.tsx renders a plain +//