From 7381be1477692ef29d772de8eccdd3763e45ffd7 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Tue, 18 Aug 2026 00:44:37 +0900 Subject: [PATCH] feat: add TechLog Studio error mapping and CSRF token provider --- .../adapters/http/studio-error-mapping.ts | 87 ++++++++++++++++++ .../adapters/http/studio-session-csrf.ts | 55 +++++++++++ .../tech-log/studio-error-mapping.test.ts | 91 +++++++++++++++++++ 3 files changed, 233 insertions(+) create mode 100644 src/features/tech-log/adapters/http/studio-error-mapping.ts create mode 100644 src/features/tech-log/adapters/http/studio-session-csrf.ts create mode 100644 tests/features/tech-log/studio-error-mapping.test.ts diff --git a/src/features/tech-log/adapters/http/studio-error-mapping.ts b/src/features/tech-log/adapters/http/studio-error-mapping.ts new file mode 100644 index 0000000..e34fb8c --- /dev/null +++ b/src/features/tech-log/adapters/http/studio-error-mapping.ts @@ -0,0 +1,87 @@ +import type { HttpExecutionOutcome } from "../../../../adapters/http/http-execution-v3.ts"; +import { StudioGatewayError } from "../../application/ports/studio-gateway-error.ts"; +import type { ProblemDetails } from "../../contracts/studio/contract.ts"; + +/** canonical studio-v1.yaml `ProblemDetails.code` enum과 1:1이다. */ +export const STUDIO_ERROR_CODES = Object.freeze([ + "AUTHENTICATION_REQUIRED", + "STUDIO_ACCESS_DENIED", + "DOCUMENT_NOT_FOUND", + "VERSION_CONFLICT", + "REQUEST_VALIDATION_FAILED", + "VALIDATION_FAILED", + "VALIDATION_STALE", + "PREVIEW_NOT_FOUND", + "PREVIEW_STALE", + "PREVIEW_EXPIRED", + "PUBLICATION_NOT_FOUND", + "PUBLICATION_CONFLICT", + "PUBLICATION_EVENT_NOT_FOUND", + "PUBLICATION_SNAPSHOT_NOT_FOUND", + "WARNING_ACKNOWLEDGEMENT_REQUIRED", + "IDEMPOTENCY_KEY_REUSED", + "ASSET_NOT_FOUND", + "ASSET_NOT_READY", + "ASSET_IN_USE", + "ASSET_QUARANTINED", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "STUDIO_UNAVAILABLE", +]) as readonly ProblemDetails["code"][]; + +const CODES = new Set(STUDIO_ERROR_CODES); + +function synthetic( + code: ProblemDetails["code"], + status: number, + detail: string, + retryable: boolean, +): StudioGatewayError { + return new StudioGatewayError({ + type: `https://techlog.local/problems/${code.toLowerCase().replaceAll("_", "-")}`, + title: code, + status, + detail, + code, + retryable, + }); +} + +/** + * 서버가 계약 밖 코드를 보내면 도메인 코드를 지어내지 않는다. 전송 계층 + * 실패와 마찬가지로 `STUDIO_UNAVAILABLE`로 접는다. + */ +export function toStudioGatewayError( + outcome: HttpExecutionOutcome, + operationId: string, +): StudioGatewayError { + switch (outcome.kind) { + case "PROBLEM": { + const problem = outcome.problem as ProblemDetails; + if (problem && typeof problem.code === "string" && CODES.has(problem.code)) { + return new StudioGatewayError(problem); + } + return synthetic( + "STUDIO_UNAVAILABLE", + outcome.metadata.status, + `${operationId} returned an uncontracted problem code.`, + false, + ); + } + case "UNAUTHENTICATED": + return synthetic("AUTHENTICATION_REQUIRED", 401, `${operationId} requires authentication.`, false); + case "FORBIDDEN": + return synthetic("STUDIO_ACCESS_DENIED", 403, `${operationId} was denied.`, false); + case "CANCELLED": + return synthetic("STUDIO_UNAVAILABLE", 499, `${operationId} was cancelled.`, false); + case "RATE_LIMITED": + return synthetic("STUDIO_UNAVAILABLE", 429, `${operationId} was rate limited.`, true); + case "TRANSPORT_FAILURE": + return synthetic("STUDIO_UNAVAILABLE", 503, `${operationId} transport failed.`, true); + case "AUTH_INTEGRATION_FAILURE": + case "CONTRACT_VIOLATION": + return synthetic("STUDIO_UNAVAILABLE", 502, `${operationId} broke its contract.`, false); + case "SUCCESS": + throw new Error(`${operationId}: success outcome is not an error`); + } +} diff --git a/src/features/tech-log/adapters/http/studio-session-csrf.ts b/src/features/tech-log/adapters/http/studio-session-csrf.ts new file mode 100644 index 0000000..bf5ffae --- /dev/null +++ b/src/features/tech-log/adapters/http/studio-session-csrf.ts @@ -0,0 +1,55 @@ +export type StudioSessionSnapshot = Readonly<{ + csrfToken: string; + csrfHeaderName: string; +}>; + +export type CsrfSessionExecutor = ( + options?: Readonly<{ signal?: AbortSignal }>, +) => Promise; + +export type CsrfTokenProvider = Readonly<{ + token(options?: Readonly<{ signal?: AbortSignal }>): Promise; + headerName(options?: Readonly<{ signal?: AbortSignal }>): Promise; + invalidate(): void; +}>; + +/** + * CSRF는 전송 관심사다. UI는 토큰을 보지 않으므로 포트로 노출하지 않고 + * 어댑터 내부에서 캐시한다. 동시 요청은 하나의 in-flight 조회를 공유한다. + */ +export function createCsrfTokenProvider( + deps: Readonly<{ execute: CsrfSessionExecutor }>, +): CsrfTokenProvider { + let cached: StudioSessionSnapshot | null = null; + let inFlight: Promise | null = null; + + async function resolve( + options?: Readonly<{ signal?: AbortSignal }>, + ): Promise { + if (cached) return cached; + inFlight ??= deps.execute(options).then( + (snapshot) => { + cached = snapshot; + inFlight = null; + return snapshot; + }, + (error: unknown) => { + inFlight = null; + throw error; + }, + ); + return inFlight; + } + + return Object.freeze({ + async token(options) { + return (await resolve(options)).csrfToken; + }, + async headerName(options) { + return (await resolve(options)).csrfHeaderName; + }, + invalidate() { + cached = null; + }, + }); +} diff --git a/tests/features/tech-log/studio-error-mapping.test.ts b/tests/features/tech-log/studio-error-mapping.test.ts new file mode 100644 index 0000000..b106b49 --- /dev/null +++ b/tests/features/tech-log/studio-error-mapping.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; + +import { + STUDIO_ERROR_CODES, + toStudioGatewayError, +} from "../../../src/features/tech-log/adapters/http/studio-error-mapping.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"; + +test("covers every canonical error code exactly once", () => { + assert.equal(STUDIO_ERROR_CODES.length, 23); + assert.equal(new Set(STUDIO_ERROR_CODES).size, 23); + for (const code of ["IDEMPOTENCY_KEY_REUSED", "WARNING_ACKNOWLEDGEMENT_REQUIRED", "ASSET_QUARANTINED"]) { + assert.ok(STUDIO_ERROR_CODES.includes(code as never), `${code} is missing`); + } +}); + +test("maps a PROBLEM outcome onto the port error, preserving the code", () => { + const error = toStudioGatewayError( + { + kind: "PROBLEM", + problem: { + type: "https://techlog.local/problems/version-conflict", + title: "VERSION_CONFLICT", + status: 409, + detail: "Expected 3; current 4.", + code: "VERSION_CONFLICT", + }, + metadata: { status: 409 }, + effect: "NOT_APPLIED", + } as never, + "saveStudioDocument", + ); + + assert.ok(isStudioGatewayError(error)); + assert.equal(error.code, "VERSION_CONFLICT"); + assert.equal(error.status, 409); +}); + +test("maps a transport failure onto STUDIO_UNAVAILABLE without inventing a domain code", () => { + const error = toStudioGatewayError( + { kind: "TRANSPORT_FAILURE", failure: { kind: "TIMEOUT" }, effect: "MAYBE_APPLIED" } as never, + "getStudioDashboard", + ); + + assert.equal(error.code, "STUDIO_UNAVAILABLE"); + assert.equal(error.retryable, true); +}); + +test("maps UNAUTHENTICATED onto AUTHENTICATION_REQUIRED", () => { + const error = toStudioGatewayError( + { kind: "UNAUTHENTICATED", effect: "NOT_APPLIED" } as never, + "getStudioDashboard", + ); + + assert.equal(error.code, "AUTHENTICATION_REQUIRED"); + assert.equal(error.status, 401); +}); + +test("fetches the CSRF token once and reuses it until invalidated", async () => { + let calls = 0; + const provider = createCsrfTokenProvider({ + async execute() { + calls += 1; + return { csrfToken: `token-${calls}`, csrfHeaderName: "X-CSRF-TOKEN" }; + }, + }); + + assert.equal(await provider.token(), "token-1"); + assert.equal(await provider.token(), "token-1"); + assert.equal(calls, 1); + + provider.invalidate(); + assert.equal(await provider.token(), "token-2"); + assert.equal(calls, 2); +}); + +test("does not stampede concurrent CSRF requests", async () => { + let calls = 0; + const provider = createCsrfTokenProvider({ + async execute() { + calls += 1; + await Promise.resolve(); + return { csrfToken: "token", csrfHeaderName: "X-CSRF-TOKEN" }; + }, + }); + + await Promise.all([provider.token(), provider.token(), provider.token()]); + assert.equal(calls, 1); +});