Files
ui/tests/upload_size_cap.test.tsx
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

291 lines
13 KiB
TypeScript

import { useEffect } from 'react'
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import { http } from 'msw'
import { server } from './msw/server'
import { jsonResponse, paginate } from './msw/helpers'
import { renderWithProviders, screen, fireEvent, waitFor, userEvent } from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import { FlightProvider, useFlight } from '../src/components'
import { AnnotationsPage } from '../src/features/annotations'
import { seedFlights } from './fixtures/seed_flights'
// AZ-476 — Upload >500 MB → 413 → user-visible error (no alert).
//
// AC-1 (FT-N-06 + NFT-RES-07): When nginx returns 413 on an oversized upload,
// the SPA shows an in-DOM error (toast or inline)
// carrying an i18n-keyed message.
// Production today (`MediaList.uploadFiles`)
// catches the upload failure silently and falls
// through to local mode — no error region, no
// i18n key. `it.fails()` + control.
// AC-2 (no alert): The 413 path does NOT invoke `alert()`.
// Today the type-rejection path DOES invoke
// alert() (line 111 of MediaList.tsx) — but ONLY
// for unsupported file types, not for size. For
// the 413 path this is PASS today (vacuous —
// the failure is silently swallowed). The test
// asserts the spy count for both alert and the
// MSW POST so the contract is pinned.
const FLIGHT = seedFlights[0]
interface UploadRig {
posts: { url: string; pathname: string; status: number }[]
}
function rigUploadEnv(opts: { uploadStatus: number }): UploadRig {
const posts: { url: string; pathname: string; status: number }[] = []
server.use(
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.get('/api/flights', () => jsonResponse(paginate(seedFlights, 1, 1000))),
http.get('/api/flights/:id', ({ params }) => {
const f = seedFlights.find((x) => x.id === params.id)
return f ? jsonResponse(f) : new Response(null, { status: 404 })
}),
http.get('/api/annotations/settings/user', () =>
jsonResponse({
id: 'user-settings-az476',
userId: 'user-az476',
selectedFlightId: FLIGHT.id,
annotationsLeftPanelWidth: null,
annotationsRightPanelWidth: null,
datasetLeftPanelWidth: null,
datasetRightPanelWidth: null,
}),
),
http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
http.get('/api/annotations/media', () => jsonResponse(paginate([], 1, 1000))),
http.get('/api/annotations/annotations', () => jsonResponse(paginate([], 1, 1000))),
http.get('/api/annotations/classes', () => jsonResponse([])),
http.get('/api/annotations/dataset/info', () =>
jsonResponse({ totalCount: 0, statusCounts: {} }),
),
// Batch upload endpoint — return the configured status (413 for the
// contract test; 200 for the happy-path control).
http.post('/api/annotations/media/batch', ({ request }) => {
const url = new URL(request.url)
posts.push({
url: request.url,
pathname: url.pathname,
status: opts.uploadStatus,
})
if (opts.uploadStatus === 413) {
return new Response('Request entity too large', { status: 413 })
}
return jsonResponse([])
}),
)
return { posts }
}
// Tiny helper component: synchronously seeds the FlightContext's selectedFlight
// on mount. This sidesteps the async user-settings → flights/<id> rehydration
// chain so the test's `if (selectedFlight)` branch in MediaList.uploadFiles is
// guaranteed live by the time we trigger the upload. The chain is exercised
// elsewhere (AZ-463); duplicating it here only adds flake to the AZ-476 contract.
function FlightSeed({ children }: { children: React.ReactNode }): React.ReactElement {
const { selectFlight, selectedFlight } = useFlight()
useEffect(() => {
if (!selectedFlight) selectFlight(FLIGHT)
}, [selectFlight, selectedFlight])
return <>{children}</>
}
function buildOversizedFile(): File {
// Spec: e2e sends a sparse 501 MB file. The fast test does not need 501 MB
// of bytes — MSW's 413 response is the wire signal under test, not the
// request payload size. Use a 1 KB placeholder so the harness stays cheap;
// the size header is what the server (real nginx) gates on, and the SPA
// observes only the 413 response. Comment kept inline so a future reader
// does not "fix" this by allocating 501 MB and slowing CI.
const blob = new Blob([new Uint8Array(1024)], { type: 'video/mp4' })
return new File([blob], 'huge_recon_video.mp4', { type: 'video/mp4' })
}
async function dropFile(file: File): Promise<void> {
// MediaList renders three file inputs:
// inputs[0] — dropzone (react-dropzone, hidden, fed by drag/drop UX).
// inputs[1] — "Open File" label (direct onChange → uploadFiles).
// inputs[2] — folder picker (webkitdirectory, direct onChange).
// We exercise the "Open File" path because its onChange handler is a thin,
// synchronous wrapper that calls `uploadFiles(files)` directly. This avoids
// react-dropzone's internal DataTransfer machinery (which requires a real
// drop event in JSDOM) while still hitting the same `uploadFiles` code
// path under test for the 413 contract.
//
// The "Open File" input has `className="hidden"` (Tailwind `display: none`).
// userEvent.upload attempts to click the input first; on a hidden node this
// fails silently in some JSDOM versions. We bypass the click by setting
// the file list directly and dispatching `change` — equivalent to React's
// onChange wiring path.
const inputs = document.querySelectorAll<HTMLInputElement>('input[type="file"]')
const target = inputs[1]
Object.defineProperty(target, 'files', {
value: [file],
configurable: true,
})
fireEvent.change(target)
// Yield once so React processes the change → uploadFiles() → api.upload().
await new Promise((r) => setTimeout(r, 0))
}
describe('AZ-476 — upload 501 MB → 413 → user-visible error (no alert)', () => {
let createObjectURLSpy: ReturnType<typeof vi.fn> | null = null
let revokeObjectURLSpy: ReturnType<typeof vi.fn> | null = null
let originalCreateObjectURL: typeof URL.createObjectURL | undefined
let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined
beforeEach(() => {
seedBearer()
// JSDOM lacks URL.createObjectURL / revokeObjectURL; production's
// local-mode fallback in MediaList.tsx calls them on the dropped File.
//
// CRITICAL: patch the methods on the URL constructor directly. Do NOT use
// `vi.stubGlobal('URL', { ...URL, createObjectURL })` — that replaces the
// global URL with a plain object, which silently breaks every `new URL(...)`
// call downstream (the SPA's API helper, MSW's request matching, etc.) and
// the resulting fetches never reach the test's MSW handler.
originalCreateObjectURL = (URL as unknown as { createObjectURL?: typeof URL.createObjectURL })
.createObjectURL
originalRevokeObjectURL = (URL as unknown as { revokeObjectURL?: typeof URL.revokeObjectURL })
.revokeObjectURL
createObjectURLSpy = vi.fn(
(file: Blob | File) => `blob:az476-${(file as File).name ?? 'unknown'}`,
)
revokeObjectURLSpy = vi.fn()
;(URL as unknown as { createObjectURL: typeof URL.createObjectURL }).createObjectURL =
createObjectURLSpy as unknown as typeof URL.createObjectURL
;(URL as unknown as { revokeObjectURL: typeof URL.revokeObjectURL }).revokeObjectURL =
revokeObjectURLSpy as unknown as typeof URL.revokeObjectURL
})
afterEach(() => {
clearBearer()
if (originalCreateObjectURL === undefined) {
delete (URL as unknown as { createObjectURL?: typeof URL.createObjectURL }).createObjectURL
} else {
;(URL as unknown as { createObjectURL: typeof URL.createObjectURL }).createObjectURL =
originalCreateObjectURL
}
if (originalRevokeObjectURL === undefined) {
delete (URL as unknown as { revokeObjectURL?: typeof URL.revokeObjectURL }).revokeObjectURL
} else {
;(URL as unknown as { revokeObjectURL: typeof URL.revokeObjectURL }).revokeObjectURL =
originalRevokeObjectURL
}
vi.unstubAllGlobals()
vi.restoreAllMocks()
createObjectURLSpy = null
revokeObjectURLSpy = null
})
describe('AC-1 (FT-N-06 + NFT-RES-07) — user-visible 413 error', () => {
it.fails(
'a 413 from /api/annotations/media/batch surfaces an in-DOM error region with an i18n-keyed message',
async () => {
// Drift: production catches the upload failure silently in
// `MediaList.uploadFiles`'s try/catch and falls through to local mode
// (creating blob URLs). No error region is rendered, no i18n key
// exists for the 413 case. The assertion below requires an element
// with role="alert" carrying an upload-size message; both are absent
// today, so the test fails until production wires the toast and the
// i18n string.
const { posts } = rigUploadEnv({ uploadStatus: 413 })
renderWithProviders(
<FlightProvider>
<FlightSeed>
<AnnotationsPage />
</FlightSeed>
</FlightProvider>,
)
await waitFor(() => {
expect(document.querySelector('input[type="file"]')).not.toBeNull()
})
// Act
const file = buildOversizedFile()
await dropFile(file)
// The POST fires, MSW returns 413.
await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 })
expect(posts[0].pathname).toBe('/api/annotations/media/batch')
expect(posts[0].status).toBe(413)
// Assert — an error region appears in the DOM. The contract is:
// role="alert" + a message carrying an upload-size phrase. The exact
// i18n key (e.g., "annotations.uploadTooLarge") is set by the
// remediation task; the test matches on the rendered text shape.
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
expect(alertEl.textContent ?? '').toMatch(/too large|exceeds|413/i)
},
)
it('control: production silently falls through to local mode on 413 (current drift)', async () => {
// Pin the current behavior so a regression that, e.g., starts throwing
// an unhandled exception (which would leak to the React error boundary
// and crash the page) is visible immediately. Today: POST fires, 413
// returns, code falls through, blob URL is created locally, the file
// appears in the media list under its original name.
const { posts } = rigUploadEnv({ uploadStatus: 413 })
renderWithProviders(
<FlightProvider>
<FlightSeed>
<AnnotationsPage />
</FlightSeed>
</FlightProvider>,
)
await waitFor(() => {
expect(document.querySelector('input[type="file"]')).not.toBeNull()
})
const file = buildOversizedFile()
await dropFile(file)
await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 })
// The local-mode side effect: the file appears in the rendered media
// list with its original name. This pins the silent-fall-through drift.
await waitFor(() => {
expect(screen.getByText(/huge_recon_video\.mp4/i)).toBeInTheDocument()
})
})
})
describe('AC-2 — no `alert()` on the 413 path', () => {
it('the 413 path does NOT invoke window.alert() today (vacuous PASS — failure is silently swallowed)', async () => {
// The current drift makes this test pass for the wrong reason: there's
// no alert because there's no error handling at all. When AC-1 lands
// and an in-DOM error region is wired, this contract still must hold —
// i.e., even with proper error surfacing, alert() stays out of the
// path. The static check (STC-SEC7) provides the source-level gate;
// this runtime test is the defence-in-depth assertion required by the
// task spec.
const alertSpy = vi.fn()
vi.stubGlobal('alert', alertSpy)
const { posts } = rigUploadEnv({ uploadStatus: 413 })
renderWithProviders(
<FlightProvider>
<FlightSeed>
<AnnotationsPage />
</FlightSeed>
</FlightProvider>,
)
await waitFor(() => {
expect(document.querySelector('input[type="file"]')).not.toBeNull()
})
const file = buildOversizedFile()
await dropFile(file)
await waitFor(() => expect(posts).toHaveLength(1), { timeout: 3000 })
// Give the error path time to either render an alert (drift) or not.
await new Promise((r) => setTimeout(r, 100))
expect(alertSpy).not.toHaveBeenCalled()
})
})
})