mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 23:21:10 +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>
320 lines
13 KiB
TypeScript
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()
|
|
})
|
|
})
|
|
})
|