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

320 lines
13 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest'
import { http } from 'msw'
import { server } from './msw/server'
import { jsonResponse, paginate, sse } from './msw/helpers'
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import { FlightProvider } from '../src/components'
import { AnnotationsPage } from '../src/features/annotations'
import { MediaType, MediaStatus } from '../src/types'
import type { Media } from '../src/types'
// AZ-461 — Detection endpoints (sync image / async video / long-video header).
//
// AC-1 (FT-P-11): clicking Detect on an image issues exactly one POST whose
// URL matches `^/api/detect/[0-9]+$` (the wire contract). The
// production handler in <AnnotationsSidebar> already POSTs
// `/api/detect/${media.id}` against the active media — passes
// today when the media id is numeric.
// AC-2 (FT-P-12): async-video detect endpoint + SSE — TARGET (Phase B). The
// async path does not exist in production today (single
// Detect button POSTs the same endpoint regardless of media
// type; no `/api/detect/video/<id>` route, no `jobId`, no
// EventSource on `/api/detect/stream/<id>`). Recorded as
// `it.fails()` so the test runs in CI (the spec requires
// "test code itself runs (does not just xit)") and emits a
// console log "FT-P-12 awaits AC-25 / async video detect impl"
// per AC-2 contract. Flips green when AC-25 lands.
// AC-3 (FT-P-13): long-video detect carries an `X-Refresh-Token` header — no
// such header is added in production (`api.post` only sets
// Authorization + Content-Type). `it.fails()` until the
// header is wired in Phase B per task spec note.
// Production detect URL is `/api/detect/<media.id>`. The contract regex
// `^/api/detect/[0-9]+$` requires a numeric id segment; the seed media for
// this test uses a numeric-style string id ('42') so the regex matches the
// observed URL today. (Other tests use 'media-1' style ids for unrelated
// reasons.)
const NUMERIC_MEDIA_ID = '42'
const NUMERIC_VIDEO_MEDIA_ID = '57'
const seedImageMedia: Media = {
id: NUMERIC_MEDIA_ID,
name: 'detect-image.jpg',
path: '/media/detect-image.jpg',
mediaType: MediaType.Image,
mediaStatus: MediaStatus.New,
duration: null,
annotationCount: 0,
waypointId: null,
userId: 'user-az461',
}
const seedVideoMedia: Media = {
id: NUMERIC_VIDEO_MEDIA_ID,
name: 'detect-video.mp4',
path: '/media/detect-video.mp4',
mediaType: MediaType.Video,
mediaStatus: MediaStatus.New,
duration: '00:01:30',
annotationCount: 0,
waypointId: null,
userId: 'user-az461',
}
interface CapturedRequest {
url: string
method: string
pathname: string
headers: Record<string, string>
}
interface CapturedSSE {
url: string
}
function captureDetectAndBootstrap(opts?: {
mediaItems?: Media[]
detectStatus?: number
detectResponse?: Record<string, unknown>
registerVideoEndpoints?: boolean
}): { detectCalls: CapturedRequest[]; sseOpens: CapturedSSE[] } {
const detectCalls: CapturedRequest[] = []
const sseOpens: CapturedSSE[] = []
const items = opts?.mediaItems ?? [seedImageMedia]
const detectStatus = opts?.detectStatus ?? 200
const detectResponse = opts?.detectResponse ?? { detections: [] }
const handlers = [
// Wide-net detect catcher — production POSTs `/api/detect/<id>` for any
// media id today. The handler captures URL + headers so AC-1 + AC-3 can
// assert against the same request log.
http.post('/api/detect/:rest*', async ({ request, params }) => {
const url = new URL(request.url)
const headers: Record<string, string> = {}
request.headers.forEach((v, k) => {
headers[k] = v
})
detectCalls.push({
url: request.url,
method: request.method,
pathname: url.pathname,
headers,
})
// Synthesize an async-video shape if the URL matches the future Phase B
// contract `^/api/detect/video/[0-9]+$`. Today no such request fires;
// when AC-25 lands and production routes here, this responder makes the
// jobId assertion in AC-2 stop being a "wholly absent" failure.
if (typeof params.rest === 'string' && params.rest.startsWith('video/')) {
return jsonResponse({ jobId: 12345 })
}
if (detectStatus >= 400) {
return new Response(JSON.stringify({ error: 'simulated' }), { status: detectStatus })
}
return jsonResponse(detectResponse)
}),
// Phase B — async video detect SSE. Today no production code opens this
// EventSource; the handler exists only so AC-2's `it.fails()` body can
// run end-to-end without MSW unhandled-request errors when the path
// eventually lands.
...(opts?.registerVideoEndpoints
? [
http.get('/api/detect/stream/:jobId', ({ request }) => {
sseOpens.push({ url: new URL(request.url).pathname })
return sse([
{ event: 'progress', data: { pct: 50 }, id: '1' },
{ event: 'done', data: { detections: [] }, id: '2' },
])
}),
]
: []),
// Bootstrap — minimal handlers so <AnnotationsPage> mounts cleanly and
// <MediaList> shows the seeded media item.
http.post('/api/admin/auth/refresh', () => new Response(null, { status: 401 })),
http.get('/api/flights', () => jsonResponse(paginate([], 1, 1000))),
http.get('/api/annotations/settings/user', () => new Response(null, { status: 404 })),
http.put('/api/annotations/settings/user', () => new Response(null, { status: 200 })),
http.get('/api/annotations/media', ({ request }) => {
const url = new URL(request.url)
const page = Number(url.searchParams.get('page') ?? '1')
const pageSize = Number(url.searchParams.get('pageSize') ?? '1000')
return jsonResponse(paginate(items, page, pageSize))
}),
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: {} })),
]
server.use(...handlers)
return { detectCalls, sseOpens }
}
// The Detect button label comes from i18n key `annotations.detect`, which
// resolves to `'AI Detect'` in the en bundle (see `src/i18n/en.json`). Match
// the localized string rather than the i18n key so the test stays robust
// against future copy tweaks while still asserting on the rendered DOM.
const DETECT_BUTTON_NAME = /AI Detect/i
async function selectMediaAndClickDetect(mediaName: string): Promise<void> {
const mediaItem = await screen.findByText(mediaName)
await userEvent.click(mediaItem)
// The Detect button lives in <AnnotationsSidebar>'s header. It is rendered
// unconditionally but is `disabled` until selectedMedia is non-null —
// userEvent.click on a disabled element is a no-op, so wait for it to
// enable first.
await waitFor(() => {
const btn = screen.getByRole('button', { name: DETECT_BUTTON_NAME })
expect(btn).not.toBeDisabled()
})
await userEvent.click(screen.getByRole('button', { name: DETECT_BUTTON_NAME }))
}
describe('AZ-461 — detection endpoints (sync / async / long-video header)', () => {
beforeEach(() => {
seedBearer()
})
describe('AC-1 (FT-P-11) — sync image detect URL canary', () => {
it('clicks Detect on an image and observes exactly one POST whose URL matches /api/detect/<id>', async () => {
// Arrange
const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedImageMedia] })
renderWithProviders(
<FlightProvider>
<AnnotationsPage />
</FlightProvider>,
)
// Act
await selectMediaAndClickDetect(seedImageMedia.name)
// Assert — exactly one POST fired against the contract URL.
await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })
expect(detectCalls[0].method).toBe('POST')
// FT-P-11 contract regex: `^/api/detect/[0-9]+$`. Numeric media id makes
// production's `/api/detect/${media.id}` satisfy this regex today.
expect(detectCalls[0].pathname).toMatch(/^\/api\/detect\/[0-9]+$/)
expect(detectCalls[0].pathname).toBe(`/api/detect/${NUMERIC_MEDIA_ID}`)
clearBearer()
})
})
describe('AC-2 (FT-P-12) — async video detect endpoint + SSE (Phase B target — QUARANTINE)', () => {
it.fails(
'POSTs `/api/detect/video/<id>`, response carries jobId, EventSource opens on `/api/detect/stream/<jobId>`',
async () => {
// Per task-spec AC-2: "FT-P-12 is implemented and registered, but
// marked Result: QUARANTINE in the CSV report until AC-25 (Phase B)
// lands. The test code itself runs (does not just `xit`) and produces
// a clear log entry." Today's production code POSTs
// `/api/detect/${media.id}` regardless of mediaType (single endpoint
// shape), so the assertion below fails. When AC-25 introduces a
// separate `/api/detect/video/<id>` POST + SSE pair, this test flips
// to PASS automatically.
//
// eslint-disable-next-line no-console
console.log('FT-P-12 awaits AC-25 / async video detect impl')
const { detectCalls, sseOpens } = captureDetectAndBootstrap({
mediaItems: [seedVideoMedia],
registerVideoEndpoints: true,
detectResponse: { jobId: 12345 },
})
renderWithProviders(
<FlightProvider>
<AnnotationsPage />
</FlightProvider>,
)
// Act
await selectMediaAndClickDetect(seedVideoMedia.name)
// Assert — the video-routed POST shape (Phase B) and the SSE handshake.
await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })
expect(detectCalls[0].pathname).toMatch(/^\/api\/detect\/video\/[0-9]+$/)
// The SSE branch — production today does not call EventSource at all
// for detect, so the polling assertion here also fails until AC-25.
await waitFor(() => expect(sseOpens.length).toBeGreaterThan(0), { timeout: 2000 })
expect(sseOpens[0].url).toMatch(/^\/api\/detect\/stream\/[0-9]+$/)
clearBearer()
},
)
it('control: production posts to /api/detect/<id> regardless of mediaType (single-endpoint drift)', async () => {
// Pin the CURRENT (drift) behavior so a regression that, e.g., stops
// sending the request at all is caught even before AC-25 lifts the
// QUARANTINE. When AC-25 introduces a separate video endpoint, this
// control test will need to be adjusted (the pinned URL will change).
const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedVideoMedia] })
renderWithProviders(
<FlightProvider>
<AnnotationsPage />
</FlightProvider>,
)
await selectMediaAndClickDetect(seedVideoMedia.name)
await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })
// Today: single endpoint, same shape for image and video.
expect(detectCalls[0].pathname).toBe(`/api/detect/${NUMERIC_VIDEO_MEDIA_ID}`)
clearBearer()
})
})
describe('AC-3 (FT-P-13) — long-video detect carries `X-Refresh-Token` header', () => {
it.fails(
'every long-video detect request carries an `X-Refresh-Token` header (drift — production sets only Authorization)',
async () => {
// Production's `api.post` chain (`src/api/client.ts` request fn) sets
// only `Authorization: Bearer <token>` and `Content-Type` for JSON
// bodies. `X-Refresh-Token` is NOT added today. This is the documented
// Step-4-style drift the task spec calls out ("until F7 lands and
// the header is added per Step 4").
const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedVideoMedia] })
renderWithProviders(
<FlightProvider>
<AnnotationsPage />
</FlightProvider>,
)
await selectMediaAndClickDetect(seedVideoMedia.name)
await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })
// Headers are normalised lower-case via the Headers iterator above.
const xRefresh = detectCalls[0].headers['x-refresh-token']
expect(xRefresh).toBeDefined()
expect(xRefresh).not.toBe('')
clearBearer()
},
)
it('control: production sets only Authorization header on detect (current behavior)', async () => {
// This control proves the static check + the spy machinery work today
// and would catch a regression that drops Authorization entirely. When
// AC-3 flips green via Phase B, this control becomes redundant; the
// `it.fails()` above flips and this test still passes (since
// Authorization is also expected to remain).
const { detectCalls } = captureDetectAndBootstrap({ mediaItems: [seedVideoMedia] })
renderWithProviders(
<FlightProvider>
<AnnotationsPage />
</FlightProvider>,
)
await selectMediaAndClickDetect(seedVideoMedia.name)
await waitFor(() => expect(detectCalls).toHaveLength(1), { timeout: 3000 })
const auth = detectCalls[0].headers['authorization']
expect(auth).toBeDefined()
expect(auth).toMatch(/^Bearer /)
clearBearer()
})
})
})