Files
tech-log-frontend/tests/features/tech-log/asset-upload-transport.test.ts
T
DongHyeonkaandClaude Opus 5 2cab4974b7 fix: break the TechLog CSRF bootstrap cycle and close the review's fix-round-1 items
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>
2026-08-18 03:07:02 +09:00

208 lines
6.9 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 { 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;
});
});