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:
DongHyeonka
2026-08-18 03:07:02 +09:00
co-authored by Claude Opus 5
parent c9c832c365
commit 2cab4974b7
16 changed files with 793 additions and 55 deletions
@@ -118,3 +118,90 @@ test("maps a network failure onto STUDIO_UNAVAILABLE", async () => {
return true;
});
});
// M2 (fix round 1). The two "don't invent a domain error" fallback branches
// had no test coverage — the code was already correct, but nothing pinned it.
test("falls back to STUDIO_UNAVAILABLE for an uncontracted problem code instead of inventing one", async () => {
server.use(
http.post(`${BASE}/api/v1/studio/assets`, () =>
HttpResponse.json(
{
type: "https://techlog.local/problems/teapot",
title: "IM_A_TEAPOT",
status: 418,
detail: "이 서버는 커피를 내릴 수 없습니다.",
code: "IM_A_TEAPOT",
},
{ status: 418, headers: { "content-type": "application/problem+json" } },
),
),
);
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "STUDIO_UNAVAILABLE");
return true;
});
});
test("falls back to STUDIO_UNAVAILABLE when the error body cannot be parsed as JSON", async () => {
server.use(
http.post(
`${BASE}/api/v1/studio/assets`,
() => new HttpResponse("<html>not json</html>", { status: 500 }),
),
);
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "STUDIO_UNAVAILABLE");
return true;
});
});
// M1 (fix round 1). A malformed 201 body must not throw a raw SyntaxError out
// of a port whose contract is StudioGatewayError.
test("falls back to STUDIO_UNAVAILABLE when a success body cannot be parsed as JSON", async () => {
server.use(
http.post(
`${BASE}/api/v1/studio/assets`,
() => new HttpResponse("not json", { status: 201 }),
),
);
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "STUDIO_UNAVAILABLE");
return true;
});
});
// M3 (fix round 1). A cancelled/deadline-exceeded upload must read the same
// as the JSON path's CANCELLED mapping: not retryable.
test("maps an aborted upload onto a non-retryable STUDIO_UNAVAILABLE", async () => {
server.use(
http.post(`${BASE}/api/v1/studio/assets`, async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return HttpResponse.json({ id: "a" }, { status: 201 });
}),
);
const controller = new AbortController();
const pending = transport().upload(
{ file: svg(), kind: "IMAGE" },
{},
{ signal: controller.signal },
);
controller.abort();
await assert.rejects(pending, (error: unknown) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "STUDIO_UNAVAILABLE");
assert.equal(error.status, 499);
assert.equal(error.retryable, false);
return true;
});
});
@@ -3,7 +3,10 @@ import { test } from "vitest";
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 { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
import {
isStudioGatewayError,
StudioGatewayError,
} from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
const READY_ASSET = {
id: "11111111-1111-4111-8111-111111111111",
@@ -117,6 +120,92 @@ test("delegates upload to the transport with CSRF and idempotency headers", asyn
assert.equal(received["Idempotency-Key"], "upload-1");
});
// I2 (fix round 1). `techLogCsrf.invalidate()` at the composition root only
// runs from `contractOperations.execute`'s `UNAUTHENTICATED` branch, which
// the multipart upload bypasses entirely. Without an explicit call from the
// gateway itself, a 401/403 on upload left a stale token cached for every
// other Studio operation.
for (const status of [401, 403]) {
test(`invalidates the cached CSRF token when upload rejects with ${status}`, async () => {
let executions = 0;
const csrf = createCsrfTokenProvider({
async execute() {
executions += 1;
return { csrfToken: `csrf-${executions}`, csrfHeaderName: "X-CSRF-TOKEN" };
},
});
const { dependencies } = deps({});
const gateway = createHttpStudioAssetGateway({
...dependencies,
csrf,
upload: {
async upload() {
throw new StudioGatewayError({
type: "https://techlog.local/problems/authentication-required",
title: "AUTHENTICATION_REQUIRED",
status,
detail: "세션이 만료되었습니다.",
code: "AUTHENTICATION_REQUIRED",
});
},
},
} as never);
await assert.rejects(
gateway.uploadAsset(
{ file: new File(["<svg/>"], "boundary.svg", { type: "image/svg+xml" }), kind: "DIAGRAM" },
{ idempotencyKey: "upload-1" },
),
);
// The upload itself already consumed one fetch.
assert.equal(executions, 1);
// A fresh token() call after the failure must re-fetch, not replay the
// (now-rejected) cached value.
await csrf.token();
assert.equal(executions, 2);
});
}
test("does not invalidate the cached CSRF token for an unrelated upload failure", async () => {
let executions = 0;
const csrf = createCsrfTokenProvider({
async execute() {
executions += 1;
return { csrfToken: `csrf-${executions}`, csrfHeaderName: "X-CSRF-TOKEN" };
},
});
const { dependencies } = deps({});
const gateway = createHttpStudioAssetGateway({
...dependencies,
csrf,
upload: {
async upload() {
throw new StudioGatewayError({
type: "https://techlog.local/problems/payload-too-large",
title: "PAYLOAD_TOO_LARGE",
status: 413,
detail: "파일이 너무 큽니다.",
code: "PAYLOAD_TOO_LARGE",
});
},
},
} as never);
await assert.rejects(
gateway.uploadAsset(
{ file: new File(["<svg/>"], "boundary.svg", { type: "image/svg+xml" }), kind: "DIAGRAM" },
{ idempotencyKey: "upload-1" },
),
);
assert.equal(executions, 1);
await csrf.token();
// Still cached — a 413 says nothing about the token's validity.
assert.equal(executions, 1);
});
test("surfaces ASSET_IN_USE from a rejected delete", async () => {
const { dependencies } = deps({
deleteStudioAsset: {
@@ -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);
},
);
@@ -0,0 +1,88 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import {
createCsrfTokenProvider,
type CsrfTokenProvider,
} from "../../../src/features/tech-log/adapters/http/studio-session-csrf.ts";
/**
* Task 7 fix round 1 (C1, item 2). `createCsrfTokenProvider`'s `resolve()`
* previously used `inFlight ??= deps.execute(options).then(...)`, which
* evaluates `deps.execute(options)` — and therefore any re-entrant call back
* into this same provider — before the `??=` assignment to `inFlight`
* completes. If the operation an `execute` implementation calls happens to
* require this same provider's token, `resolve()` re-enters itself while
* `inFlight` is still `null`, recursing without bound
* (`RangeError: Maximum call stack size exceeded`) instead of deduplicating.
* The real fix for the TechLog Studio case is giving `getStudioSession` a
* credential-free auth profile (`studio-csrf-composition.test.ts` proves
* that end to end); this file pins the provider's own defense-in-depth
* guard in isolation, so any future `execute` implementation with the same
* mistake fails loudly and immediately instead of overflowing the stack.
*/
test("shares one in-flight request across concurrent callers", async () => {
let executions = 0;
let resolveExecute: ((snapshot: { csrfToken: string; csrfHeaderName: string }) => void) | undefined;
const provider = createCsrfTokenProvider({
execute() {
executions += 1;
return new Promise((resolve) => {
resolveExecute = resolve;
});
},
});
const first = provider.token();
const second = provider.token();
resolveExecute?.({ csrfToken: "csrf-1", csrfHeaderName: "X-CSRF-TOKEN" });
assert.equal(await first, "csrf-1");
assert.equal(await second, "csrf-1");
assert.equal(executions, 1);
});
test("caches the token after the first successful resolution", async () => {
let executions = 0;
const provider = createCsrfTokenProvider({
async execute() {
executions += 1;
return { csrfToken: `csrf-${executions}`, csrfHeaderName: "X-CSRF-TOKEN" };
},
});
assert.equal(await provider.token(), "csrf-1");
assert.equal(await provider.token(), "csrf-1");
assert.equal(executions, 1);
});
test("re-fetches after invalidate()", async () => {
let executions = 0;
const provider = createCsrfTokenProvider({
async execute() {
executions += 1;
return { csrfToken: `csrf-${executions}`, csrfHeaderName: "X-CSRF-TOKEN" };
},
});
assert.equal(await provider.token(), "csrf-1");
provider.invalidate();
assert.equal(await provider.token(), "csrf-2");
assert.equal(executions, 2);
});
test("fails loudly instead of recursing when execute re-enters the provider before its first request settles", async () => {
let provider!: CsrfTokenProvider;
provider = createCsrfTokenProvider({
async execute() {
// Reproduces the shape of the C1 cycle directly: the operation that
// issues the token itself asks this same provider for the token,
// synchronously re-entering `resolve()` before `inFlight` is assigned.
await provider.token();
return { csrfToken: "unreachable", csrfHeaderName: "X-CSRF-TOKEN" };
},
});
await assert.rejects(provider.token(), /re-entered/);
});
@@ -0,0 +1,65 @@
// @vitest-environment jsdom
import assert from "node:assert/strict";
import { renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { test } from "vitest";
import type { StudioAssetGateway } from "../../../src/features/tech-log/application/ports/studio-asset-gateway.ts";
import {
StudioContext,
useStudioAssetGateway,
type StudioContextValue,
} from "../../../src/features/tech-log/presentation/studio/use-studio.ts";
/**
* I3 (Task 7 fix round 1). `StudioContextValue.assetGateway` stays nullable
* so the many test harnesses that render `StudioProvider` without a
* `createAssetGateway` prop keep compiling — but that nullability would let
* Asset UI (Task 11) write `if (!assetGateway) return null` and ship a
* confusingly empty screen with the type checker satisfied. This pins the
* throwing accessor Asset UI must use instead.
*/
function contextValue(
assetGateway: StudioAssetGateway | null,
): StudioContextValue {
return {
gateway: {} as never,
assetGateway,
resolvePublishedLabel: () => undefined,
now: () => new Date("2026-08-14T01:00:00.000Z"),
editor: null,
requestAnnouncement: "",
setRequestAnnouncement: () => {},
navigateInternal: () => {},
beginEditor: () => {},
updateEditorDraft: () => {},
setEditorStatus: () => {},
clearEditor: () => {},
};
}
function wrapperFor(assetGateway: StudioAssetGateway | null) {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<StudioContext.Provider value={contextValue(assetGateway)}>
{children}
</StudioContext.Provider>
);
};
}
test("returns the asset gateway when the provider supplied one", () => {
const assetGateway = {} as StudioAssetGateway;
const { result } = renderHook(() => useStudioAssetGateway(), {
wrapper: wrapperFor(assetGateway),
});
assert.equal(result.current, assetGateway);
});
test("throws a clear error instead of returning null when no asset gateway was supplied", () => {
assert.throws(
() => renderHook(() => useStudioAssetGateway(), { wrapper: wrapperFor(null) }),
/useStudioAssetGateway must be used within a StudioProvider that was given a createAssetGateway prop/,
);
});