Files
ui/tests/form_hygiene.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

172 lines
6.9 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest'
import { http } from 'msw'
import { server } from './msw/server'
import { jsonResponse } from './msw/helpers'
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import { SettingsPage } from '../src/features/settings'
// AZ-475 — Numeric form input — empty / non-numeric rejection
//
// AC-1 (FT-N-11): clearing a numeric field MUST surface a validation error
// and prevent the PUT from firing. Silent zero is a regression.
// AC-2 (FT-N-12): typing a non-numeric value MUST surface a validation error
// and prevent the PUT from firing.
//
// Production drift (`src/features/settings/SettingsPage.tsx:38-48, 59-60`):
// 1. `<label>` carries no `htmlFor` — labels are not programmatically
// associated with their inputs (a separate a11y drift surfaced by this
// test's setup; the test works around it via DOM traversal). Phase B
// task should add `id`/`htmlFor` so `getByLabelText` works directly
// and screen readers can navigate the form.
// 2. `parseInt(v) || 0` and `parseFloat(v) || 0` silently coerce empty
// input to 0 with no validation, then the save handler PUTs the
// zeroed payload. FT-N-11 / FT-N-12 are recorded as `it.fails()`
// until production lands a `useNumericField` validator (or equivalent)
// that blocks save on invalid input.
function inputForLabel(labelText: RegExp | string): HTMLInputElement {
// SettingsPage's `<label>` is a sibling of the `<input>` inside a wrapper
// `<div>` (no `htmlFor`). Find the label, walk to its parent, then to the
// input. Once production lands `htmlFor` (drift #1 above), tests can use
// `screen.findByLabelText` directly.
const label = screen.getByText(labelText, { selector: 'label' })
const wrapper = label.parentElement
if (!wrapper) throw new Error(`label "${String(labelText)}" has no parent`)
const input = wrapper.querySelector('input')
if (!input) throw new Error(`no input next to label "${String(labelText)}"`)
return input as HTMLInputElement
}
interface CapturedPut {
url: string
body: Record<string, unknown>
}
function captureSettingsPut(): { puts: CapturedPut[] } {
const puts: CapturedPut[] = []
server.use(
http.put('/api/annotations/settings/system', async ({ request }) => {
puts.push({
url: new URL(request.url).pathname,
body: (await request.json()) as Record<string, unknown>,
})
return jsonResponse({ ok: true })
}),
// Settings page bootstraps three GETs.
http.get('/api/annotations/settings/system', () =>
jsonResponse({
id: 'sys-az475',
name: 'AZ-475 system',
militaryUnit: null,
defaultCameraWidth: 1920,
defaultCameraFoV: 60,
}),
),
http.get('/api/annotations/settings/directories', () =>
jsonResponse({
id: 'dirs-az475',
videosDir: '/srv/v',
imagesDir: '/srv/i',
labelsDir: '/srv/l',
resultsDir: '/srv/r',
thumbnailsDir: '/srv/t',
gpsSatDir: '/srv/gs',
gpsRouteDir: '/srv/gr',
}),
),
http.get('/api/flights/aircrafts', () => jsonResponse([])),
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
)
return { puts }
}
describe('AZ-475 — numeric form input rejection', () => {
beforeEach(() => {
seedBearer()
})
describe('AC-1 (FT-N-11) — empty numeric input', () => {
it.fails(
'shows a validation error and DOES NOT issue the PUT when the field is cleared',
async () => {
// Arrange
const { puts } = captureSettingsPut()
renderWithProviders(<SettingsPage />)
await screen.findByText(/Default Camera Width/i)
const widthInput = inputForLabel(/Default Camera Width/i)
expect(widthInput).toBeInTheDocument()
// Act
await userEvent.clear(widthInput)
// Find the matching Save button (first Save in tenant config block).
const saveButtons = await screen.findAllByRole('button', { name: /Save/i })
await userEvent.click(saveButtons[0])
// Assert — validation message present, no PUT issued.
// Drift today: SettingsPage uses `parseInt(v) || 0` (silent zero) AND
// issues the PUT regardless. Both halves of this assertion fail.
const error = await screen.findByText(/required|invalid|must be a number/i, undefined, {
timeout: 1000,
})
expect(error).toBeInTheDocument()
await new Promise(r => setTimeout(r, 50))
expect(puts).toHaveLength(0)
clearBearer()
},
)
it('control: production today silently coerces empty input to 0 and PUTs', async () => {
// Pin current behavior so a regression that, e.g., starts crashing on
// empty input is caught even before AC-1 is fixed. When AC-1 lands,
// this control flips red and is removed.
const { puts } = captureSettingsPut()
renderWithProviders(<SettingsPage />)
await screen.findByText(/Default Camera Width/i)
const widthInput = inputForLabel(/Default Camera Width/i)
await userEvent.clear(widthInput)
const saveButtons = await screen.findAllByRole('button', { name: /Save/i })
await userEvent.click(saveButtons[0])
await waitFor(() => expect(puts).toHaveLength(1), { timeout: 1000 })
expect(puts[0].body).toMatchObject({ defaultCameraWidth: 0 })
clearBearer()
})
})
describe('AC-2 (FT-N-12) — non-numeric input', () => {
it.fails(
'shows a validation error and DOES NOT issue the PUT when input is non-numeric',
async () => {
// Arrange
const { puts } = captureSettingsPut()
renderWithProviders(<SettingsPage />)
await screen.findByText(/Default Camera Width/i)
const widthInput = inputForLabel(/Default Camera Width/i)
// Act — `<input type="number">` ignores non-numeric typed chars in browsers,
// BUT user-event still fires onChange events. To force a non-numeric value
// through the React state we set the value directly via fireEvent on
// input. (`userEvent.type` would no-op on a number input for "abc".)
await userEvent.clear(widthInput)
widthInput.value = 'abc'
widthInput.dispatchEvent(new Event('input', { bubbles: true }))
widthInput.dispatchEvent(new Event('change', { bubbles: true }))
const saveButtons = await screen.findAllByRole('button', { name: /Save/i })
await userEvent.click(saveButtons[0])
// Assert — validation error visible; no PUT.
const error = await screen.findByText(/invalid|must be a number/i, undefined, {
timeout: 1000,
})
expect(error).toBeInTheDocument()
await new Promise(r => setTimeout(r, 50))
expect(puts).toHaveLength(0)
clearBearer()
},
)
})
})