`contractOperations.execute` is the single executor every installed feature dispatches through, and it called `invalidateTechLogCsrfOnOutcome` for every operation. A 403 on an unrelated reference-feature request therefore threw away a perfectly good TechLog CSRF token, forcing an avoidable `getStudioSession` round trip on the next Studio operation -- and, when the session endpoint is itself unhealthy, turning someone else's authorization failure into a Studio outage. `invalidateTechLogCsrfOnOutcome` now takes the operation's auth profile and acts only on the two TechLog Studio profiles. Required, not optional, so the scoping cannot be dropped again by omission, and the predicate lives in the feature file: `bootstrap/runtime-adapters.ts` is template-synced and its change is the one added argument. The composition test now installs both contributions the way `installed-contract-contributions.ts` does, and asserts a reference-feature 403 leaves the cached token alone. Reverting the scope check fails exactly that test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
347 lines
13 KiB
TypeScript
347 lines
13 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,
|
|
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";
|
|
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "../../../src/features/reference-feature/contracts/reference-feature-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() {
|
|
// Both contributions, exactly as `installed-contract-contributions.ts`
|
|
// composes them: `contractOperations.execute` is one executor shared by
|
|
// every installed feature, so a reference-feature operation's outcome
|
|
// travels through the same code path a Studio operation's does.
|
|
const composed = composeContractContributions([
|
|
TECH_LOG_STUDIO_CONTRIBUTION,
|
|
REFERENCE_FEATURE_TEMPLATE_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,
|
|
);
|
|
// `attachStudioSessionCredentials` returns null for a non-Studio
|
|
// profile so the caller falls through to its own credential logic --
|
|
// the bearer path in the composition root, this stand-in here.
|
|
return (
|
|
outcome ??
|
|
Object.freeze({
|
|
kind: "READY" as const,
|
|
headers: Object.freeze({ authorization: "Bearer reference-token" }),
|
|
})
|
|
);
|
|
},
|
|
});
|
|
|
|
contractOperations = Object.freeze({
|
|
async execute(operationId, input, executionContext) {
|
|
const operation = composed.httpByOperationId.get(operationId);
|
|
if (!operation) throw new Error(`no such operation: ${operationId}`);
|
|
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. Final fix wave item 8 added the auth
|
|
// profile: this executor serves every installed feature, so the call
|
|
// has to be told which one produced the outcome.
|
|
invalidateTechLogCsrfOnOutcome(
|
|
outcome.kind,
|
|
operation.frontend.authProfileId,
|
|
techLogCsrf,
|
|
);
|
|
return outcome;
|
|
},
|
|
});
|
|
|
|
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);
|
|
},
|
|
);
|
|
|
|
/**
|
|
* 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);
|
|
},
|
|
);
|
|
|
|
/**
|
|
* Final fix wave, item 8. `contractOperations.execute` is the single executor
|
|
* every installed feature dispatches through, and it called
|
|
* `invalidateTechLogCsrfOnOutcome` for *every* operation. A 403 on an
|
|
* unrelated reference-feature request therefore discarded the TechLog CSRF
|
|
* token, forcing an avoidable `getStudioSession` round trip on the next
|
|
* Studio operation -- and, when the session endpoint is itself unhealthy,
|
|
* turning someone else's authorization failure into a Studio outage.
|
|
*/
|
|
test(
|
|
"a 403 on a non-Studio operation leaves the TechLog CSRF token cached",
|
|
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"));
|
|
return HttpResponse.json({ documentTotals: {}, workflowSections: [] });
|
|
}),
|
|
);
|
|
server.use(
|
|
http.get(`${BASE}/api/reference-resources`, () => new HttpResponse(null, { status: 403 })),
|
|
);
|
|
|
|
const { contractOperations } = composeStudioRuntime();
|
|
|
|
const first = await contractOperations.execute(
|
|
"getStudioDashboard",
|
|
{},
|
|
{ routeId: "TECH_LOG_STUDIO" },
|
|
);
|
|
assert.equal(first.kind, "SUCCESS");
|
|
assert.equal(dashboardHeaders[0], "csrf-token-1");
|
|
|
|
// Someone else's 403, on a feature that has nothing to do with the Studio
|
|
// session.
|
|
const rejected = await contractOperations.execute(
|
|
"LIST_REFERENCE_RESOURCES",
|
|
{ limit: 10 },
|
|
{ routeId: "REFERENCE_FEATURE" },
|
|
);
|
|
assert.equal(rejected.kind, "FORBIDDEN");
|
|
|
|
const second = await contractOperations.execute(
|
|
"getStudioDashboard",
|
|
{},
|
|
{ routeId: "TECH_LOG_STUDIO" },
|
|
);
|
|
assert.equal(second.kind, "SUCCESS");
|
|
assert.equal(dashboardHeaders[1], "csrf-token-1");
|
|
assert.equal(sessionCalls, 1);
|
|
},
|
|
);
|