Files
tech-log-frontend/tests/features/tech-log/studio-csrf-composition.test.ts
DongHyeonkaandClaude Opus 5 25a6b63d27 feat: Studio 응답 봉투를 전송 경계에서 언랩한다
studio-v1.yaml v3.0.0(ADR-006)에 맞춰 계약을 재생성하고, 성공은
{success,data,meta}, 실패는 {success,error,meta} 봉투를 전송 경계에서
언랩하는 envelopeData/envelopeError validator를 도입한다. 앱·도메인
계층은 기존과 같은 payload/ProblemDetails 모양을 계속 받고,
StudioGateway 포트 시그니처는 무변경이다.

- tech-log-studio-contract-contribution.ts: envelopeData/envelopeError
  도입, 18개 operation의 outputValidator를 passthrough에서 envelopeData로
  교체
- studio-error-mapping.ts: 봉투 오류의 status(항상 0)를
  outcome.metadata.status로 덮는다. SafeResponseMetadata.status가
  실제 필드명이며(httpStatus 아님) PROBLEM outcome에서 필수 필드다
- contract.ts: 삭제된 ProblemDetails 생성 스키마를 손으로 유지 — 앱
  계층·mock 게이트웨이가 그 모양을 계속 소비한다
- asset-upload-transport.ts: multipart 업로드는 일반 계약 런타임을
  거치지 않는 별도 seam이지만 같은 wire 봉투를 쓴다 — envelopeData/
  envelopeError를 재사용해 이 경로도 언랩한다 (브리프 파일 목록 밖의
  발견, report에 기록)
- 테스트: 신규 studio-envelope-unwrap.test.ts(TDD) + 봉투 뼈대를 직접
  만드는 기존 테스트(asset-upload-transport, studio-csrf-composition,
  contract-generation)를 봉투 형태로 갱신

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 21:55:45 +09:00

363 lines
14 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());
// wire format은 봉투다 (ADR-006) — 이 파일은 실제 platform 계약 런타임
// (`createContractHttpExecutor`)을 조립하므로 `outputValidator`
// (`envelopeData`)가 그대로 걸린다. 봉투가 아닌 본문은 SUCCESS_SCHEMA_INVALID로
// 거절된다.
const dataEnvelope = (data: unknown) => ({
success: true,
data,
meta: { requestId: "r", traceId: "t", correlationId: null, page: null },
});
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(
dataEnvelope({
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(dataEnvelope({ 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(
dataEnvelope({ 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(
dataEnvelope({
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(dataEnvelope({ 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(
dataEnvelope({
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(dataEnvelope({ 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);
},
);