import assert from "node:assert/strict"; import { afterAll, afterEach, beforeAll, test } from "vitest"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import { createContractHttpExecutor } from "../../../src/adapters/http/http-execution-v3.ts"; import { composeContractContributions } from "../../../src/contracts/external-contract-runtime.ts"; import { INSTALLED_REST_AUTH_PROFILES } from "../../../src/contracts/rest-profiles.ts"; import { createAssetUploadTransport } from "../../../src/features/tech-log/adapters/http/asset-upload-transport.ts"; import { createHttpStudioAssetGateway } from "../../../src/features/tech-log/adapters/http/http-studio-asset-gateway.ts"; import { createCsrfTokenProvider } from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts"; import { attachStudioSessionCredentials, invalidateTechLogCsrfOnOutcome, } from "../../../src/features/tech-log/adapters/http/studio-session-credentials.ts"; import type { StudioOperationExecutor } from "../../../src/features/tech-log/adapters/http/http-studio-gateway.ts"; import { TECH_LOG_STUDIO_CONTRIBUTION } from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts"; /** * I1 (Task 7 fix round 1). C1 was a self-referential CSRF bootstrap cycle * that no unit test caught, because every existing test either mocked * `contractOperations` directly (never touching `attachCredentials`) or * mocked `attachCredentials` directly (never touching the real * `contractOperations`/`createCsrfTokenProvider` composition). This file * composes the real `createContractHttpExecutor`, the real * `createCsrfTokenProvider`, and the real `attachStudioSessionCredentials` — * the exact function `bootstrap/runtime-adapters.ts` calls, not a * reimplementation of it — the same way the composition root does, and * proves `getStudioSession` dispatches exactly once while its token reaches * both a JSON operation's request and the multipart upload's headers. * * If this test is deleted and either the `TECH_LOG_STUDIO_BOOTSTRAP` auth * profile disappears from `getStudioSession`, or `csrf` is threaded through a * second `createCsrfTokenProvider()` call instead of the one instance built * here, this is the test that would have caught it. */ const BASE = "http://api.test"; const server = setupServer(); beforeAll(() => server.listen({ onUnhandledRequest: "error" })); afterEach(() => server.resetHandlers()); afterAll(() => server.close()); function scopeSnapshot() { return Object.freeze({ generation: 1, fingerprint: "scope-1", identities: Object.freeze({}) as never, signal: new AbortController().signal, isCurrent: () => true, }); } /** * Mirrors `createRuntimeAdapters`'s wiring in `bootstrap/runtime-adapters.ts` * exactly: `techLogCsrf` is declared closing over a forward reference to * `contractOperations` (a throwing stub until assigned), `contractHttp`'s * `attachCredentials` calls the same `attachStudioSessionCredentials` the * production composition root calls, and `contractOperations` is assigned * afterward. */ function composeStudioRuntime() { const composed = composeContractContributions([TECH_LOG_STUDIO_CONTRIBUTION]); let contractOperations: StudioOperationExecutor = Object.freeze({ async execute() { throw new Error("contractOperations used before assignment"); }, }); const techLogCsrf = createCsrfTokenProvider({ async execute(options) { const outcome = await contractOperations.execute( "getStudioSession", {}, { routeId: "TECH_LOG_STUDIO", ...(options?.signal ? { signal: options.signal } : {}), }, ); if (outcome.kind !== "SUCCESS") { throw new Error("studio session is unavailable"); } const value = outcome.value as { csrfToken: string; csrfHeaderName: string }; return { csrfToken: value.csrfToken, csrfHeaderName: value.csrfHeaderName }; }, }); const contractHttp = createContractHttpExecutor({ baseUrl: `${BASE}/`, maxRetryAttempts: 0, authProfiles: INSTALLED_REST_AUTH_PROFILES, async attachCredentials(operation, authContext) { const outcome = await attachStudioSessionCredentials( operation.authProfileId, authContext, techLogCsrf, ); return outcome ?? Object.freeze({ kind: "UNAVAILABLE" as const }); }, }); contractOperations = Object.freeze({ async execute(operationId, input, executionContext) { const operation = composed.httpByOperationId.get(operationId); if (!operation) throw new Error(`no such operation: ${operationId}`); const outcome = await contractHttp.execute(operation, input, { routeId: executionContext.routeId, scope: scopeSnapshot(), ...(executionContext.signal ? { signal: executionContext.signal } : {}), ...(executionContext.intent ? { intent: executionContext.intent } : {}), }); // Fix round 2, item 1. Same production call as // `bootstrap/runtime-adapters.ts`'s `contractOperations.execute` — not // a reimplementation of it. invalidateTechLogCsrfOnOutcome(outcome.kind, techLogCsrf); return outcome; }, }); return { contractOperations, techLogCsrf }; } test( "getStudioSession dispatches exactly once and its token reaches both a JSON operation and the upload", async () => { let sessionCalls = 0; server.use( http.get(`${BASE}/api/v1/studio/session`, () => { sessionCalls += 1; return HttpResponse.json({ authenticated: true, displayName: "테스터", roles: ["editor"], csrfToken: "csrf-token-1", csrfHeaderName: "X-CSRF-TOKEN", }); }), ); let jsonRequestHeader: string | null = null; server.use( http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => { jsonRequestHeader = request.headers.get("x-csrf-token"); return HttpResponse.json({ documentTotals: {}, workflowSections: [], }); }), ); let uploadRequestHeader: string | null = null; server.use( http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => { uploadRequestHeader = request.headers.get("X-CSRF-TOKEN"); return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 }); }), ); const { contractOperations, techLogCsrf } = composeStudioRuntime(); // JSON path: a plain read that uses `TECH_LOG_STUDIO_SESSION` and // therefore requires the CSRF header — this is what C1 made impossible // (unbounded recursion, zero dispatched requests). const dashboardOutcome = await contractOperations.execute( "getStudioDashboard", {}, { routeId: "TECH_LOG_STUDIO" }, ); assert.equal(dashboardOutcome.kind, "SUCCESS"); assert.equal(jsonRequestHeader, "csrf-token-1"); // Multipart path: bypasses `contractOperations` entirely but reads the // token from the same `techLogCsrf` instance. const assetGateway = createHttpStudioAssetGateway({ operations: contractOperations, csrf: techLogCsrf, upload: createAssetUploadTransport({ baseUrl: `${BASE}/`, timeoutMs: 10_000 }), }); const uploaded = await assetGateway.uploadAsset( { file: new File([""], "b.svg", { type: "image/svg+xml" }), kind: "IMAGE" }, { idempotencyKey: "up-1" }, ); assert.equal((uploaded as { id: string }).id, "a"); assert.equal(uploadRequestHeader, "csrf-token-1"); // The one-provider-per-session invariant: both consumers dispatched // `getStudioSession` through the very same in-flight/cached lookup. assert.equal(sessionCalls, 1); }, ); /** * Fix round 2, item 1. `contractOperations.execute` previously invalidated * `techLogCsrf` only on `UNAUTHENTICATED` (HTTP 401). A CSRF-specific * rejection normally arrives as `FORBIDDEN` (HTTP 403) instead — the * platform classifies any 403 response as `FORBIDDEN` regardless of body * (`http-execution-v3.ts:1050`) — so a token rejected during an ordinary * document save left the stale token cached, and every subsequent Studio * mutation kept failing until the page reloaded. The multipart upload path * already invalidated on both 401 and 403; this proves the JSON path now * agrees. */ test( "a 403 on a JSON operation invalidates the cached token so the next operation re-fetches the session", async () => { let sessionCalls = 0; server.use( http.get(`${BASE}/api/v1/studio/session`, () => { sessionCalls += 1; return HttpResponse.json({ authenticated: true, displayName: "테스터", roles: ["editor"], csrfToken: `csrf-token-${sessionCalls}`, csrfHeaderName: "X-CSRF-TOKEN", }); }), ); const dashboardHeaders: (string | null)[] = []; server.use( http.get(`${BASE}/api/v1/studio/dashboard`, ({ request }) => { dashboardHeaders.push(request.headers.get("x-csrf-token")); // First call: the server rejects the (now-stale) token with 403. // Second call: succeeds with whatever token is presented. return dashboardHeaders.length === 1 ? new HttpResponse(null, { status: 403 }) : HttpResponse.json({ documentTotals: {}, workflowSections: [] }); }), ); const { contractOperations } = composeStudioRuntime(); const first = await contractOperations.execute( "getStudioDashboard", {}, { routeId: "TECH_LOG_STUDIO" }, ); assert.equal(first.kind, "FORBIDDEN"); assert.equal(dashboardHeaders[0], "csrf-token-1"); const second = await contractOperations.execute( "getStudioDashboard", {}, { routeId: "TECH_LOG_STUDIO" }, ); assert.equal(second.kind, "SUCCESS"); assert.equal(dashboardHeaders[1], "csrf-token-2"); // Two fresh session fetches: the cache was discarded after the 403, not // replayed on the retry. assert.equal(sessionCalls, 2); }, );