Files
ui/tests/destructive_ux.test.tsx
T
Oleksandr Bezdieniezhnykh 70fb452805 [AZ-510] Auth bootstrap: POST refresh + chained /users/me
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>
2026-05-13 02:59:31 +03:00

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'
// 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.post('/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',
() => {},
)
})
})