mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 12:21:10 +00:00
70fb452805
Replace the broken `GET /api/admin/auth/refresh` (no `credentials:'include'`) mount-time bootstrap with `POST /api/admin/auth/refresh` (with credentials) chained to `GET /api/admin/users/me`. Returning users with a valid HttpOnly refresh cookie no longer flash through `/login`. Closes Finding B3 / Vision P3. - Add module-scoped `bootstrapInflight` guard (StrictMode double-mount safety) + test-only reset hook exported via the `src/auth` barrel; `tests/setup.ts` resets it in `afterEach` to prevent pending-promise leakage between tests. - Defensive `hasPermission` against legacy `/users/me` payloads omitting `permissions`; default MSW handler now seeds `permissions` explicitly. - Add `endpoints.admin.usersMe()` builder (STC-ARCH-02 forbids the literal). - Bulk-swap 15 test files from `http.get` -> `http.post` for the refresh override so intentional bootstrap-fail tests still fail correctly. - Update auth component description; mark B3 closed. - Code review verdict PASS; static + fast suites green (231 / 13 skipped). Batch report: _docs/03_implementation/batch_13_cycle3_report.md Co-authored-by: Cursor <cursoragent@cursor.com>
209 lines
8.8 KiB
TypeScript
209 lines
8.8 KiB
TypeScript
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(
|
|
// AZ-510 — bootstrap = POST refresh -> { token } + chained GET /users/me.
|
|
http.post('/api/admin/auth/refresh', () => jsonResponse({ token: 'test-bearer-default' })),
|
|
http.get('/api/admin/users/me', () =>
|
|
jsonResponse({ ...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()
|
|
})
|
|
})
|
|
})
|