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>
257 lines
7.8 KiB
TypeScript
257 lines
7.8 KiB
TypeScript
import assert from "node:assert/strict";
|
|
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,
|
|
StudioGatewayError,
|
|
} from "../../../src/features/tech-log/application/ports/studio-gateway-error.ts";
|
|
|
|
const READY_ASSET = {
|
|
id: "11111111-1111-4111-8111-111111111111",
|
|
assetKey: "fetch-strategy-boundary",
|
|
kind: "DIAGRAM",
|
|
mediaType: "image/svg+xml",
|
|
originalFilename: "boundary.svg",
|
|
byteSize: 4096,
|
|
width: 1080,
|
|
height: 420,
|
|
altText: "Fetch Join과 Batch Fetch 비교",
|
|
decorative: false,
|
|
managementStatus: "READY",
|
|
publicPath: "/media/fetch-strategy-boundary.svg",
|
|
usageCount: 1,
|
|
version: 1,
|
|
createdAt: "2026-08-14T01:00:00.000Z",
|
|
updatedAt: "2026-08-14T01:00:00.000Z",
|
|
};
|
|
|
|
function deps(outcomes: Record<string, unknown>) {
|
|
const calls: { operationId: string; input: unknown }[] = [];
|
|
return {
|
|
calls,
|
|
dependencies: {
|
|
operations: {
|
|
async execute(operationId: string, input: unknown) {
|
|
calls.push({ operationId, input });
|
|
const outcome = outcomes[operationId];
|
|
if (!outcome) throw new Error(`no outcome for ${operationId}`);
|
|
return outcome as never;
|
|
},
|
|
},
|
|
csrf: createCsrfTokenProvider({
|
|
async execute() {
|
|
return { csrfToken: "csrf", csrfHeaderName: "X-CSRF-TOKEN" };
|
|
},
|
|
}),
|
|
upload: {
|
|
async upload() {
|
|
return READY_ASSET as never;
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
test("lists assets through the canonical operation", async () => {
|
|
const { calls, dependencies } = deps({
|
|
listStudioAssets: {
|
|
kind: "SUCCESS",
|
|
value: { items: [READY_ASSET], nextCursor: null },
|
|
effect: "APPLIED_CONFIRMED",
|
|
},
|
|
});
|
|
const gateway = createHttpStudioAssetGateway(dependencies as never);
|
|
|
|
const page = await gateway.listAssets({ kind: "DIAGRAM", limit: 20 });
|
|
|
|
assert.equal(page.items.length, 1);
|
|
assert.equal(calls[0]!.operationId, "listStudioAssets");
|
|
});
|
|
|
|
test("gets a single asset detail through the canonical operation", async () => {
|
|
const detail = {
|
|
asset: READY_ASSET,
|
|
usages: [
|
|
{
|
|
documentId: "22222222-2222-4222-8222-222222222222",
|
|
documentTitle: "Fetch Join과 Batch Fetch 비교",
|
|
},
|
|
],
|
|
hasPublicationHistory: true,
|
|
};
|
|
const { calls, dependencies } = deps({
|
|
getStudioAsset: {
|
|
kind: "SUCCESS",
|
|
value: detail,
|
|
effect: "APPLIED_CONFIRMED",
|
|
},
|
|
});
|
|
const gateway = createHttpStudioAssetGateway(dependencies as never);
|
|
|
|
const result = await gateway.getAsset(READY_ASSET.id);
|
|
|
|
assert.equal(calls[0]!.operationId, "getStudioAsset");
|
|
assert.deepEqual(calls[0]!.input, { assetId: READY_ASSET.id });
|
|
assert.deepEqual(result, detail);
|
|
});
|
|
|
|
test("delegates upload to the transport with CSRF and idempotency headers", async () => {
|
|
let received: Record<string, string> = {};
|
|
const { dependencies } = deps({});
|
|
const gateway = createHttpStudioAssetGateway({
|
|
...dependencies,
|
|
upload: {
|
|
async upload(_form: unknown, headers: Record<string, string>) {
|
|
received = headers;
|
|
return READY_ASSET as never;
|
|
},
|
|
},
|
|
} as never);
|
|
|
|
const asset = await gateway.uploadAsset(
|
|
{ file: new File(["<svg/>"], "boundary.svg", { type: "image/svg+xml" }), kind: "DIAGRAM" },
|
|
{ idempotencyKey: "upload-1" },
|
|
);
|
|
|
|
assert.equal(asset.managementStatus, "READY");
|
|
assert.equal(received["X-CSRF-TOKEN"], "csrf");
|
|
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: {
|
|
kind: "PROBLEM",
|
|
problem: {
|
|
type: "https://techlog.local/problems/asset-in-use",
|
|
title: "ASSET_IN_USE",
|
|
status: 409,
|
|
detail: "사용 중인 Asset은 삭제할 수 없습니다.",
|
|
code: "ASSET_IN_USE",
|
|
},
|
|
metadata: { status: 409 },
|
|
effect: "NOT_APPLIED",
|
|
},
|
|
});
|
|
const gateway = createHttpStudioAssetGateway(dependencies as never);
|
|
|
|
await assert.rejects(
|
|
gateway.deleteAsset(READY_ASSET.id, { idempotencyKey: "delete-1" }),
|
|
(error: unknown) => {
|
|
assert.ok(isStudioGatewayError(error));
|
|
assert.equal(error.code, "ASSET_IN_USE");
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test("sends expectedVersion when updating metadata", async () => {
|
|
const { calls, dependencies } = deps({
|
|
updateStudioAsset: {
|
|
kind: "SUCCESS",
|
|
value: { ...READY_ASSET, version: 2, decorative: true, altText: null },
|
|
effect: "APPLIED_CONFIRMED",
|
|
},
|
|
});
|
|
const gateway = createHttpStudioAssetGateway(dependencies as never);
|
|
|
|
const updated = await gateway.updateAssetMetadata(
|
|
READY_ASSET.id,
|
|
{ expectedVersion: 1, decorative: true, altText: null },
|
|
{ idempotencyKey: "update-1" },
|
|
);
|
|
|
|
assert.equal(updated.version, 2);
|
|
const input = calls[0]!.input as Record<string, unknown>;
|
|
assert.equal(input["expectedVersion"], 1);
|
|
assert.equal(input["assetId"], READY_ASSET.id);
|
|
});
|