feat: add TechLog Studio error mapping and CSRF token provider
This commit is contained in:
@@ -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<string>(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<unknown, unknown>,
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
export type StudioSessionSnapshot = Readonly<{
|
||||||
|
csrfToken: string;
|
||||||
|
csrfHeaderName: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type CsrfSessionExecutor = (
|
||||||
|
options?: Readonly<{ signal?: AbortSignal }>,
|
||||||
|
) => Promise<StudioSessionSnapshot>;
|
||||||
|
|
||||||
|
export type CsrfTokenProvider = Readonly<{
|
||||||
|
token(options?: Readonly<{ signal?: AbortSignal }>): Promise<string>;
|
||||||
|
headerName(options?: Readonly<{ signal?: AbortSignal }>): Promise<string>;
|
||||||
|
invalidate(): void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF는 전송 관심사다. UI는 토큰을 보지 않으므로 포트로 노출하지 않고
|
||||||
|
* 어댑터 내부에서 캐시한다. 동시 요청은 하나의 in-flight 조회를 공유한다.
|
||||||
|
*/
|
||||||
|
export function createCsrfTokenProvider(
|
||||||
|
deps: Readonly<{ execute: CsrfSessionExecutor }>,
|
||||||
|
): CsrfTokenProvider {
|
||||||
|
let cached: StudioSessionSnapshot | null = null;
|
||||||
|
let inFlight: Promise<StudioSessionSnapshot> | null = null;
|
||||||
|
|
||||||
|
async function resolve(
|
||||||
|
options?: Readonly<{ signal?: AbortSignal }>,
|
||||||
|
): Promise<StudioSessionSnapshot> {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user