Files
tech-log-frontend/tests/features/tech-log/asset-upload-transport.test.ts
T
DongHyeonkaandClaude Opus 5 35cc5c868a 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>
2026-08-18 03:25:29 +09:00

272 lines
9.1 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 { 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";
const server = setupServer();
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
const transport = () =>
createAssetUploadTransport({ baseUrl: `${BASE}/`, timeoutMs: 10_000 });
const svg = () => new File(["<svg/>"], "b.svg", { type: "image/svg+xml" });
test("posts multipart form data with the supplied headers", async () => {
let seen: { kind: unknown; alt: unknown; csrf: string | null; key: string | null } | null = null;
server.use(
http.post(`${BASE}/api/v1/studio/assets`, async ({ request }) => {
const form = await request.formData();
seen = {
kind: form.get("kind"),
alt: form.get("altText"),
csrf: request.headers.get("X-CSRF-TOKEN"),
key: request.headers.get("Idempotency-Key"),
};
return HttpResponse.json({ id: "a", managementStatus: "READY" }, { status: 201 });
}),
);
const asset = await transport().upload(
{ file: svg(), kind: "DIAGRAM", altText: "경계 다이어그램", decorative: false },
{ "X-CSRF-TOKEN": "csrf", "Idempotency-Key": "up-1" },
);
assert.equal((asset as { id: string }).id, "a");
assert.equal(seen!.kind, "DIAGRAM");
assert.equal(seen!.alt, "경계 다이어그램");
assert.equal(seen!.csrf, "csrf");
assert.equal(seen!.key, "up-1");
});
test("does not set content-type itself so the boundary survives", async () => {
let contentType: string | null = "unset";
server.use(
http.post(`${BASE}/api/v1/studio/assets`, ({ request }) => {
contentType = request.headers.get("content-type");
return HttpResponse.json({ id: "a" }, { status: 201 });
}),
);
await transport().upload({ file: svg(), kind: "IMAGE" }, {});
assert.ok(contentType?.startsWith("multipart/form-data; boundary="));
});
test("maps 413 onto PAYLOAD_TOO_LARGE", async () => {
server.use(
http.post(`${BASE}/api/v1/studio/assets`, () =>
HttpResponse.json(
{
type: "https://techlog.local/problems/payload-too-large",
title: "PAYLOAD_TOO_LARGE",
status: 413,
detail: "파일이 너무 큽니다.",
code: "PAYLOAD_TOO_LARGE",
},
{ status: 413, headers: { "content-type": "application/problem+json" } },
),
),
);
await assert.rejects(
transport().upload({ file: svg(), kind: "IMAGE" }, {}),
(error: unknown) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "PAYLOAD_TOO_LARGE");
return true;
},
);
});
test("maps 415 onto UNSUPPORTED_MEDIA_TYPE", async () => {
server.use(
http.post(`${BASE}/api/v1/studio/assets`, () =>
HttpResponse.json(
{
type: "https://techlog.local/problems/unsupported-media-type",
title: "UNSUPPORTED_MEDIA_TYPE",
status: 415,
detail: "지원하지 않는 형식입니다.",
code: "UNSUPPORTED_MEDIA_TYPE",
},
{ status: 415, headers: { "content-type": "application/problem+json" } },
),
),
);
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "UNSUPPORTED_MEDIA_TYPE");
return true;
});
});
test("maps a network failure onto STUDIO_UNAVAILABLE", async () => {
server.use(http.post(`${BASE}/api/v1/studio/assets`, () => HttpResponse.error()));
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "STUDIO_UNAVAILABLE");
return true;
});
});
// M2 (fix round 1). The two "don't invent a domain error" fallback branches
// had no test coverage — the code was already correct, but nothing pinned it.
test("falls back to STUDIO_UNAVAILABLE for an uncontracted problem code instead of inventing one", async () => {
server.use(
http.post(`${BASE}/api/v1/studio/assets`, () =>
HttpResponse.json(
{
type: "https://techlog.local/problems/teapot",
title: "IM_A_TEAPOT",
status: 418,
detail: "이 서버는 커피를 내릴 수 없습니다.",
code: "IM_A_TEAPOT",
},
{ status: 418, headers: { "content-type": "application/problem+json" } },
),
),
);
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "STUDIO_UNAVAILABLE");
return true;
});
});
test("falls back to STUDIO_UNAVAILABLE when the error body cannot be parsed as JSON", async () => {
server.use(
http.post(
`${BASE}/api/v1/studio/assets`,
() => new HttpResponse("<html>not json</html>", { status: 500 }),
),
);
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "STUDIO_UNAVAILABLE");
return true;
});
});
// M1 (fix round 1). A malformed 201 body must not throw a raw SyntaxError out
// of a port whose contract is StudioGatewayError.
test("falls back to STUDIO_UNAVAILABLE when a success body cannot be parsed as JSON", async () => {
server.use(
http.post(
`${BASE}/api/v1/studio/assets`,
() => new HttpResponse("not json", { status: 201 }),
),
);
await assert.rejects(transport().upload({ file: svg(), kind: "IMAGE" }, {}), (error: unknown) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "STUDIO_UNAVAILABLE");
return true;
});
});
// M3 (fix round 1). A cancelled/deadline-exceeded upload must read the same
// as the JSON path's CANCELLED mapping: not retryable.
test("maps an aborted upload onto a non-retryable STUDIO_UNAVAILABLE", async () => {
server.use(
http.post(`${BASE}/api/v1/studio/assets`, async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return HttpResponse.json({ id: "a" }, { status: 201 });
}),
);
const controller = new AbortController();
const pending = transport().upload(
{ file: svg(), kind: "IMAGE" },
{},
{ signal: controller.signal },
);
controller.abort();
await assert.rejects(pending, (error: unknown) => {
assert.ok(isStudioGatewayError(error));
assert.equal(error.code, "STUDIO_UNAVAILABLE");
assert.equal(error.status, 499);
assert.equal(error.retryable, false);
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);
});