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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
c9c832c365
commit
2cab4974b7
@@ -0,0 +1,184 @@
|
||||
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);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user