[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:
Oleksandr Bezdieniezhnykh
2026-05-11 03:46:18 +03:00
parent 2e04a01ac9
commit 2051088706
14 changed files with 1466 additions and 33 deletions
+271 -4
View File
@@ -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 — <ProtectedRoute> 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): <ProtectedRoute> 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 <AuthContext> 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 <div data-testid="login-route">login-route</div>
@@ -25,6 +40,22 @@ function AdminSentinel() {
return <div data-testid="admin-route">admin-route</div>
}
function FlightsSentinel() {
return <div data-testid="flights-route">flights-route</div>
}
function SettingsSentinel() {
return <div data-testid="settings-route">settings-route</div>
}
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={
<ProtectedRoute>
<div data-testid="flights-route">flights-route</div>
<FlightsSentinel />
</ProtectedRoute>
}
/>
@@ -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<void>(() => { /* never resolves */ })
return new HttpResponse(null, { status: 200 })
}),
)
// Act
renderWithProviders(
<Routes>
<Route
path="/flights"
element={
<ProtectedRoute>
<FlightsSentinel />
</ProtectedRoute>
}
/>
</Routes>,
{ 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<void>(() => { /* never resolves */ })
return new HttpResponse(null, { status: 200 })
}),
)
const { container } = renderWithProviders(
<Routes>
<Route
path="/flights"
element={<ProtectedRoute><FlightsSentinel /></ProtectedRoute>}
/>
</Routes>,
{ 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<void>(() => { /* never */ })
return new HttpResponse(null, { status: 200 })
}),
)
renderWithProviders(
<Routes>
<Route
path="/flights"
element={<ProtectedRoute><FlightsSentinel /></ProtectedRoute>}
/>
</Routes>,
{ 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<void>(() => { /* never */ })
return new HttpResponse(null, { status: 200 })
}),
)
const { container } = renderWithProviders(
<Routes>
<Route
path="/flights"
element={<ProtectedRoute><FlightsSentinel /></ProtectedRoute>}
/>
</Routes>,
{ 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(
<Routes>
<Route
path="/admin"
element={<ProtectedRoute><AdminSentinel /></ProtectedRoute>}
/>
<Route path="/flights" element={<FlightsSentinel />} />
<Route path="/login" element={<LoginSentinel />} />
</Routes>,
{ 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(
<Routes>
<Route
path="/admin"
element={<ProtectedRoute><AdminSentinel /></ProtectedRoute>}
/>
<Route path="/flights" element={<FlightsSentinel />} />
<Route path="/login" element={<LoginSentinel />} />
</Routes>,
{ 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(
<Routes>
<Route
path="/admin"
element={<ProtectedRoute><AdminSentinel /></ProtectedRoute>}
/>
<Route path="/flights" element={<FlightsSentinel />} />
</Routes>,
{ 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(
<Routes>
<Route
path="/settings"
element={<ProtectedRoute><SettingsSentinel /></ProtectedRoute>}
/>
<Route path="/flights" element={<FlightsSentinel />} />
<Route path="/login" element={<LoginSentinel />} />
</Routes>,
{ 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(
<Routes>
<Route
path="/settings"
element={<ProtectedRoute><SettingsSentinel /></ProtectedRoute>}
/>
<Route path="/flights" element={<FlightsSentinel />} />
</Routes>,
{ initialEntries: ['/settings'] },
)
await waitFor(() => expect(screen.getByTestId('settings-route')).toBeInTheDocument())
})
})
})
+206
View File
@@ -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
// <button> trigger and a <div> menu without any aria-* attributes, and the
// dropdown has NO Escape key handler (only a `mousedown` listener for
// click-outside). All three task ACs therefore fail today; FT-P-30/31 are
// captured as documented DRIFT via `it.fails()` (flips green when production
// gains the attributes); FT-N-09 is QUARANTINEd via `it.skip` because the
// behavior is wholly absent — there is no addEventListener('keydown', ...) in
// the dropdown to assert against.
function HeaderHarness({ children }: { children?: ReactNode }) {
return <FlightProvider>{children}<Header /></FlightProvider>
}
function mountHeader() {
return renderWithProviders(
<Routes>
<Route
path="/flights"
element={<HeaderHarness><div data-testid="content" /></HeaderHarness>}
/>
<Route path="/login" element={<div data-testid="login-route" />} />
</Routes>,
{ initialEntries: ['/flights'] },
)
}
function wireAuthAndFlights() {
server.use(
http.get('/api/admin/auth/refresh', () =>
jsonResponse({ token: 'test-bearer-default', user: { ...opAlice, permissions: seedPermissions[opAlice.id] ?? [] } }),
),
http.get('/api/flights', ({ request }) => {
const url = new URL(request.url)
const pageSize = Number(url.searchParams.get('pageSize') ?? '50')
return jsonResponse(paginate(seedFlights, 1, pageSize))
}),
http.get('/api/annotations/settings/user', () =>
jsonResponse({
id: 'us-1', userId: opAlice.id, selectedFlightId: null,
annotationsLeftPanelWidth: null, annotationsRightPanelWidth: null,
datasetLeftPanelWidth: null, datasetRightPanelWidth: null,
}),
),
http.put('/api/annotations/settings/user', async ({ request }) => {
const body = await request.json()
return jsonResponse(body)
}),
)
}
describe('AZ-468 / src/components/Header.tsx — flight dropdown', () => {
beforeEach(() => {
seedBearer()
wireAuthAndFlights()
})
afterEach(() => {
clearBearer()
vi.restoreAllMocks()
})
describe('FT-P-30 — closed-state a11y', () => {
it.fails(
'trigger advertises aria-expanded=false when the menu is closed (drift: attribute currently missing)',
async () => {
mountHeader()
// Wait for the flights list to have been fetched so the trigger is hydrated.
await waitFor(() =>
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
)
const trigger = screen.getByRole('button', { name: /select flight/i })
// AC-1 contract: aria-expanded=false when closed; no aria-activedescendant.
expect(trigger).toHaveAttribute('aria-expanded', 'false')
expect(trigger).not.toHaveAttribute('aria-activedescendant')
},
)
it('control — closed trigger today lacks aria-expanded entirely (drift seen)', async () => {
mountHeader()
await waitFor(() =>
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
)
const trigger = screen.getByRole('button', { name: /select flight/i })
expect(trigger).not.toHaveAttribute('aria-expanded')
})
})
describe('FT-P-31 — open-state a11y', () => {
it.fails(
'opened dropdown advertises aria-expanded=true and listbox/menu role with a real aria-activedescendant (drift: attributes missing today)',
async () => {
const user = userEvent.setup()
mountHeader()
await waitFor(() =>
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
)
const trigger = screen.getByRole('button', { name: /select flight/i })
await user.click(trigger)
// AC-2 contract.
expect(trigger).toHaveAttribute('aria-expanded', 'true')
const listbox = screen.getByRole('listbox')
expect(listbox).toBeInTheDocument()
const optionId = trigger.getAttribute('aria-activedescendant')
expect(optionId).toBeTruthy()
expect(document.getElementById(optionId as string)).not.toBeNull()
},
)
it('control — opened dropdown today exposes options but with no role and no aria wiring (drift seen)', async () => {
const user = userEvent.setup()
mountHeader()
await waitFor(() =>
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
)
const trigger = screen.getByRole('button', { name: /select flight/i })
await user.click(trigger)
// The filter input renders on open, so the panel is visibly open.
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument()
// But none of the listbox roles or aria-activedescendant wiring exists yet.
expect(screen.queryByRole('listbox')).toBeNull()
expect(trigger).not.toHaveAttribute('aria-activedescendant')
})
})
describe('FT-N-09 — Escape close + document-level handler detached', () => {
it.skip(
'QUARANTINE (no production behavior): Escape closes the dropdown and the document keydown handler is removed',
async () => {
// When the production code lands a document-level keydown listener that
// handles Escape, this test asserts:
// 1. Pressing Escape closes the dropdown (filter input gone)
// 2. The document.addEventListener('keydown', ...) call made when the
// dropdown opened is paired with a removeEventListener('keydown', ...)
// with the SAME handler reference when the dropdown closes (verified
// via spies on document.addEventListener/removeEventListener).
// The test below is a sketch of the assertion shape — left skipped because
// Header has no keydown listener today and asserting against absent code
// would produce noise, not signal.
const addSpy = vi.spyOn(document, 'addEventListener')
const removeSpy = vi.spyOn(document, 'removeEventListener')
const user = userEvent.setup()
mountHeader()
await waitFor(() =>
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
)
const trigger = screen.getByRole('button', { name: /select flight/i })
await user.click(trigger)
const keydownAdds = addSpy.mock.calls.filter(([type]) => type === 'keydown')
expect(keydownAdds.length).toBeGreaterThanOrEqual(1)
await user.keyboard('{Escape}')
expect(screen.queryByPlaceholderText(/filter/i)).toBeNull()
const keydownRemoves = removeSpy.mock.calls.filter(([type, fn]) =>
type === 'keydown' && fn === keydownAdds[0]?.[1],
)
expect(keydownRemoves.length).toBeGreaterThanOrEqual(1)
},
)
it('control — Escape today is a no-op; the dropdown stays open (drift seen)', async () => {
const user = userEvent.setup()
mountHeader()
await waitFor(() =>
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
)
const trigger = screen.getByRole('button', { name: /select flight/i })
await user.click(trigger)
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument()
await user.keyboard('{Escape}')
// QUARANTINE evidence: the filter input is still present — Escape did
// nothing because the Header has no keydown handler today.
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument()
})
})
})