Files
tech-log-frontend/tests/features/tech-log/studio-csrf-composition.test.ts
T
DongHyeonkaandClaude Opus 5 2cab4974b7 fix: break the TechLog CSRF bootstrap cycle and close the review's fix-round-1 items
C1 (Critical): getStudioSession was stamped with the same
TECH_LOG_STUDIO_SESSION auth profile as every other Studio operation, and
that profile requires the CSRF header it is getStudioSession's own job to
issue -- an unconditional cycle that recursed without bound in HTTP mode.
Fixed with a credential-free TECH_LOG_STUDIO_BOOTSTRAP auth profile for
getStudioSession alone, a synchronous re-entrancy guard in
createCsrfTokenProvider as defense in depth, and a throwing stub in place of
the prior `let x!: T` assertion. Added a composition-level regression test
that wires the real executor, CSRF provider, and credential-attach function
together and proves getStudioSession dispatches exactly once while its token
reaches both a JSON operation and the multipart upload.

Also: invalidate the cached CSRF token on a 401/403 from the upload path
(I2), a throwing useStudioAssetGateway() accessor so Task 11 cannot silently
compile a null-gateway UI (I3), and the M1-M5 minors from the review (guard
a malformed success body, cover the untested error fallbacks, align aborted
uploads with the JSON path's non-retryable CANCELLED mapping, derive the
credential header name from one source instead of two, and correct the
adapter review doc's operation count).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 03:07:02 +09:00

185 lines
7.3 KiB
TypeScript

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 } 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}`);
return contractHttp.execute(operation, input, {
routeId: executionContext.routeId,
scope: scopeSnapshot(),
...(executionContext.signal ? { signal: executionContext.signal } : {}),
...(executionContext.intent ? { intent: executionContext.intent } : {}),
});
},
});
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(["<svg/>"], "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);
},
);