Files
clean-architecture-frontend…/tests/integration/http-execution-contract.test.ts
T
DongHyeonkaandClaude Opus 5 df18349682 fix: validate the snapshot that installs, not the object that was shown
Three trust boundaries checked a caller's object and then read it again to
use it. Between those two reads an accessor or a Proxy can answer
differently, so the value that passed validation and the value that was
installed were not the same value.

A credential owner's answer was read field by field outside the auth
boundary: a throwing `kind` getter escaped into the transport catch and an
auth outage reached operators as `NETWORK_FAILURE`. Contract composition
validated a contribution and then copied it, so a policy that answered
10,000 to the ceiling check and 999,999 to the copy installed the second
value. The cursor runtime validated its profile once and re-read it on
every page, so raising `maxPages` after construction widened a cap that
had already been checked.

`src/contracts/exact-snapshot.ts` is the one descriptor-based decoder they
now share: every property is read exactly once, an accessor, a symbol, an
inherited or non-enumerable field and a throwing trap all resolve to a
typed failure, and validation runs on the owned copy.

Separately, the `responseBody: NONE` probe awaited a bare `read()`. The
deadline produced a bounded public result while the raw reader kept its
lease, so the body stayed locked and the outer compensator could not
cancel it. The probe now takes the operation lifetime and owns the cancel
and the lock release itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:25:12 +09:00

395 lines
12 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { createHttpClient } from "../../src/adapters/http/client.ts";
import { createContractHttpExecutor } from "../../src/adapters/http/http-execution-v3.ts";
import { joinMutationEffectCertainty } from "../../src/adapters/http/http-effect-certainty.ts";
import {
entityQueryKeys,
TEST_HTTP_CONTRACT,
} from "../helpers/http-contract-fixture.ts";
import { TEST_CREATE_HTTP_CONTRACT } from "../helpers/external-contract-fixture.ts";
function successResponse(data: unknown) {
return Response.json({
success: true,
data,
meta: { requestId: "request-1", traceId: "trace-1" },
});
}
function failureResponse(status: number) {
return Response.json(
{
success: false,
error: { code: "TEMPORARY" },
meta: { requestId: "request-1", traceId: "trace-1" },
},
{ status },
);
}
function immediateClock() {
return { now: () => 0, sleep: async () => {} };
}
function recordingScheduler() {
const callbacks: Array<() => void> = [];
return {
callbacks,
setTimeout: vi.fn((callback: () => void) => {
callbacks.push(callback);
return callbacks.length - 1;
}),
clearTimeout: vi.fn(),
};
}
type HttpDependencies = Parameters<typeof createHttpClient>[0];
function testClient(options: HttpDependencies) {
return createHttpClient({ ...TEST_HTTP_CONTRACT, ...options });
}
describe("mutation effect certainty lattice", () => {
it.each([
["NOT_STARTED", "NOT_APPLIED", "NOT_APPLIED"],
["NOT_APPLIED", "NOT_STARTED", "NOT_APPLIED"],
["NOT_STARTED", "MAYBE_APPLIED", "MAYBE_APPLIED"],
["MAYBE_APPLIED", "NOT_STARTED", "MAYBE_APPLIED"],
["MAYBE_APPLIED", "NOT_APPLIED", "MAYBE_APPLIED"],
["NOT_APPLIED", "MAYBE_APPLIED", "MAYBE_APPLIED"],
["MAYBE_APPLIED", "APPLIED_CONFIRMED", "APPLIED_CONFIRMED"],
["APPLIED_CONFIRMED", "NOT_STARTED", "APPLIED_CONFIRMED"],
["APPLIED_CONFIRMED", "MAYBE_APPLIED", "APPLIED_CONFIRMED"],
] as const)("joins %s with %s as %s", (current, observed, expected) => {
expect(joinMutationEffectCertainty(current, observed)).toBe(expected);
});
});
describe("HTTP operation execution contract", () => {
it("permits a keyless command intent but rejects an unexpected key before dispatch", async () => {
const attachCredentials = vi.fn(() => ({
kind: "READY" as const,
headers: {},
}));
const observedKeys: Array<string | null> = [];
const fetcher = vi.fn(
async (_input: RequestInfo | URL, init?: RequestInit) => {
observedKeys.push(new Headers(init?.headers).get("Idempotency-Key"));
return Response.json(
{ id: "created", name: "Created" },
{ status: 201 },
);
},
);
const executor = createContractHttpExecutor({
baseUrl: "https://api.example/",
maxRetryAttempts: 0,
attachCredentials,
fetcher,
});
const nonKeyedCommand = {
...TEST_CREATE_HTTP_CONTRACT,
contract: {
...TEST_CREATE_HTTP_CONTRACT.contract,
retrySemantics: "NEVER" as const,
commandRecovery: null,
},
};
const scope = Object.freeze({
generation: 1,
fingerprint: "scope-1",
identities: Object.freeze({}) as never,
signal: new AbortController().signal,
isCurrent: () => true,
});
const intent = Object.freeze({
intentId: "intent-1",
operationId: "TEST_CREATE_ENTITY",
canonicalInputIdentity: "opaque-input-identity",
createdAtMonotonicMs: 1,
});
await expect(
executor.execute(
nonKeyedCommand,
{ name: "created" },
{ routeId: "TEST_ROUTE", scope, intent },
),
).resolves.toMatchObject({ kind: "SUCCESS" });
expect(observedKeys).toEqual([null]);
attachCredentials.mockClear();
fetcher.mockClear();
await expect(
executor.execute(
nonKeyedCommand,
{ name: "created" },
{ routeId: "TEST_ROUTE", scope, intent: { ...intent, idempotencyKey: "unexpected-key" } },
),
).resolves.toMatchObject({
kind: "CONTRACT_VIOLATION",
violation: {
kind: "UNEXPECTED_IDEMPOTENCY_KEY",
operation: "REQUEST",
},
effect: "NOT_STARTED",
});
expect(attachCredentials).not.toHaveBeenCalled();
expect(fetcher).not.toHaveBeenCalled();
});
it("fails auth-required execution before fetch when the session integration is unavailable", async () => {
const fetcher = vi.fn();
const client = testClient({
baseUrl: "https://api.test",
fetcher,
authSession: undefined,
});
await expect(
client.execute({ operationId: "LIST_ENTITIES", routeId: "TEST_ROUTE" }),
).resolves.toMatchObject({
ok: false,
error: { kind: "AUTH_INTEGRATION_FAILURE" },
});
expect(fetcher).not.toHaveBeenCalled();
});
it("rejects an oversized response without materializing its JSON", async () => {
const largeOperation = {
...TEST_HTTP_CONTRACT.getOperation("LIST_ENTITIES"),
maxResponseBytes: 32,
responseMediaTypes: ["application/json"],
successStatuses: [200],
};
const client = testClient({
baseUrl: "https://api.test",
getOperation: () => largeOperation,
fetcher: vi.fn(async () =>
Response.json({
success: true,
data: [{ id: "one", name: "a".repeat(100) }],
meta: { requestId: "request-1", traceId: "trace-1" },
}),
),
});
await expect(client.execute("LIST_ENTITIES")).resolves.toMatchObject({
ok: false,
error: { kind: "RESPONSE_BODY_LIMIT" },
});
});
it("sends parsed search/body values and aligns canonical query identity", async () => {
const requests: Request[] = [];
const scheduler = recordingScheduler();
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
const request = input as Request;
requests.push(request);
if (request.method === "POST") {
return successResponse({ id: "created", name: "Trimmed" });
}
return successResponse([]);
});
const client = testClient({
baseUrl: "https://api.test",
fetcher,
clock: immediateClock(),
scheduler,
});
const filters = { tags: ["open", "new"], cursor: "a/b", limit: 5 };
await client.execute({
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
searchParams: filters,
});
await client.execute({
operationId: "CREATE_ENTITY",
routeId: "TEST_ROUTE",
body: { name: " Trimmed " },
idempotencyKey: "logical-command",
});
expect(requests[0].url).toBe(
"https://api.test/api/entities?cursor=a%2Fb&limit=5&tags=open&tags=new",
);
expect(requests[0].headers.get("Idempotency-Key")).toBeNull();
expect(entityQueryKeys.list(filters).at(-1)).toEqual(filters);
await expect(requests[1].json()).resolves.toEqual({ name: "Trimmed" });
expect(requests[1].headers.get("Idempotency-Key")).toBe("logical-command");
expect(requests[1].url).not.toContain("logical-command");
expect(requests[0].headers.get("X-Correlation-ID")).toBeTruthy();
expect(requests[0].credentials).toBe("same-origin");
expect(requests[0].cache).toBe("no-store");
expect(requests[0].redirect).toBe("error");
expect(scheduler.setTimeout).toHaveBeenCalledTimes(2);
expect(scheduler.clearTimeout).toHaveBeenCalledTimes(2);
});
it("performs no fetch or timer work for invalid request input", async () => {
const fetcher = vi.fn();
const scheduler = recordingScheduler();
const client = testClient({
baseUrl: "https://api.test",
fetcher,
scheduler,
});
await expect(
client.execute({
operationId: "CREATE_ENTITY",
routeId: "TEST_ROUTE",
body: { name: " " },
}),
).resolves.toMatchObject({
ok: false,
error: { kind: "VALIDATION_REJECTED" },
});
expect(fetcher).not.toHaveBeenCalled();
expect(scheduler.setTimeout).not.toHaveBeenCalled();
expect(scheduler.clearTimeout).not.toHaveBeenCalled();
});
it.each([
[0, 1],
[1, 2],
[2, 3],
])(
"applies runtime max retry count %i as %i total attempts",
async (maxRetryAttempts, totalAttempts) => {
const fetcher = vi.fn(async () => failureResponse(503));
const scheduler = recordingScheduler();
const client = testClient({
baseUrl: "https://api.test",
fetcher,
clock: immediateClock(),
scheduler,
maxRetryAttempts,
});
await expect(
client.execute({
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
}),
).resolves.toMatchObject({
ok: false,
error: { kind: "SERVER_FAILURE" },
});
expect(fetcher).toHaveBeenCalledTimes(totalAttempts);
expect(scheduler.setTimeout).toHaveBeenCalledTimes(totalAttempts);
expect(scheduler.clearTimeout).toHaveBeenCalledTimes(totalAttempts);
},
);
it("distinguishes a runtime timeout from caller navigation abort and cleans listeners", async () => {
const scheduler = recordingScheduler();
const fetcher = vi.fn(
(input: RequestInfo | URL) =>
new Promise<Response>((_resolve, reject) => {
(input as Request).signal.addEventListener(
"abort",
() => reject(new DOMException("aborted", "AbortError")),
{ once: true },
);
}),
);
const client = testClient({
baseUrl: "https://api.test",
fetcher,
scheduler,
maxRetryAttempts: 0,
});
const timeoutResult = client.execute({
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
});
// The credential wait is bounded by the same attempt controller, so wait
// until the request is actually in flight before firing the deadline.
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(scheduler.callbacks).toHaveLength(1));
scheduler.callbacks[0]();
await expect(timeoutResult).resolves.toMatchObject({
ok: false,
error: { kind: "REQUEST_TIMEOUT" },
});
const caller = new AbortController();
const add = vi.spyOn(caller.signal, "addEventListener");
const remove = vi.spyOn(caller.signal, "removeEventListener");
const abortResult = client.execute({
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
signal: caller.signal,
});
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
caller.abort("navigation");
await expect(abortResult).resolves.toMatchObject({
ok: false,
error: { kind: "REQUEST_ABORTED" },
});
expect(add).toHaveBeenCalledOnce();
expect(remove).toHaveBeenCalledOnce();
expect(scheduler.clearTimeout).toHaveBeenCalledTimes(2);
});
it("never retries unsafe, non-retryable status, or schema failures", async () => {
const unsafeOperation = {
method: "POST",
path: "/api/unsafe",
operationId: "UNSAFE",
auth: "none",
timeoutMs: null,
idempotency: "none",
retry: "never",
requestSource: "none",
requestSchema: "unused",
responseSchema: "EntityPayload",
owner: "test",
} as const;
const unsafeFetch = vi.fn(async () => failureResponse(503));
const unsafeClient = testClient({
baseUrl: "https://api.test",
fetcher: unsafeFetch,
clock: immediateClock(),
getOperation: () => unsafeOperation,
});
await unsafeClient.execute({
operationId: "UNSAFE",
routeId: "TEST",
});
expect(unsafeFetch).toHaveBeenCalledOnce();
const statusFetch = vi.fn(async () => failureResponse(500));
const statusClient = testClient({
baseUrl: "https://api.test",
fetcher: statusFetch,
clock: immediateClock(),
});
await statusClient.execute({
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
});
expect(statusFetch).toHaveBeenCalledOnce();
const schemaFetch = vi.fn(async () =>
successResponse([{ id: "one", name: 42 }]),
);
const schemaScheduler = recordingScheduler();
const schemaClient = testClient({
baseUrl: "https://api.test",
fetcher: schemaFetch,
clock: immediateClock(),
scheduler: schemaScheduler,
});
await schemaClient.execute({
operationId: "LIST_ENTITIES",
routeId: "TEST_ROUTE",
});
expect(schemaFetch).toHaveBeenCalledOnce();
expect(schemaScheduler.clearTimeout).toHaveBeenCalledOnce();
});
});