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>
363 lines
14 KiB
TypeScript
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);
|
|
},
|
|
);
|