mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 09:31:10 +00:00
1dd25edee3
AZ-466 — Destructive UX policy + ConfirmDialog a11y + no-alert (4pts):
src/components/ConfirmDialog.test.tsx (8 fast),
tests/destructive_ux.test.tsx (4 fast, AdminPage class-delete drift),
e2e/tests/destructive_ux.e2e.ts. New static checks STC-SEC7 (alert
allowlist) + STC-SEC8 (destructive-surfaces gated/drift) wired through
scripts/check-banned-deps.mjs reading tests/security/banned-deps.json.
AZ-475 — Numeric form input rejection (2pts):
tests/form_hygiene.test.tsx (3 fast). Documents two SettingsPage drifts:
silent zero coercion via parseInt(v)||0 and labels missing htmlFor.
AZ-462 — Overlay membership at in-window edges (2pts):
tests/overlay_membership.test.tsx (6 fast). Documents getTimeWindowDetections
strict < drift; AC-1 boundary tests are it.fails(); AC-2 / control PASS.
Mocks HTMLCanvasElement.getContext to capture strokeRect.
AZ-460 — Annotation save URL + payload contract (2pts):
tests/annotations_endpoint.test.tsx (6 fast),
e2e/tests/annotations_endpoint.e2e.ts. AC-1 URL canary PASSes; AC-2
payload missing 4 fields documented as it.fails(); AC-3 manual-draw
PASS, AI-suggestion-accept + bulk-edit-save QUARANTINE skip.
Test infrastructure:
- tests/setup.ts: NoopResizeObserver + NoopEventSource JSDOM polyfills.
- tests/msw/handlers/annotations.ts: doubly-prefixed paths matching
production calls (e.g. /api/annotations/annotations).
- tests/msw/handlers/flights.ts: plural /aircrafts paths.
Verification: bun run test:fast → 80 passed, 13 skipped (14 files).
scripts/run-tests.sh --static-only → 24/24 PASS (was 22; +STC-SEC7/SEC8).
Per-batch self-review verdict: PASS_WITH_WARNINGS. Cumulative review
of batches 04-06 due after batch 6 per implement/SKILL.md Step 14.5.
Report: _docs/03_implementation/batch_04_report.md.
Also includes the previously-untracked
_docs/03_implementation/cumulative_review_batches_01-03_report.md
generated at the start of this session before batch 4 began.
Co-authored-by: Cursor <cursoragent@cursor.com>
167 lines
7.6 KiB
TypeScript
167 lines
7.6 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import { http } from 'msw'
|
|
import { server } from './msw/server'
|
|
import { jsonResponse, noContent } from './msw/helpers'
|
|
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
|
|
import { seedBearer, clearBearer } from './helpers/auth'
|
|
import AdminPage from '../src/features/admin/AdminPage'
|
|
|
|
// AZ-466 — Destructive UX policy (cross-component half)
|
|
//
|
|
// AC-1 (FT-P-26): clicking Delete on a class → confirming → DELETE fires AFTER confirm.
|
|
// AC-2 (FT-N-07): clicking Delete → Cancel → NO DELETE fires.
|
|
// AC-4 (FT-P-27 / NFT-SEC-08): static check enumerates every destructive surface
|
|
// and asserts each one mounts a `<ConfirmDialog>`.
|
|
// The static side lives in `scripts/run-tests.sh` /
|
|
// `scripts/check-banned-deps.mjs` (`STC-SEC8`).
|
|
// The runtime mirror is one of the cases below.
|
|
// AC-5 (NFT-SEC-07): no `alert()` in `src/`. Static side enforces this; runtime
|
|
// side here only documents the current allowlist. The runtime
|
|
// test would require renderring every component that calls
|
|
// alert — out of black-box scope. Static check `STC-SEC7`
|
|
// handles enforcement.
|
|
//
|
|
// Production drift (`src/features/admin/AdminPage.tsx:30-33` and table row
|
|
// line 76):
|
|
// `handleDeleteClass` directly calls `api.delete` without gating through
|
|
// `<ConfirmDialog>`. The class-delete row's `<button onClick=...>` triggers
|
|
// the network mutation immediately. FT-P-26 + FT-N-07 are recorded as
|
|
// `it.fails()` until production wraps `handleDeleteClass` behind ConfirmDialog
|
|
// (Phase B feature task).
|
|
|
|
const SEED_CLASSES = [
|
|
{ id: 1, name: 'class-a', shortName: 'a', color: '#ff0000', maxSizeM: 7 },
|
|
{ id: 2, name: 'class-b', shortName: 'b', color: '#00ff00', maxSizeM: 5 },
|
|
]
|
|
|
|
interface CapturedDelete {
|
|
url: string
|
|
classId: string
|
|
}
|
|
|
|
function captureClassDelete(): { deletes: CapturedDelete[] } {
|
|
const deletes: CapturedDelete[] = []
|
|
server.use(
|
|
http.delete('/api/admin/classes/:id', ({ request, params }) => {
|
|
deletes.push({ url: new URL(request.url).pathname, classId: String(params.id) })
|
|
return noContent()
|
|
}),
|
|
// AdminPage bootstrap: classes (annotations service), aircrafts, users.
|
|
// NOTE: `AdminPage` reads `/api/admin/users` as a flat User[]
|
|
// (`api.get<User[]>` then `users.map`) — but the suite-default MSW
|
|
// wraps `seedUsers` in `paginate(...)`. That's a documented
|
|
// production-vs-suite drift (admin handler should expose flat in dev).
|
|
// For this destructive-UX test we only care about class-delete
|
|
// wiring, so the override returns a flat empty array to keep
|
|
// AdminPage from crashing on `users.map`.
|
|
http.get('/api/annotations/classes', () => jsonResponse(SEED_CLASSES)),
|
|
http.get('/api/flights/aircrafts', () => jsonResponse([])),
|
|
http.get('/api/admin/users', () => jsonResponse([])),
|
|
// AuthContext bootstraps with GET /api/admin/auth/refresh; tests using
|
|
// <ProtectedRoute>-less render still mount AuthProvider. Return 401 so
|
|
// the unauth path resolves quickly and bootstrap finishes.
|
|
http.get('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
|
|
)
|
|
return { deletes }
|
|
}
|
|
|
|
describe('AZ-466 — Destructive UX policy (class-delete cross-component test)', () => {
|
|
beforeEach(() => {
|
|
seedBearer()
|
|
})
|
|
|
|
describe('AC-1 (FT-P-26) — happy path: Delete → Confirm → DELETE fires', () => {
|
|
it.fails(
|
|
'class-delete prompts a `<ConfirmDialog>` BEFORE issuing the DELETE',
|
|
async () => {
|
|
// Arrange
|
|
const { deletes } = captureClassDelete()
|
|
renderWithProviders(<AdminPage />)
|
|
// Wait for the class table to populate.
|
|
await screen.findByText('class-a')
|
|
|
|
// Act — find the delete button on the first class row.
|
|
const rows = screen.getAllByText(/^class-/i)
|
|
const firstRow = rows[0].closest('tr')!
|
|
const deleteBtn = firstRow.querySelector('button')!
|
|
await userEvent.click(deleteBtn)
|
|
|
|
// Assert — a ConfirmDialog must appear before any DELETE fires.
|
|
// Drift: AdminPage's `handleDeleteClass` issues api.delete directly
|
|
// (no ConfirmDialog wired). The DELETE fires immediately and the
|
|
// dialog never appears.
|
|
const dialog = await screen.findByRole('dialog', undefined, { timeout: 1000 })
|
|
expect(dialog).toBeInTheDocument()
|
|
expect(deletes).toHaveLength(0)
|
|
|
|
// Confirm via the dialog → DELETE fires now.
|
|
const confirm = screen.getAllByRole('button').find(b => /confirm/i.test(b.textContent ?? ''))!
|
|
await userEvent.click(confirm)
|
|
await waitFor(() => expect(deletes).toHaveLength(1), { timeout: 1000 })
|
|
clearBearer()
|
|
},
|
|
)
|
|
|
|
it('control: production today bypasses ConfirmDialog and deletes immediately', async () => {
|
|
// Pin the current (drift) one-click delete behavior. When AC-1 lands,
|
|
// this control flips red and is removed.
|
|
const { deletes } = captureClassDelete()
|
|
renderWithProviders(<AdminPage />)
|
|
await screen.findByText('class-a')
|
|
|
|
const rows = screen.getAllByText(/^class-/i)
|
|
const firstRow = rows[0].closest('tr')!
|
|
const deleteBtn = firstRow.querySelector('button')!
|
|
await userEvent.click(deleteBtn)
|
|
|
|
await waitFor(() => expect(deletes).toHaveLength(1), { timeout: 1000 })
|
|
expect(deletes[0].url).toMatch(/\/api\/admin\/classes\/\d+/)
|
|
clearBearer()
|
|
})
|
|
})
|
|
|
|
describe('AC-2 (FT-N-07) — cancel path: Delete → Cancel → NO DELETE fires', () => {
|
|
it.fails(
|
|
'class-delete with Cancel via the ConfirmDialog suppresses the DELETE entirely',
|
|
async () => {
|
|
// Arrange
|
|
const { deletes } = captureClassDelete()
|
|
renderWithProviders(<AdminPage />)
|
|
await screen.findByText('class-a')
|
|
|
|
// Act — click delete, then Cancel on the dialog.
|
|
const rows = screen.getAllByText(/^class-/i)
|
|
const firstRow = rows[0].closest('tr')!
|
|
await userEvent.click(firstRow.querySelector('button')!)
|
|
|
|
// Drift: the dialog never appears today. The find call fails first
|
|
// (no `role="dialog"` ever mounts), but even if it did, cancel would
|
|
// need to suppress a DELETE that today already fired synchronously.
|
|
const dialog = await screen.findByRole('dialog', undefined, { timeout: 1000 })
|
|
expect(dialog).toBeInTheDocument()
|
|
const cancel = screen.getAllByRole('button').find(b => /cancel/i.test(b.textContent ?? ''))!
|
|
await userEvent.click(cancel)
|
|
|
|
// Assert — NO DELETE was issued.
|
|
await new Promise(r => setTimeout(r, 50))
|
|
expect(deletes).toHaveLength(0)
|
|
clearBearer()
|
|
},
|
|
)
|
|
})
|
|
|
|
describe('AC-4 (FT-P-27 / NFT-SEC-08) — destructive surfaces enumeration', () => {
|
|
// The runtime side of FT-P-27 / NFT-SEC-08 is a multi-component static
|
|
// walk. Implementing it as a Vitest test would require rendering every
|
|
// production page and asserting every destructive surface mounts a
|
|
// ConfirmDialog. That is the static check's job (`STC-SEC8` in
|
|
// `scripts/run-tests.sh` calling `check-banned-deps.mjs --kind=destructive_unguarded`).
|
|
// We pin one runtime example here (AdminPage's class-delete) above to
|
|
// catch regressions on a known-current drift surface.
|
|
it.skip(
|
|
'QUARANTINE — full enumeration is enforced by STC-SEC8 (static check); per-surface runtime tests follow per-feature in Phase B',
|
|
() => {},
|
|
)
|
|
})
|
|
})
|