216 lines
6.7 KiB
TypeScript
216 lines
6.7 KiB
TypeScript
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("falls back to STUDIO_UNAVAILABLE and drops an uncontracted problem code", () => {
|
|
const error = toStudioGatewayError(
|
|
{
|
|
kind: "PROBLEM",
|
|
problem: {
|
|
type: "https://techlog.local/problems/bogus",
|
|
title: "BOGUS",
|
|
status: 418,
|
|
detail: "server sent a code outside the contract",
|
|
code: "TOTALLY_MADE_UP_CODE",
|
|
},
|
|
metadata: { status: 418 },
|
|
effect: "NOT_APPLIED",
|
|
} as never,
|
|
"saveStudioDocument",
|
|
);
|
|
|
|
assert.equal(error.code, "STUDIO_UNAVAILABLE");
|
|
assert.equal(error.status, 418);
|
|
// The unrecognised code must not survive into the resulting error anywhere,
|
|
// not just be absent from `.code` — otherwise a caller reading `.problem`
|
|
// could still observe it.
|
|
assert.ok(
|
|
!JSON.stringify(error.problem).includes("TOTALLY_MADE_UP_CODE"),
|
|
"the bogus code leaked into the synthesized problem",
|
|
);
|
|
});
|
|
|
|
test("maps FORBIDDEN onto STUDIO_ACCESS_DENIED", () => {
|
|
const error = toStudioGatewayError(
|
|
{ kind: "FORBIDDEN", effect: "NOT_APPLIED" } as never,
|
|
"getStudioDashboard",
|
|
);
|
|
|
|
assert.equal(error.code, "STUDIO_ACCESS_DENIED");
|
|
assert.equal(error.status, 403);
|
|
assert.equal(error.retryable, false);
|
|
});
|
|
|
|
test("maps RATE_LIMITED onto a retryable STUDIO_UNAVAILABLE", () => {
|
|
const error = toStudioGatewayError(
|
|
{ kind: "RATE_LIMITED", effect: "NOT_APPLIED" } as never,
|
|
"getStudioDashboard",
|
|
);
|
|
|
|
assert.equal(error.code, "STUDIO_UNAVAILABLE");
|
|
assert.equal(error.status, 429);
|
|
assert.equal(error.retryable, true);
|
|
});
|
|
|
|
test("maps CANCELLED onto a non-retryable STUDIO_UNAVAILABLE", () => {
|
|
const error = toStudioGatewayError(
|
|
{ kind: "CANCELLED", effect: "NOT_STARTED" } as never,
|
|
"getStudioDashboard",
|
|
);
|
|
|
|
assert.equal(error.code, "STUDIO_UNAVAILABLE");
|
|
assert.equal(error.status, 499);
|
|
assert.equal(error.retryable, false);
|
|
});
|
|
|
|
test("maps CONTRACT_VIOLATION and AUTH_INTEGRATION_FAILURE onto the same non-retryable STUDIO_UNAVAILABLE", () => {
|
|
const contractViolation = toStudioGatewayError(
|
|
{
|
|
kind: "CONTRACT_VIOLATION",
|
|
violation: { kind: "UNEXPECTED_STATUS", operation: "RESPONSE" },
|
|
effect: "NOT_APPLICABLE",
|
|
} as never,
|
|
"getStudioDashboard",
|
|
);
|
|
const authIntegrationFailure = toStudioGatewayError(
|
|
{ kind: "AUTH_INTEGRATION_FAILURE", reason: "UNKNOWN_AUTH_PROFILE", effect: "NOT_APPLICABLE" } as never,
|
|
"getStudioDashboard",
|
|
);
|
|
|
|
for (const error of [contractViolation, authIntegrationFailure]) {
|
|
assert.equal(error.code, "STUDIO_UNAVAILABLE");
|
|
assert.equal(error.status, 502);
|
|
assert.equal(error.retryable, false);
|
|
}
|
|
});
|
|
|
|
test("throws for a SUCCESS outcome instead of returning a fabricated error", () => {
|
|
assert.throws(
|
|
() =>
|
|
toStudioGatewayError(
|
|
{ kind: "SUCCESS", value: undefined, metadata: { status: 200 }, effect: "NOT_APPLICABLE" } as never,
|
|
"getStudioDashboard",
|
|
),
|
|
/success outcome is not an error/,
|
|
);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
test("resolves the header name from the same cached snapshot as the token", async () => {
|
|
let calls = 0;
|
|
const provider = createCsrfTokenProvider({
|
|
async execute() {
|
|
calls += 1;
|
|
return { csrfToken: "token", csrfHeaderName: "X-CSRF-TOKEN" };
|
|
},
|
|
});
|
|
|
|
assert.equal(await provider.headerName(), "X-CSRF-TOKEN");
|
|
assert.equal(await provider.token(), "token");
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
test("does not cache a rejected CSRF fetch and retries cleanly on the next call", async () => {
|
|
let calls = 0;
|
|
const provider = createCsrfTokenProvider({
|
|
async execute() {
|
|
calls += 1;
|
|
if (calls === 1) throw new Error("network failure");
|
|
return { csrfToken: "token", csrfHeaderName: "X-CSRF-TOKEN" };
|
|
},
|
|
});
|
|
|
|
await assert.rejects(provider.token());
|
|
assert.equal(calls, 1);
|
|
|
|
assert.equal(await provider.token(), "token");
|
|
assert.equal(calls, 2);
|
|
});
|