fix: invalidate the TechLog CSRF token on 403 and harden the bootstrap profile wiring

Item 1 (real bug): contractOperations.execute only invalidated the cached
CSRF token on UNAUTHENTICATED (401). A CSRF-specific rejection normally
arrives as FORBIDDEN (403) -- the platform classifies any 403 response as
FORBIDDEN unconditionally -- so a token rejected during an ordinary document
save left the stale token cached and every subsequent Studio mutation kept
failing until reload. Extracted invalidateTechLogCsrfOnOutcome() so
production and the composition test call the identical function; it now
invalidates on both UNAUTHENTICATED and FORBIDDEN.

Item 2: the upload transport's uncontracted-status fallback hardcoded status
503, so an uncontracted 401/403 body never reached the gateway's
error.status === 401 || 403 invalidation check. Passes the real
response.status through.

Item 3: safeOperation()'s auth-profile parameter is now typed as a union of
the two valid profile constants instead of a bare string, and
assertExactlyOneTechLogStudioBootstrapOperation() fails composition closed
if getStudioSession stops being the sole caller of the credential-free
bootstrap profile.

Item 4: corrected two stale operation counts in the adapter review doc.

Both new tests for items 1 and 2 were run and shown failing before their
fix, per this task's TDD standard for error-path changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-08-18 03:25:29 +09:00
co-authored by Claude Opus 5
parent 2cab4974b7
commit 35cc5c868a
8 changed files with 283 additions and 12 deletions
@@ -4,6 +4,8 @@ import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
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 { isStudioGatewayError } from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
const BASE = "http://api.test";
@@ -205,3 +207,65 @@ test("maps an aborted upload onto a non-retryable STUDIO_UNAVAILABLE", async ()
return true;
});
});
// Fix round 2, item 2. The uncontracted-status fallback previously hardcoded
// status 503, discarding the real HTTP status the server sent.
// http-studio-asset-gateway.ts's uploadAsset() only invalidates the cached
// CSRF token when `error.status === 401 || error.status === 403` — an
// uncontracted 401/403 body silently became "not 401/403" (503) and never
// triggered invalidation.
test("passes the real HTTP status through for an uncontracted status instead of hardcoding 503", async () => {
server.use(
http.post(`${BASE}/api/v1/studio/assets`, () =>
HttpResponse.json({ message: "token rejected" }, { status: 401 }),
),
);
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "STUDIO_UNAVAILABLE");
assert.equal(error.status, 401);
return true;
});
});
test("an uncontracted 401 body still invalidates the cached CSRF token end to end", async () => {
server.use(
http.post(`${BASE}/api/v1/studio/assets`, () =>
HttpResponse.json({ message: "token rejected" }, { status: 401 }),
),
);
let executions = 0;
const csrf = createCsrfTokenProvider({
async execute() {
executions += 1;
return { csrfToken: `csrf-${executions}`, csrfHeaderName: "X-CSRF-TOKEN" };
},
});
const gateway = createHttpStudioAssetGateway({
operations: {
async execute() {
throw new Error("not used by this test");
},
},
csrf,
upload: transport(),
});
await assert.rejects(
gateway.uploadAsset(
{ file: svg(), kind: "IMAGE" },
{ idempotencyKey: "up-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 — proving the gateway actually saw status
// 401, not the transport's old default of 503.
await csrf.token();
assert.equal(executions, 2);
});
@@ -7,6 +7,10 @@ import {
TECH_LOG_STUDIO_OPERATION_IDS,
} from "../../../src/features/tech-log/contracts/tech-log-studio-contract-contribution.ts";
import { composeContractContributions } from "../../../src/contracts/external-contract-runtime.ts";
import {
assertExactlyOneTechLogStudioBootstrapOperation,
TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID,
} from "../../../src/features/tech-log/adapters/http/studio-session-credentials.ts";
const UPLOAD = "uploadStudioAsset";
@@ -62,3 +66,40 @@ test("path templates match the canonical /api/v1/studio prefix", () => {
);
}
});
// Fix round 2, item 3. `getStudioSession` must be the sole operation on the
// credential-free bootstrap profile — anything else either reopens the C1
// cycle (zero operations) or lets a mutating operation dispatch with no CSRF
// header at all (a second operation on the bootstrap profile).
test("getStudioSession is the sole operation on the bootstrap auth profile", () => {
const bootstrapOperations = TECH_LOG_STUDIO_CONTRIBUTION.http.filter(
(entry) => entry.frontend.authProfileId === TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID,
);
assert.deepEqual(
bootstrapOperations.map((entry) => entry.contract.operationId),
["getStudioSession"],
);
});
test("assertExactlyOneTechLogStudioBootstrapOperation rejects zero or multiple bootstrap operations", () => {
assert.throws(
() => assertExactlyOneTechLogStudioBootstrapOperation([
{ operationId: "getStudioDashboard", authProfileId: "TECH_LOG_STUDIO_SESSION" },
]),
/must be used by exactly one operation/,
);
assert.throws(
() => assertExactlyOneTechLogStudioBootstrapOperation([
{ operationId: "getStudioSession", authProfileId: TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID },
{ operationId: "deleteStudioAsset", authProfileId: TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID },
]),
/must be used by exactly one operation/,
);
assert.doesNotThrow(() =>
assertExactlyOneTechLogStudioBootstrapOperation([
{ operationId: "getStudioSession", authProfileId: TECH_LOG_STUDIO_BOOTSTRAP_AUTH_PROFILE_ID },
{ operationId: "getStudioDashboard", authProfileId: "TECH_LOG_STUDIO_SESSION" },
]),
);
});
@@ -9,7 +9,10 @@ import { INSTALLED_REST_AUTH_PROFILES } from "../../../src/contracts/rest-profil
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 {
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";
@@ -102,12 +105,17 @@ function composeStudioRuntime() {
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, {
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;
},
});
@@ -182,3 +190,67 @@ test(
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);
},
);