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>
185 lines
7.3 KiB
TypeScript
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);
|
|
},
|
|
);
|