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>
395 lines
12 KiB
TypeScript
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();
|
|
});
|
|
});
|