mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 16:41:11 +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>
172 lines
6.9 KiB
TypeScript
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()
|
|
},
|
|
)
|
|
})
|
|
})
|