refactor: 프론트 템플릿 리펙토링
This commit is contained in:
@@ -0,0 +1,317 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type ApplicationResult,
|
||||
useApplicationMutation,
|
||||
} from "../../src/presentation/adapters/query/application-query.ts";
|
||||
import type { MutationIntentFactory } from "../../src/application/ports/mutation-intent-factory.ts";
|
||||
import {
|
||||
RESOURCE_INVALIDATION_TOPIC,
|
||||
deterministicMutationIntentFactory,
|
||||
queryClient,
|
||||
scopeSnapshot,
|
||||
wrapper,
|
||||
} from "./application-query-fixture.tsx";
|
||||
|
||||
describe("application mutation intent admission", () => {
|
||||
it("retains optimistic data when execute throws after dispatch begins", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "thrown-unknown-effect"];
|
||||
client.setQueryData(key, ["base"]);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
execute: async () => {
|
||||
throw new Error("private transport defect");
|
||||
},
|
||||
currentData: client.getQueryData(key),
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: (previous, input) => [
|
||||
...(previous as string[]),
|
||||
input,
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let outcome: ApplicationResult<string> | undefined;
|
||||
await act(async () => {
|
||||
outcome = await hook.result.current.submit("created");
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
effect: "MAYBE_APPLIED",
|
||||
retryable: false,
|
||||
action: "contact-support",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(outcome)).not.toContain("private transport defect");
|
||||
expect(client.getQueryData(key)).toEqual(["base", "created"]);
|
||||
expect(hook.result.current.state.indicator).toBe(
|
||||
"mutation-effect-unknown",
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a distinct logical intent for each independently admitted submit", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
const observedIntents: unknown[] = [];
|
||||
const execute = vi.fn(
|
||||
async (
|
||||
input: string,
|
||||
context: Readonly<{ signal: AbortSignal; intent?: unknown }>,
|
||||
) => {
|
||||
observedIntents.push(context.intent);
|
||||
return { ok: true as const, value: input };
|
||||
},
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
definitionId: "independent-intent-v1",
|
||||
definitionVersion: 1,
|
||||
operationId: "CREATE_WITH_INTENT",
|
||||
requiresIdempotencyKey: true,
|
||||
owner: "platform-test",
|
||||
duplicatePolicy: "ALLOW_PARALLEL",
|
||||
scope,
|
||||
execute,
|
||||
invalidate: [],
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await hook.result.current.submit("same-input");
|
||||
await hook.result.current.submit("same-input");
|
||||
});
|
||||
|
||||
expect(observedIntents).toHaveLength(2);
|
||||
expect(observedIntents[0]).toMatchObject({
|
||||
intentId: "intent-1",
|
||||
operationId: "CREATE_WITH_INTENT",
|
||||
idempotencyKey: "key-1",
|
||||
});
|
||||
expect(observedIntents[1]).toMatchObject({
|
||||
intentId: "intent-2",
|
||||
operationId: "CREATE_WITH_INTENT",
|
||||
idempotencyKey: "key-2",
|
||||
});
|
||||
expect(observedIntents[0]).not.toEqual(observedIntents[1]);
|
||||
});
|
||||
|
||||
it("creates no second intent when JOIN_IDENTICAL shares an admitted submit", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
const deterministicFactory = deterministicMutationIntentFactory();
|
||||
const createIntent = vi.fn(deterministicFactory.create);
|
||||
const factory: MutationIntentFactory = Object.freeze({
|
||||
create: createIntent,
|
||||
});
|
||||
let complete: (value: ApplicationResult<string>) => void = () => {};
|
||||
const execute = vi.fn(
|
||||
() =>
|
||||
new Promise<ApplicationResult<string>>((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
definitionId: "joined-intent-v1",
|
||||
definitionVersion: 1,
|
||||
operationId: "CREATE_JOINED",
|
||||
requiresIdempotencyKey: true,
|
||||
owner: "platform-test",
|
||||
duplicatePolicy: "JOIN_IDENTICAL",
|
||||
scope,
|
||||
execute,
|
||||
invalidate: [],
|
||||
}),
|
||||
{ wrapper: wrapper(client, factory) },
|
||||
);
|
||||
|
||||
let first: Promise<ApplicationResult<string>> | null = null;
|
||||
let joined: Promise<ApplicationResult<string>> | null = null;
|
||||
act(() => {
|
||||
first = hook.result.current.submit("same-input");
|
||||
joined = hook.result.current.submit("same-input");
|
||||
});
|
||||
|
||||
expect(first).toBe(joined);
|
||||
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
|
||||
expect(createIntent).toHaveBeenCalledOnce();
|
||||
expect(createIntent).toHaveBeenCalledWith({
|
||||
operationId: "CREATE_JOINED",
|
||||
canonicalInputIdentity:
|
||||
"scope-fingerprint-0001:joined-intent-v1:scope-identity-token-0001",
|
||||
requiresIdempotencyKey: true,
|
||||
});
|
||||
|
||||
complete({ ok: true, value: "same-input" });
|
||||
if (!first) throw new Error("expected admitted mutation");
|
||||
await act(() => first);
|
||||
});
|
||||
|
||||
it("keeps the legacy raw mutation path outside the intent factory", async () => {
|
||||
const client = queryClient();
|
||||
const createIntent = vi.fn<MutationIntentFactory["create"]>();
|
||||
const factory: MutationIntentFactory = Object.freeze({
|
||||
create: createIntent,
|
||||
});
|
||||
const execute = vi.fn(async (input: string) => ({
|
||||
ok: true as const,
|
||||
value: input,
|
||||
}));
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
definitionId: "legacy-raw-path-v1",
|
||||
execute,
|
||||
}),
|
||||
{ wrapper: wrapper(client, factory) },
|
||||
);
|
||||
|
||||
await act(() => hook.result.current.submit("legacy-input"));
|
||||
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(createIntent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a duplicate submit by default while one is active", async () => {
|
||||
const client = queryClient();
|
||||
let complete: (value: ApplicationResult<string>) => void = () => {};
|
||||
const execute = vi.fn(
|
||||
() =>
|
||||
new Promise<ApplicationResult<string>>((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
definitionId: "legacy-duplicate-rejection-v1",
|
||||
execute,
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let first: Promise<ApplicationResult<string>> | null = null;
|
||||
let duplicate: Promise<ApplicationResult<string>> | null = null;
|
||||
act(() => {
|
||||
first = hook.result.current.submit("created");
|
||||
duplicate = hook.result.current.submit("created");
|
||||
});
|
||||
|
||||
if (!first || !duplicate) throw new Error("expected two submissions");
|
||||
await expect(duplicate).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "DUPLICATE_IN_FLIGHT" },
|
||||
});
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
|
||||
complete({ ok: true, value: "created" });
|
||||
await act(() => first as Promise<ApplicationResult<string>>);
|
||||
});
|
||||
|
||||
it("deduplicates submit and commits one optimistic mutation", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "list"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
let complete: (value: ApplicationResult<string>) => void = () => {};
|
||||
const execute = vi.fn(
|
||||
() =>
|
||||
new Promise<ApplicationResult<string>>((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
// §11.2: joining is opt-in. The default is REJECT_WHILE_ACTIVE.
|
||||
duplicatePolicy: "JOIN_IDENTICAL",
|
||||
execute,
|
||||
invalidate: [RESOURCE_INVALIDATION_TOPIC],
|
||||
currentData: client.getQueryData(key),
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: (previous, input) => [
|
||||
...(previous as string[]),
|
||||
input,
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let first: Promise<ApplicationResult<string>> | null = null;
|
||||
let duplicate: Promise<ApplicationResult<string>> | null = null;
|
||||
act(() => {
|
||||
first = hook.result.current.submit("created");
|
||||
duplicate = hook.result.current.submit("created");
|
||||
});
|
||||
expect(first).toBe(duplicate);
|
||||
await waitFor(() =>
|
||||
expect(client.getQueryData(key)).toEqual(["existing", "created"]),
|
||||
);
|
||||
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.indicator).toBe("mutation-pending"),
|
||||
);
|
||||
|
||||
complete({ ok: true, value: "created" });
|
||||
if (!first) throw new Error("expected pending mutation");
|
||||
await act(() => first);
|
||||
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.indicator).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it("never joins distinct mutation inputs to the same runtime promise", async () => {
|
||||
const client = queryClient();
|
||||
const resolvers = new Map<
|
||||
string,
|
||||
(value: ApplicationResult<string>) => void
|
||||
>();
|
||||
const execute = vi.fn(
|
||||
(input: string) =>
|
||||
new Promise<ApplicationResult<string>>((resolve) => {
|
||||
resolvers.set(input, resolve);
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
definitionId: "legacy-parallel-distinct-inputs-v1",
|
||||
execute,
|
||||
currentData: true,
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let first: Promise<ApplicationResult<string>> | undefined;
|
||||
let second: Promise<ApplicationResult<string>> | undefined;
|
||||
act(() => {
|
||||
first = hook.result.current.submit("first");
|
||||
second = hook.result.current.submit("second");
|
||||
});
|
||||
expect(first).not.toBe(second);
|
||||
await waitFor(() => expect(execute).toHaveBeenCalledTimes(2));
|
||||
|
||||
resolvers.get("first")?.({ ok: true, value: "first" });
|
||||
resolvers.get("second")?.({ ok: true, value: "second" });
|
||||
if (!first || !second) throw new Error("expected pending mutations");
|
||||
await act(async () => {
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type ApplicationResult,
|
||||
useApplicationMutation,
|
||||
} from "../../src/presentation/adapters/query/application-query.ts";
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
import {
|
||||
RESOURCE_INVALIDATION_TOPIC,
|
||||
queryClient,
|
||||
wrapper,
|
||||
} from "./application-query-fixture.tsx";
|
||||
|
||||
describe("application mutation optimistic cache lifecycle", () => {
|
||||
it("cancels an in-flight query before taking the optimistic snapshot", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "ordered-update"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
let finishCancellation: () => void = () => {};
|
||||
const cancellation = new Promise<void>((resolve) => {
|
||||
finishCancellation = resolve;
|
||||
});
|
||||
const cancelQueries = vi
|
||||
.spyOn(client, "cancelQueries")
|
||||
.mockImplementation(async () => cancellation);
|
||||
const getQueryData = vi.spyOn(client, "getQueryData");
|
||||
const update = vi.fn((previous, input) => [
|
||||
...(previous as string[]),
|
||||
input,
|
||||
]);
|
||||
const execute = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
value: "created",
|
||||
}));
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
execute,
|
||||
currentData: ["existing"],
|
||||
optimistic: { queryKey: key, update },
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let pending: Promise<ApplicationResult<string>> | undefined;
|
||||
act(() => {
|
||||
pending = hook.result.current.submit("created");
|
||||
});
|
||||
expect(cancelQueries).toHaveBeenCalledWith({
|
||||
queryKey: key,
|
||||
exact: true,
|
||||
});
|
||||
expect(getQueryData).not.toHaveBeenCalled();
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
|
||||
finishCancellation();
|
||||
if (!pending) throw new Error("expected pending mutation");
|
||||
await act(() => pending);
|
||||
|
||||
expect(getQueryData).toHaveBeenCalledWith(key);
|
||||
expect(update).toHaveBeenCalledWith(["existing"], "created");
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(client.getQueryData(key)).toEqual(["existing", "created"]);
|
||||
});
|
||||
|
||||
it("keeps a committed optimistic update when invalidation fails", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "committed-update"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
vi.spyOn(client, "invalidateQueries").mockRejectedValue(
|
||||
new Error("cache refresh failed"),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
execute: async () => ({ ok: true, value: "created" }),
|
||||
invalidate: [RESOURCE_INVALIDATION_TOPIC],
|
||||
currentData: ["existing"],
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: (previous, input) => [
|
||||
...(previous as string[]),
|
||||
input,
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await hook.result.current.submit("created");
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ ok: true, value: "created" });
|
||||
expect(client.getQueryData(key)).toEqual(["existing", "created"]);
|
||||
});
|
||||
|
||||
it("normalizes an optimistic preparation defect without running the command", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "invalid-optimistic-update"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
const execute = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
value: "created",
|
||||
}));
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
execute,
|
||||
currentData: ["existing"],
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: () => {
|
||||
throw new Error("private optimistic detail");
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await hook.result.current.submit("created");
|
||||
});
|
||||
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "UNKNOWN_FAILURE",
|
||||
effect: "NOT_STARTED",
|
||||
operationId: "APPLICATION_MUTATION",
|
||||
userMessageKey: "error.unknown_failure",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(outcome)).not.toContain("private optimistic detail");
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
expect(client.getQueryData(key)).toEqual(["existing"]);
|
||||
});
|
||||
|
||||
it("removes an optimistic cache entry when no prior data existed", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "new-optimistic-entry"];
|
||||
const failure = createFailure("SERVER_FAILURE", "CREATE", 0, {
|
||||
effect: "NOT_APPLIED",
|
||||
});
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
execute: async () => ({ ok: false, error: failure }),
|
||||
currentData: true,
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: (_previous, input) => [input],
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await hook.result.current.submit("temporary");
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({ ok: false, error: failure });
|
||||
expect(client.getQueryData(key)).toBeUndefined();
|
||||
expect(client.getQueryState(key)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rolls optimistic data back and exposes a resolvable conflict", async () => {
|
||||
const client = queryClient();
|
||||
const key = ["resource", "list"];
|
||||
client.setQueryData(key, ["existing"]);
|
||||
const conflict = createFailure("CONFLICT", "CREATE", 0, {
|
||||
effect: "NOT_APPLIED",
|
||||
});
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
execute: async () => ({ ok: false, error: conflict }),
|
||||
invalidate: [RESOURCE_INVALIDATION_TOPIC],
|
||||
currentData: client.getQueryData(key),
|
||||
optimistic: {
|
||||
queryKey: key,
|
||||
update: (previous, input) => [
|
||||
...(previous as string[]),
|
||||
input,
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
let outcome;
|
||||
await act(async () => {
|
||||
outcome = await hook.result.current.submit("conflicting");
|
||||
});
|
||||
expect(outcome).toEqual({ ok: false, error: conflict });
|
||||
expect(client.getQueryData(key)).toEqual(["existing"]);
|
||||
expect(hook.result.current.state.indicator).toBe("mutation-conflict");
|
||||
expect(hook.result.current.state.overlay).toMatchObject({
|
||||
mutationPending: false,
|
||||
mutationConflict: true,
|
||||
});
|
||||
|
||||
await act(() => hook.result.current.resolveConflict());
|
||||
expect(hook.result.current.state.indicator).toBeNull();
|
||||
expect(client.getQueryState(key)?.isInvalidated).toBe(true);
|
||||
});});
|
||||
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type ApplicationResult,
|
||||
useApplicationMutation,
|
||||
} from "../../src/presentation/adapters/query/application-query.ts";
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
import {
|
||||
queryClient,
|
||||
scopeSnapshot,
|
||||
wrapper,
|
||||
} from "./application-query-fixture.tsx";
|
||||
|
||||
describe("scope-bound mutation fence", () => {
|
||||
it("rejects a submit whose scope is already fenced", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
const execute = vi.fn(async () => ({ ok: true as const, value: "v" }));
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
definitionId: "fenced-mutation-v1",
|
||||
definitionVersion: 1,
|
||||
operationId: "CREATE_FENCED",
|
||||
requiresIdempotencyKey: false,
|
||||
owner: "platform-test",
|
||||
duplicatePolicy: "REJECT_WHILE_ACTIVE",
|
||||
scope,
|
||||
execute,
|
||||
invalidate: [],
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
scope.fence();
|
||||
const outcome = await hook.result.current.submit("value");
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "SCOPE_GENERATION_CHANGED",
|
||||
effect: "NOT_STARTED",
|
||||
},
|
||||
});
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a mutation result whose scope was fenced after dispatch", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
const execute = vi.fn(async () => {
|
||||
scope.fence();
|
||||
return { ok: true as const, value: "committed" };
|
||||
});
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
definitionId: "late-mutation-v1",
|
||||
definitionVersion: 1,
|
||||
operationId: "CREATE_LATE",
|
||||
requiresIdempotencyKey: false,
|
||||
owner: "platform-test",
|
||||
duplicatePolicy: "ALLOW_PARALLEL",
|
||||
scope,
|
||||
execute,
|
||||
invalidate: [],
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
const outcome = await hook.result.current.submit("value");
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "SCOPE_GENERATION_CHANGED" },
|
||||
});
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("aborts a hung mutation when its captured scope is fenced", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
let observedSignal: AbortSignal | undefined;
|
||||
const execute = vi.fn(
|
||||
(_input: string, context: Readonly<{ signal: AbortSignal }>) =>
|
||||
new Promise<ApplicationResult<string>>((resolve) => {
|
||||
observedSignal = context.signal;
|
||||
context.signal.addEventListener(
|
||||
"abort",
|
||||
() =>
|
||||
resolve({
|
||||
ok: false,
|
||||
error: createFailure("REQUEST_ABORTED", "CREATE_HUNG", 0),
|
||||
}),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationMutation<string, string>({
|
||||
definitionId: "hung-mutation-v1",
|
||||
definitionVersion: 1,
|
||||
operationId: "CREATE_HUNG",
|
||||
requiresIdempotencyKey: false,
|
||||
owner: "platform-test",
|
||||
duplicatePolicy: "REJECT_WHILE_ACTIVE",
|
||||
scope,
|
||||
execute,
|
||||
invalidate: [],
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
const outcome = hook.result.current.submit("value");
|
||||
await waitFor(() => expect(observedSignal).toBe(scope.signal));
|
||||
|
||||
scope.fence();
|
||||
|
||||
expect(observedSignal?.aborted).toBe(true);
|
||||
await expect(outcome).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "SCOPE_GENERATION_CHANGED",
|
||||
effect: "MAYBE_APPLIED",
|
||||
retryable: false,
|
||||
action: "contact-support",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type ApplicationResult,
|
||||
useApplicationQuery,
|
||||
} from "../../src/presentation/adapters/query/application-query.ts";
|
||||
import { createFailure } from "../../src/contracts/errors.ts";
|
||||
import { queryClient, wrapper } from "./application-query-fixture.tsx";
|
||||
|
||||
describe("application query inbound bridge", () => {
|
||||
it("latches a background failure over stale data and clears it on retry success", async () => {
|
||||
const client = queryClient();
|
||||
const responses: ApplicationResult<string[]>[] = [
|
||||
{ ok: true, value: ["first"] },
|
||||
{
|
||||
ok: false,
|
||||
error: createFailure("SERVER_FAILURE", "LIST", 0),
|
||||
},
|
||||
{ ok: true, value: ["recovered"] },
|
||||
];
|
||||
const execute = vi.fn(async () => responses.shift() ?? responses[0]);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["resource", "list"],
|
||||
execute,
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(hook.result.current.data).toEqual(["first"]));
|
||||
await act(() => hook.result.current.retry());
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.indicator).toBe("stale-degraded"),
|
||||
);
|
||||
expect(hook.result.current.state.base).toBe("success");
|
||||
expect(hook.result.current.data).toEqual(["first"]);
|
||||
|
||||
await act(() => hook.result.current.retry());
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.data).toEqual(["recovered"]),
|
||||
);
|
||||
expect(hook.result.current.state.indicator).toBeNull();
|
||||
expect(execute).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("projects an initial application failure into terminal state", async () => {
|
||||
const client = queryClient();
|
||||
const failure = createFailure("FORBIDDEN", "LIST", 0);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["forbidden"],
|
||||
execute: async () => ({ ok: false, error: failure }),
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.base).toBe("terminal-error"),
|
||||
);
|
||||
expect(hook.result.current.state.failure).toBe(failure);
|
||||
});
|
||||
|
||||
it("normalizes an unexpected execute rejection into a safe terminal failure", async () => {
|
||||
const client = queryClient();
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["unexpected-rejection"],
|
||||
execute: async () => {
|
||||
throw new Error("private upstream detail");
|
||||
},
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.base).toBe("terminal-error"),
|
||||
);
|
||||
expect(hook.result.current.state.failure).toMatchObject({
|
||||
kind: "UNKNOWN_FAILURE",
|
||||
operationId: "APPLICATION_QUERY",
|
||||
userMessageKey: "error.unknown_failure",
|
||||
action: "contact-support",
|
||||
});
|
||||
expect(JSON.stringify(hook.result.current.state.failure)).not.toContain(
|
||||
"private upstream detail",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes cancellation to the application and does not retain an unmounted error", async () => {
|
||||
const client = queryClient();
|
||||
let aborted = false;
|
||||
const execute = vi.fn(
|
||||
({ signal }: { signal: AbortSignal }) =>
|
||||
new Promise<ApplicationResult<unknown>>((resolve) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
aborted = true;
|
||||
resolve({
|
||||
ok: false,
|
||||
error: createFailure("REQUEST_ABORTED", "LIST", 0),
|
||||
});
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery({
|
||||
queryKey: ["cancelled"],
|
||||
execute,
|
||||
}),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(execute).toHaveBeenCalledOnce());
|
||||
hook.unmount();
|
||||
await waitFor(() => expect(aborted).toBe(true));
|
||||
expect(client.getQueryState(["cancelled"])?.status).not.toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { MutationIntentFactory } from "../../src/application/ports/mutation-intent-factory.ts";
|
||||
import { QueryInvalidationProvider } from "../../src/presentation/adapters/query/query-invalidation-provider.tsx";
|
||||
import { MutationIntentProvider } from "../../src/presentation/adapters/query/mutation-intent-provider.tsx";
|
||||
import {
|
||||
defineQueryInvalidationTopic,
|
||||
type QueryInvalidationCoordinator,
|
||||
} from "../../src/contracts/query-invalidation.ts";
|
||||
import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts";
|
||||
import type { QueryResultMeasure } from "../../src/contracts/server-state.ts";
|
||||
import type { CacheScopeSnapshot } from "../../src/contracts/server-state-scope.ts";
|
||||
|
||||
export const RESOURCE_INVALIDATION_TOPIC =
|
||||
defineQueryInvalidationTopic("resource");
|
||||
|
||||
export function scopeSnapshot(
|
||||
generation = 1,
|
||||
fingerprint = "scope-fingerprint-0001",
|
||||
): CacheScopeSnapshot & { fence(): void } {
|
||||
let current = true;
|
||||
const lifetime = new AbortController();
|
||||
const identities = createRuntimeIdentityRegistry({
|
||||
tokenFactory: () => "scope-identity-token-0001",
|
||||
});
|
||||
return {
|
||||
generation,
|
||||
fingerprint,
|
||||
identities,
|
||||
signal: lifetime.signal,
|
||||
isCurrent: () => current,
|
||||
fence() {
|
||||
current = false;
|
||||
lifetime.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function measureOne(): QueryResultMeasure {
|
||||
return { itemCount: 1, estimatedBytes: 8 };
|
||||
}
|
||||
|
||||
export function queryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: 0, gcTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function deterministicMutationIntentFactory(): MutationIntentFactory {
|
||||
let sequence = 0;
|
||||
return Object.freeze({
|
||||
create(input) {
|
||||
sequence += 1;
|
||||
return Object.freeze({
|
||||
intentId: `intent-${sequence}`,
|
||||
operationId: input.operationId,
|
||||
canonicalInputIdentity: input.canonicalInputIdentity,
|
||||
...(input.requiresIdempotencyKey
|
||||
? { idempotencyKey: `key-${sequence}` }
|
||||
: {}),
|
||||
createdAtMonotonicMs: sequence,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function wrapper(
|
||||
client: QueryClient,
|
||||
mutationIntentFactory = deterministicMutationIntentFactory(),
|
||||
) {
|
||||
const coordinator: QueryInvalidationCoordinator = {
|
||||
async invalidate(topics) {
|
||||
for (const topic of topics) {
|
||||
await client.invalidateQueries({
|
||||
queryKey: [topic],
|
||||
exact: false,
|
||||
refetchType: "active",
|
||||
});
|
||||
}
|
||||
},
|
||||
beginMutation() {
|
||||
return { release: async () => {} };
|
||||
},
|
||||
async resetLocal() {
|
||||
await client.cancelQueries();
|
||||
client.clear();
|
||||
},
|
||||
dispose() {},
|
||||
};
|
||||
return function QueryWrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<MutationIntentProvider factory={mutationIntentFactory}>
|
||||
<QueryClientProvider client={client}>
|
||||
<QueryInvalidationProvider coordinator={coordinator}>
|
||||
{children}
|
||||
</QueryInvalidationProvider>
|
||||
</QueryClientProvider>
|
||||
</MutationIntentProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type ApplicationResult,
|
||||
useApplicationQuery,
|
||||
} from "../../src/presentation/adapters/query/application-query.ts";
|
||||
import {
|
||||
createQueryInvalidationPrefix,
|
||||
defineQueryNamespaceIdentity,
|
||||
} from "../../src/contracts/query-keys.ts";
|
||||
import {
|
||||
bindQuery,
|
||||
type QueryResultMeasure,
|
||||
} from "../../src/contracts/server-state.ts";
|
||||
import {
|
||||
measureOne,
|
||||
queryClient,
|
||||
scopeSnapshot,
|
||||
wrapper,
|
||||
} from "./application-query-fixture.tsx";
|
||||
|
||||
describe("scope-bound query commit fence", () => {
|
||||
it("binds the namespace-first V2 query key", () => {
|
||||
const scope = scopeSnapshot();
|
||||
const definition = {
|
||||
definitionId: "resource-detail-v1",
|
||||
definitionVersion: 1,
|
||||
owner: "platform-test",
|
||||
namespace: "resource",
|
||||
namespaceVersion: 1,
|
||||
operationId: "GET_RESOURCE",
|
||||
profileId: "DETAIL_STANDARD" as const,
|
||||
measureResult: measureOne,
|
||||
execute: async () => ({ ok: true as const, value: "value" }),
|
||||
};
|
||||
|
||||
const bound = bindQuery(definition, "resource-1", scope);
|
||||
|
||||
expect(bound.queryKey).toEqual([
|
||||
"query",
|
||||
2,
|
||||
"resource",
|
||||
1,
|
||||
"scope-fingerprint-0001",
|
||||
1,
|
||||
"scope-identity-token-0001",
|
||||
]);
|
||||
expect(bound.queryKey.slice(0, 4)).toEqual(
|
||||
createQueryInvalidationPrefix(
|
||||
defineQueryNamespaceIdentity("resource", 1),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("discards a successful result whose scope was fenced during execution", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
let complete: (value: ApplicationResult<string>) => void = () => {};
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery(
|
||||
bindQuery(
|
||||
{
|
||||
definitionId: "fenced-detail-v1",
|
||||
definitionVersion: 1,
|
||||
owner: "platform-test",
|
||||
namespace: "fenced",
|
||||
namespaceVersion: 1,
|
||||
operationId: "GET_FENCED",
|
||||
profileId: "DETAIL_STANDARD",
|
||||
measureResult: measureOne,
|
||||
execute: () =>
|
||||
new Promise<ApplicationResult<string>>((resolve) => {
|
||||
complete = resolve;
|
||||
}),
|
||||
},
|
||||
"input",
|
||||
scope,
|
||||
),
|
||||
),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
// §10.8: the scope goes stale after dispatch but before commit.
|
||||
scope.fence();
|
||||
await act(async () => {
|
||||
complete({ ok: true, value: "late" });
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.failure?.kind).toBe(
|
||||
"SCOPE_GENERATION_CHANGED",
|
||||
),
|
||||
);
|
||||
expect(hook.result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses to start when the captured scope is already stale", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
const execute = vi.fn(async () => ({ ok: true as const, value: "v" }));
|
||||
scope.fence();
|
||||
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery(
|
||||
bindQuery(
|
||||
{
|
||||
definitionId: "stale-detail-v1",
|
||||
definitionVersion: 1,
|
||||
owner: "platform-test",
|
||||
namespace: "stale",
|
||||
namespaceVersion: 1,
|
||||
operationId: "GET_STALE",
|
||||
profileId: "DETAIL_STANDARD",
|
||||
measureResult: measureOne,
|
||||
execute,
|
||||
},
|
||||
"input",
|
||||
scope,
|
||||
),
|
||||
),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.failure?.kind).toBe(
|
||||
"SCOPE_GENERATION_CHANGED",
|
||||
),
|
||||
);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a result that exceeds the profile budget instead of caching it", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery(
|
||||
bindQuery(
|
||||
{
|
||||
definitionId: "oversized-list-v1",
|
||||
definitionVersion: 1,
|
||||
owner: "platform-test",
|
||||
namespace: "oversized",
|
||||
namespaceVersion: 1,
|
||||
operationId: "LIST_OVERSIZED",
|
||||
profileId: "VOLATILE_STATUS",
|
||||
// §10.4: VOLATILE_STATUS admits 1 item and 64KiB.
|
||||
measureResult: (): QueryResultMeasure => ({
|
||||
itemCount: 2,
|
||||
estimatedBytes: 8,
|
||||
}),
|
||||
execute: async () => ({ ok: true as const, value: "value" }),
|
||||
},
|
||||
"input",
|
||||
scope,
|
||||
),
|
||||
),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.failure?.kind).toBe(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
),
|
||||
);
|
||||
expect(hook.result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats a throwing measurement as a measurement failure, not a cache commit", async () => {
|
||||
const client = queryClient();
|
||||
const scope = scopeSnapshot();
|
||||
const hook = renderHook(
|
||||
() =>
|
||||
useApplicationQuery(
|
||||
bindQuery(
|
||||
{
|
||||
definitionId: "unmeasurable-v1",
|
||||
definitionVersion: 1,
|
||||
owner: "platform-test",
|
||||
namespace: "unmeasurable",
|
||||
namespaceVersion: 1,
|
||||
operationId: "GET_UNMEASURABLE",
|
||||
profileId: "DETAIL_STANDARD",
|
||||
measureResult: (): QueryResultMeasure => {
|
||||
throw new Error("estimator defect");
|
||||
},
|
||||
execute: async () => ({ ok: true as const, value: "value" }),
|
||||
},
|
||||
"input",
|
||||
scope,
|
||||
),
|
||||
),
|
||||
{ wrapper: wrapper(client) },
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hook.result.current.state.failure?.kind).toBe(
|
||||
"RESULT_LIMIT_EXCEEDED",
|
||||
),
|
||||
);
|
||||
expect(hook.result.current.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
import type {
|
||||
BrowserDataResult,
|
||||
IndexedDbRepositoryPort,
|
||||
} from "../../../../src/application/ports/browser-file-storage/index.ts";
|
||||
|
||||
export type LocalDraft = Readonly<{
|
||||
draftId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}>;
|
||||
|
||||
export type SaveLocalDraftCommand = Readonly<{
|
||||
draft: LocalDraft;
|
||||
expectedRevision: number | null;
|
||||
idempotencyKey: string;
|
||||
}>;
|
||||
|
||||
export type RemoveLocalDraftCommand = Readonly<{
|
||||
draftId: string;
|
||||
expectedRevision: number;
|
||||
idempotencyKey: string;
|
||||
}>;
|
||||
|
||||
export type LocalDraftRecord = Readonly<{
|
||||
draft: LocalDraft;
|
||||
revision: number;
|
||||
}>;
|
||||
|
||||
export interface LocalDraftStore {
|
||||
save(
|
||||
command: SaveLocalDraftCommand,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<Readonly<{ revision: number }>>>;
|
||||
find(
|
||||
draftId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<LocalDraftRecord | null>>;
|
||||
remove(
|
||||
command: RemoveLocalDraftCommand,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BrowserDataResult<void>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature-owned binding over the technology-neutral IndexedDB application port.
|
||||
*
|
||||
* The feature knows its domain type and optimistic concurrency inputs. It does
|
||||
* not know database names, stores, transactions, native IDB objects, codecs,
|
||||
* migrations, quota handling or connection lifecycle.
|
||||
*/
|
||||
export function createLocalDraftStore(
|
||||
repository: IndexedDbRepositoryPort<LocalDraft, never>,
|
||||
): LocalDraftStore {
|
||||
const store: LocalDraftStore = {
|
||||
async save(command, signal) {
|
||||
const result = await repository.compareAndSwap({
|
||||
key: command.draft.draftId,
|
||||
value: command.draft,
|
||||
expectedRevision: command.expectedRevision,
|
||||
idempotencyKey: command.idempotencyKey,
|
||||
signal,
|
||||
});
|
||||
if (!result.ok) return result;
|
||||
return Object.freeze({
|
||||
ok: true as const,
|
||||
value: Object.freeze({ revision: result.value.revision }),
|
||||
});
|
||||
},
|
||||
|
||||
async find(draftId, signal) {
|
||||
const result = await repository.read(draftId, signal);
|
||||
if (!result.ok) return result;
|
||||
return Object.freeze({
|
||||
ok: true as const,
|
||||
value:
|
||||
result.value === null
|
||||
? null
|
||||
: Object.freeze({
|
||||
draft: result.value.value,
|
||||
revision: result.value.revision,
|
||||
}),
|
||||
});
|
||||
},
|
||||
|
||||
async remove(command, signal) {
|
||||
const result = await repository.remove({
|
||||
key: command.draftId,
|
||||
expectedRevision: command.expectedRevision,
|
||||
idempotencyKey: command.idempotencyKey,
|
||||
signal,
|
||||
});
|
||||
if (!result.ok) return result;
|
||||
return Object.freeze({ ok: true as const, value: undefined });
|
||||
},
|
||||
};
|
||||
return Object.freeze(store);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
BrowserDataResult,
|
||||
IndexedDbRepositoryPort,
|
||||
} from "../../../src/application/ports/browser-file-storage/index.ts";
|
||||
import {
|
||||
createLocalDraftStore,
|
||||
type LocalDraft,
|
||||
} from "./fixtures/local-draft-feature.ts";
|
||||
|
||||
function success<Value>(value: Value): BrowserDataResult<Value> {
|
||||
return Object.freeze({ ok: true as const, value });
|
||||
}
|
||||
|
||||
function repositoryFixture(): IndexedDbRepositoryPort<LocalDraft, never> {
|
||||
let stored: Readonly<{ value: LocalDraft; revision: number }> | null = null;
|
||||
|
||||
const repository: IndexedDbRepositoryPort<LocalDraft, never> = {
|
||||
async open() {
|
||||
return success(undefined);
|
||||
},
|
||||
async read(key) {
|
||||
if (stored === null || stored.value.draftId !== key) {
|
||||
return success(null);
|
||||
}
|
||||
return success(stored);
|
||||
},
|
||||
async query() {
|
||||
return success(Object.freeze({ items: [], nextCursor: null }));
|
||||
},
|
||||
async compareAndSwap(input) {
|
||||
const currentRevision = stored?.revision ?? null;
|
||||
if (currentRevision !== input.expectedRevision) {
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
error: Object.freeze({
|
||||
code: "CONFLICT" as const,
|
||||
operation: "INDEXEDDB_WRITE" as const,
|
||||
retryable: false,
|
||||
recovery: "RECONCILE" as const,
|
||||
}),
|
||||
});
|
||||
}
|
||||
const revision = (currentRevision ?? 0) + 1;
|
||||
stored = Object.freeze({ value: input.value, revision });
|
||||
return success(
|
||||
Object.freeze({
|
||||
key: input.key,
|
||||
revision,
|
||||
replayed: false,
|
||||
}),
|
||||
);
|
||||
},
|
||||
async remove(input) {
|
||||
if (stored === null || stored.revision !== input.expectedRevision) {
|
||||
return Object.freeze({
|
||||
ok: false as const,
|
||||
error: Object.freeze({
|
||||
code: "CONFLICT" as const,
|
||||
operation: "INDEXEDDB_WRITE" as const,
|
||||
retryable: false,
|
||||
recovery: "RECONCILE" as const,
|
||||
}),
|
||||
});
|
||||
}
|
||||
stored = null;
|
||||
return success(
|
||||
Object.freeze({
|
||||
key: input.key,
|
||||
revision: input.expectedRevision + 1,
|
||||
replayed: false,
|
||||
}),
|
||||
);
|
||||
},
|
||||
async enforceLifecycleBatch() {
|
||||
stored = null;
|
||||
return success(
|
||||
Object.freeze({
|
||||
state: "COMPLETE" as const,
|
||||
scannedRows: 0,
|
||||
deletedRows: 0,
|
||||
budgetExhausted: false,
|
||||
}),
|
||||
);
|
||||
},
|
||||
getStatus() {
|
||||
return Object.freeze({ kind: "READY" as const, schemaVersion: 1 });
|
||||
},
|
||||
subscribeStatus() {
|
||||
return () => {};
|
||||
},
|
||||
close() {},
|
||||
};
|
||||
return Object.freeze(repository);
|
||||
}
|
||||
|
||||
describe("IndexedDB local-draft consumer experience", () => {
|
||||
it("implements save/find/remove through the public application port", async () => {
|
||||
const store = createLocalDraftStore(repositoryFixture());
|
||||
const draft = Object.freeze({
|
||||
draftId: "draft-1",
|
||||
title: "Architecture notes",
|
||||
body: "Feature code owns the draft model.",
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.save({
|
||||
draft,
|
||||
expectedRevision: null,
|
||||
idempotencyKey: "draft-save-0001",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, value: { revision: 1 } });
|
||||
|
||||
await expect(store.find("draft-1")).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { draft, revision: 1 },
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.remove({
|
||||
draftId: "draft-1",
|
||||
expectedRevision: 1,
|
||||
idempotencyKey: "draft-remove-0001",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, value: undefined });
|
||||
|
||||
await expect(store.find("draft-1")).resolves.toEqual({
|
||||
ok: true,
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps native IndexedDB and runtime internals out of feature-owned code", async () => {
|
||||
const source = await readFile(
|
||||
new URL("./fixtures/local-draft-feature.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const importLines = source
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("import "));
|
||||
expect(importLines).toHaveLength(1);
|
||||
expect(source).toContain(
|
||||
"src/application/ports/browser-file-storage/index.ts",
|
||||
);
|
||||
|
||||
for (const forbidden of [
|
||||
"src/adapters/storage/indexeddb",
|
||||
"globalThis.indexedDB",
|
||||
"IDBFactory",
|
||||
"IDBDatabase",
|
||||
"IDBTransaction",
|
||||
"IDBObjectStore",
|
||||
]) {
|
||||
expect(source).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createFeatureHttpBinding,
|
||||
defineFeatureHttpOperation,
|
||||
type InstalledHttpOperationExecutor,
|
||||
} from "../../../src/adapters/http/index.ts";
|
||||
import {
|
||||
mappingFailure,
|
||||
mappingSuccess,
|
||||
} from "../../../src/contracts/boundary-mapper.ts";
|
||||
|
||||
type Resource = Readonly<{ id: string; title: string }>;
|
||||
|
||||
const OPERATIONS = Object.freeze({
|
||||
LOAD_RESOURCE: defineFeatureHttpOperation<
|
||||
Readonly<{ resourceId: string }>,
|
||||
Resource
|
||||
>({
|
||||
operationId: "LOAD_RESOURCE",
|
||||
routeId: "RESOURCE_DETAIL",
|
||||
mapSuccess(value) {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
typeof (value as Record<string, unknown>).id !== "string" ||
|
||||
typeof (value as Record<string, unknown>).title !== "string"
|
||||
) {
|
||||
return mappingFailure("MAPPING_INVARIANT_REJECTED");
|
||||
}
|
||||
const candidate = value as Readonly<{ id: string; title: string }>;
|
||||
return mappingSuccess(
|
||||
Object.freeze({ id: candidate.id, title: candidate.title }),
|
||||
);
|
||||
},
|
||||
}),
|
||||
} as const);
|
||||
|
||||
describe("feature HTTP binding", () => {
|
||||
it("keeps typed feature input while platform owns route/context execution", async () => {
|
||||
const execute = vi.fn<InstalledHttpOperationExecutor["execute"]>(
|
||||
async (_operationId, input, context) => {
|
||||
expect(input).toEqual({ resourceId: "resource-1" });
|
||||
expect(context.routeId).toBe("RESOURCE_DETAIL");
|
||||
return Object.freeze({
|
||||
kind: "SUCCESS" as const,
|
||||
value: Object.freeze({ id: "resource-1", title: "Reference" }),
|
||||
metadata: Object.freeze({ status: 200 }),
|
||||
effect: "NOT_APPLICABLE" as const,
|
||||
});
|
||||
},
|
||||
);
|
||||
const binding = createFeatureHttpBinding(
|
||||
Object.freeze({ execute }),
|
||||
OPERATIONS,
|
||||
);
|
||||
|
||||
const result = await binding.execute("LOAD_RESOURCE", {
|
||||
resourceId: "resource-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { id: "resource-1", title: "Reference" },
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("normalizes transport failure before it crosses the feature gateway", async () => {
|
||||
const executor: InstalledHttpOperationExecutor = Object.freeze({
|
||||
async execute() {
|
||||
return Object.freeze({
|
||||
kind: "TRANSPORT_FAILURE" as const,
|
||||
failure: Object.freeze({
|
||||
kind: "TIMEOUT" as const,
|
||||
retryable: true,
|
||||
}),
|
||||
effect: "NOT_STARTED" as const,
|
||||
});
|
||||
},
|
||||
});
|
||||
const binding = createFeatureHttpBinding(executor, OPERATIONS);
|
||||
|
||||
const result = await binding.execute("LOAD_RESOURCE", {
|
||||
resourceId: "resource-1",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.kind).toBe("REQUEST_TIMEOUT");
|
||||
expect(result.error.operationId).toBe("LOAD_RESOURCE");
|
||||
expect(result.error.effect).toBe("NOT_STARTED");
|
||||
});
|
||||
|
||||
it("turns feature mapper rejection into the shared mapping failure", async () => {
|
||||
const executor: InstalledHttpOperationExecutor = Object.freeze({
|
||||
async execute() {
|
||||
return Object.freeze({
|
||||
kind: "SUCCESS" as const,
|
||||
value: Object.freeze({ unexpected: true }),
|
||||
metadata: Object.freeze({ status: 200 }),
|
||||
effect: "NOT_APPLICABLE" as const,
|
||||
});
|
||||
},
|
||||
});
|
||||
const binding = createFeatureHttpBinding(executor, OPERATIONS);
|
||||
|
||||
const result = await binding.execute("LOAD_RESOURCE", {
|
||||
resourceId: "resource-1",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.kind).toBe("MAPPING_CONTRACT_VIOLATION");
|
||||
expect(result.error.code).toBe("MAPPING_INVARIANT_REJECTED");
|
||||
});
|
||||
});
|
||||
@@ -1,61 +1,68 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createHttpClient } from "../../../src/adapters/http/client.ts";
|
||||
import { createDemoSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
|
||||
import {
|
||||
createReferenceHttpGateway,
|
||||
} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
|
||||
import { createReferenceFeatureInput } from "../../../src/features/reference-feature/application/reference-feature-api.ts";
|
||||
import { REFERENCE_FEATURE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract.ts";
|
||||
import { mapReferenceOperation } from "../../../src/features/reference-feature/contracts/reference-mapper.ts";
|
||||
import {
|
||||
validateReferencePayload,
|
||||
validateReferenceRequest,
|
||||
} from "../../../src/features/reference-feature/contracts/reference-schemas.ts";
|
||||
import { createContractHttpExecutor } from "../../../src/adapters/http/index.ts";
|
||||
import { createHttpObservationProjector } from "../../../src/bootstrap/runtime-adapters.ts";
|
||||
import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts";
|
||||
import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts";
|
||||
|
||||
describe("reference feature diagnostics correlation", () => {
|
||||
it("preserves route, operation and request correlation through the vertical path", async () => {
|
||||
function scopeSnapshot() {
|
||||
return Object.freeze({
|
||||
generation: 1,
|
||||
fingerprint: "reference-scope",
|
||||
identities: Object.freeze({}) as never,
|
||||
signal: new AbortController().signal,
|
||||
isCurrent: () => true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("reference feature HTTP diagnostics", () => {
|
||||
it("preserves route and operation identity through the installed V3 path", async () => {
|
||||
const record = vi.fn();
|
||||
const operations =
|
||||
REFERENCE_FEATURE_CONTRACT.apiOperations as Readonly<
|
||||
Record<
|
||||
string,
|
||||
ReturnType<
|
||||
NonNullable<Parameters<typeof createHttpClient>[0]["getOperation"]>
|
||||
>
|
||||
>
|
||||
>;
|
||||
const client = createHttpClient({
|
||||
const contractHttp = createContractHttpExecutor({
|
||||
baseUrl: "https://api.test",
|
||||
authSession: createDemoSessionAdapter("authenticated"),
|
||||
fetcher: async () =>
|
||||
Response.json({
|
||||
success: true,
|
||||
data: [{ id: "reference-1", name: "Reference" }],
|
||||
meta: { requestId: "safe-request", traceId: "safe-trace" },
|
||||
}),
|
||||
getOperation(operationId) {
|
||||
const operation = operations[operationId];
|
||||
if (!operation) throw new Error("Unregistered reference operation");
|
||||
return operation;
|
||||
},
|
||||
validatePayload: validateReferencePayload,
|
||||
validateRequest: validateReferenceRequest,
|
||||
mapPayload: mapReferenceOperation,
|
||||
correlationIdFactory: () => "reference-correlation",
|
||||
diagnostics: { record },
|
||||
scheduler: {
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
maxRetryAttempts: 0,
|
||||
attachCredentials: () => ({
|
||||
kind: "READY" as const,
|
||||
headers: { authorization: "Bearer diagnostics-test-token" },
|
||||
}),
|
||||
fetcher: (async () =>
|
||||
Response.json([
|
||||
{ id: "reference-1", name: "Reference" },
|
||||
])) as unknown as typeof fetch,
|
||||
observe: createHttpObservationProjector({
|
||||
diagnostics: { record },
|
||||
telemetry: { emit: vi.fn() },
|
||||
}),
|
||||
});
|
||||
const application = createReferenceFeatureInput(
|
||||
createReferenceHttpGateway(client),
|
||||
const operations = new Map(
|
||||
REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.map((operation) => [
|
||||
operation.contract.operationId,
|
||||
operation,
|
||||
]),
|
||||
);
|
||||
const installed = createReferenceFeatureInstalledInput({
|
||||
contractOperations: Object.freeze({
|
||||
async execute(operationId, input, context) {
|
||||
const operation = operations.get(operationId);
|
||||
if (!operation) throw new Error("Unregistered reference operation");
|
||||
return contractHttp.execute(operation, input, {
|
||||
routeId: context.routeId,
|
||||
scope: scopeSnapshot(),
|
||||
...(context.signal === undefined
|
||||
? {}
|
||||
: { signal: context.signal }),
|
||||
...(context.intent === undefined
|
||||
? {}
|
||||
: { intent: context.intent }),
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
application.listResources({ limit: 20 }),
|
||||
installed.input.listResources({ limit: 20 }),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
expect(record).toHaveBeenCalledOnce();
|
||||
expect(record).toHaveBeenCalledWith({
|
||||
level: "info",
|
||||
@@ -63,8 +70,7 @@ describe("reference feature diagnostics correlation", () => {
|
||||
context: expect.objectContaining({
|
||||
route_id: "REFERENCE_RESOURCE_LIST",
|
||||
operation_id: "LIST_REFERENCE_RESOURCES",
|
||||
correlation_id: "reference-correlation",
|
||||
outcome: "success",
|
||||
outcome: "SUCCESS",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createFailure } from "../../../src/contracts/errors.ts";
|
||||
import type { Result } from "../../../src/contracts/result.ts";
|
||||
import { createFailure, type ApiFailure } from "../../../src/contracts/errors.ts";
|
||||
import {
|
||||
createReferenceHttpGateway,
|
||||
type RawReferenceHttpExecutor,
|
||||
type ReferenceHttpBinding,
|
||||
} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
|
||||
import type { ReferenceResource } from "../../../src/features/reference-feature/domain/reference-resource.ts";
|
||||
|
||||
@@ -20,14 +21,42 @@ const resources = Object.freeze({
|
||||
}),
|
||||
}) satisfies Readonly<Record<string, ReferenceResource>>;
|
||||
|
||||
type ScriptedResult = Result<
|
||||
ReferenceResource | readonly ReferenceResource[],
|
||||
ApiFailure
|
||||
>;
|
||||
|
||||
function scriptedBinding(results: ScriptedResult[]) {
|
||||
const calls: Array<
|
||||
readonly [operationId: string, input: unknown, context: unknown]
|
||||
> = [];
|
||||
let index = 0;
|
||||
const execute = (async (
|
||||
operationId: string,
|
||||
input: unknown,
|
||||
context?: unknown,
|
||||
) => {
|
||||
calls.push([operationId, input, context]);
|
||||
const result = results[index];
|
||||
index += 1;
|
||||
if (!result) throw new Error("Missing scripted result");
|
||||
return result;
|
||||
}) as ReferenceHttpBinding["execute"];
|
||||
|
||||
return {
|
||||
binding: Object.freeze({ execute }) satisfies ReferenceHttpBinding,
|
||||
calls,
|
||||
};
|
||||
}
|
||||
|
||||
describe("reference HTTP operation gateway", () => {
|
||||
it("builds the exact registered request for every gateway operation", async () => {
|
||||
const execute = vi
|
||||
.fn<RawReferenceHttpExecutor["execute"]>()
|
||||
.mockResolvedValueOnce({ ok: true, value: [resources.first] })
|
||||
.mockResolvedValueOnce({ ok: true, value: resources.created })
|
||||
.mockResolvedValueOnce({ ok: true, value: resources.first });
|
||||
const gateway = createReferenceHttpGateway({ execute });
|
||||
it("delegates exact typed feature inputs to the capability binding", async () => {
|
||||
const scripted = scriptedBinding([
|
||||
{ ok: true, value: [resources.first] },
|
||||
{ ok: true, value: resources.created },
|
||||
{ ok: true, value: resources.first },
|
||||
]);
|
||||
const gateway = createReferenceHttpGateway(scripted.binding);
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
await expect(
|
||||
@@ -40,67 +69,37 @@ describe("reference HTTP operation gateway", () => {
|
||||
gateway.get("reference-1", { signal }),
|
||||
).resolves.toEqual({ ok: true, value: resources.first });
|
||||
|
||||
expect(execute.mock.calls).toEqual([
|
||||
expect(scripted.calls).toEqual([
|
||||
[
|
||||
{
|
||||
operationId: "LIST_REFERENCE_RESOURCES",
|
||||
routeId: "REFERENCE_RESOURCE_LIST",
|
||||
searchParams: {
|
||||
cursor: "next",
|
||||
limit: 20,
|
||||
tags: ["active"],
|
||||
},
|
||||
signal,
|
||||
},
|
||||
"LIST_REFERENCE_RESOURCES",
|
||||
{ cursor: "next", limit: 20, tags: ["active"] },
|
||||
{ signal },
|
||||
],
|
||||
[
|
||||
{
|
||||
operationId: "CREATE_REFERENCE_RESOURCE",
|
||||
routeId: "REFERENCE_RESOURCE_LIST",
|
||||
body: { name: "Created", note: "safe note" },
|
||||
},
|
||||
"CREATE_REFERENCE_RESOURCE",
|
||||
{ name: "Created", note: "safe note" },
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
{
|
||||
operationId: "GET_REFERENCE_RESOURCE",
|
||||
routeId: "REFERENCE_RESOURCE_DETAIL",
|
||||
pathParams: { resourceId: "reference-1" },
|
||||
signal,
|
||||
},
|
||||
"GET_REFERENCE_RESOURCE",
|
||||
{ resourceId: "reference-1" },
|
||||
{ signal },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves a validated raw failure without casting it into success", async () => {
|
||||
it("preserves a capability-normalized failure across the feature gateway", async () => {
|
||||
const failure = createFailure(
|
||||
"SCHEMA_MISMATCH",
|
||||
"GET_REFERENCE_RESOURCE",
|
||||
0,
|
||||
);
|
||||
const execute = vi
|
||||
.fn<RawReferenceHttpExecutor["execute"]>()
|
||||
.mockResolvedValue({ ok: false, error: failure });
|
||||
const gateway = createReferenceHttpGateway({ execute });
|
||||
const scripted = scriptedBinding([{ ok: false, error: failure }]);
|
||||
const gateway = createReferenceHttpGateway(scripted.binding);
|
||||
|
||||
await expect(gateway.get("invalid")).resolves.toEqual({
|
||||
ok: false,
|
||||
error: failure,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a raw success does not match its operation result", async () => {
|
||||
const execute = vi
|
||||
.fn<RawReferenceHttpExecutor["execute"]>()
|
||||
.mockResolvedValue({ ok: true, value: { id: "not-a-list" } });
|
||||
const gateway = createReferenceHttpGateway({ execute });
|
||||
|
||||
await expect(gateway.list({ limit: 20 })).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "MAPPING_CONTRACT_VIOLATION",
|
||||
code: "BOUND_RESULT_TYPE_MISMATCH",
|
||||
operationId: "LIST_REFERENCE_RESOURCES",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+6
-7
@@ -1,9 +1,8 @@
|
||||
import type { RawReferenceHttpExecutor } from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
|
||||
import type { ReferenceHttpBinding } from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts";
|
||||
|
||||
declare const http: RawReferenceHttpExecutor;
|
||||
declare const http: ReferenceHttpBinding;
|
||||
|
||||
http.execute({
|
||||
operationId: "GET_REFERENCE_RESOURCE",
|
||||
routeId: "REFERENCE_RESOURCE_LIST",
|
||||
pathParams: { resourceId: "resource-1" },
|
||||
});
|
||||
const listInput = { limit: 20 };
|
||||
|
||||
// GET is bound to { resourceId: string }; list input must be rejected.
|
||||
http.execute("GET_REFERENCE_RESOURCE", listInput);
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { Result } from "../../../src/application/result.ts";
|
||||
import type { Result } from "../../../src/contracts/result.ts";
|
||||
|
||||
export function invalidUnwrap(result: Result<number, string>): number {
|
||||
return result.value;
|
||||
|
||||
+16
-16
@@ -11,41 +11,41 @@ import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
CiGateArtifact,
|
||||
CiGateArtifactSchema,
|
||||
} from "../../scripts/contracts/ci-gates.ts";
|
||||
import { parseCiGateContract } from "../../scripts/contracts/ci-gates.ts";
|
||||
} from "../../../scripts/contracts/ci-gates.ts";
|
||||
import { parseCiGateContract } from "../../../scripts/contracts/ci-gates.ts";
|
||||
import {
|
||||
hasCiArtifactSemanticValidator,
|
||||
readBoundedRegularFile,
|
||||
validateCiArtifact,
|
||||
} from "../../scripts/lib/ci-artifact-validator.ts";
|
||||
import { writeCiGateLogAtomic } from "../../scripts/lib/ci-gate-log.ts";
|
||||
import { linkFixtureNodeModules } from "../../scripts/lib/fixture-node-modules.ts";
|
||||
import { copyReleaseEvidenceTree } from "../../scripts/lib/removal-fixture.ts";
|
||||
import { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../scripts/lib/ci-candidate-archive.ts";
|
||||
} from "../../../scripts/lib/ci-artifact-validator.ts";
|
||||
import { writeCiGateLogAtomic } from "../../../scripts/lib/ci-gate-log.ts";
|
||||
import { linkFixtureNodeModules } from "../../../scripts/lib/fixture-node-modules.ts";
|
||||
import { copyReleaseEvidenceTree } from "../../../scripts/lib/removal-fixture.ts";
|
||||
import { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../../scripts/lib/ci-candidate-archive.ts";
|
||||
import {
|
||||
CANDIDATE_ARCHIVE_USAGE,
|
||||
parseCandidateArchiveArguments,
|
||||
} from "../../scripts/lib/ci-candidate-archive-cli.ts";
|
||||
import { validateProviderUpload } from "../../scripts/lib/provider-upload-validator.ts";
|
||||
import { verifyExactPromotionBundle } from "../../scripts/lib/exact-promotion-bundle.ts";
|
||||
} from "../../../scripts/lib/ci-candidate-archive-cli.ts";
|
||||
import { validateProviderUpload } from "../../../scripts/lib/provider-upload-validator.ts";
|
||||
import { verifyExactPromotionBundle } from "../../../scripts/lib/exact-promotion-bundle.ts";
|
||||
import {
|
||||
cleanupFinalizedPromotion,
|
||||
stageVerifiedPromotion,
|
||||
} from "../../scripts/lib/promotion-stager.ts";
|
||||
import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts";
|
||||
} from "../../../scripts/lib/promotion-stager.ts";
|
||||
import { PROMOTED_FILE_NAMES } from "../../../scripts/contracts/promotion-artifacts.ts";
|
||||
import {
|
||||
providerEvidenceSignaturePayload,
|
||||
providerPublicKeyFingerprint,
|
||||
providerVerificationArtifactSchema,
|
||||
} from "../../scripts/lib/provider-evidence.ts";
|
||||
import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts";
|
||||
import { readProviderTrust } from "../../scripts/lib/provider-trust.ts";
|
||||
} from "../../../scripts/lib/provider-evidence.ts";
|
||||
import { localEvidenceAssessmentArtifactSchema } from "../../../scripts/contracts/release-artifacts.ts";
|
||||
import { readProviderTrust } from "../../../scripts/lib/provider-trust.ts";
|
||||
import {
|
||||
createReleaseCandidateManifest,
|
||||
LOCAL_EVIDENCE_ASSESSMENT_PATH,
|
||||
RELEASE_CANDIDATE_EVIDENCE_PATHS,
|
||||
RELEASE_CANDIDATE_MANIFEST_PATH,
|
||||
} from "../../scripts/lib/release-candidate.ts";
|
||||
} from "../../../scripts/lib/release-candidate.ts";
|
||||
|
||||
/**
|
||||
* Budget for the provider suites specifically. They spawn a systemd scope, a
|
||||
@@ -0,0 +1,168 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { loadCiGateContract } from "../../../scripts/contracts/ci-gates.ts";
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryRoots.splice(0).map((root) =>
|
||||
rm(root, { recursive: true, force: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe("CI-runner command-generated evidence freshness", () => {
|
||||
it("rejects stale command-generated evidence from a successful no-op producer", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "ci-gate-stale-evidence-"));
|
||||
temporaryRoots.push(root);
|
||||
await mkdir(path.join(root, "config/ci"), { recursive: true });
|
||||
await mkdir(path.join(root, "artifacts/tests"), { recursive: true });
|
||||
const contract = JSON.parse(
|
||||
JSON.stringify(await loadCiGateContract(process.cwd())),
|
||||
) as Record<string, any>;
|
||||
const command = contract.commands.find(
|
||||
(entry: Record<string, any>) => entry.id === "test-runtime-schema",
|
||||
);
|
||||
command.script = "test:stale-evidence-noop";
|
||||
const evidence = contract.artifacts.find(
|
||||
(entry: Record<string, any>) =>
|
||||
entry.path === "artifacts/tests/runtime-schema.xml",
|
||||
);
|
||||
const packageDocument = JSON.parse(
|
||||
await readFile("package.json", "utf8"),
|
||||
) as { scripts: Record<string, string> };
|
||||
packageDocument.scripts[command.script] = "true";
|
||||
await writeFile(
|
||||
path.join(root, "config/ci/gates.json"),
|
||||
`${JSON.stringify(contract)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(root, "package.json"),
|
||||
`${JSON.stringify(packageDocument)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(root, evidence.path),
|
||||
'<testsuite name="stale" tests="0" failures="0"/>\n',
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"],
|
||||
{ cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(
|
||||
await readFile(
|
||||
path.join(root, "artifacts/quality/gates/FE-GATE-004.txt"),
|
||||
"utf8",
|
||||
),
|
||||
).toMatch(/not freshly produced/i);
|
||||
});
|
||||
|
||||
it("accepts a fresh deterministic rewrite with identical evidence bytes", async () => {
|
||||
const root = await mkdtemp(
|
||||
path.join(tmpdir(), "ci-gate-identical-rewrite-"),
|
||||
);
|
||||
temporaryRoots.push(root);
|
||||
await mkdir(path.join(root, "config/ci"), { recursive: true });
|
||||
await mkdir(path.join(root, "artifacts/tests"), { recursive: true });
|
||||
const contract = JSON.parse(
|
||||
JSON.stringify(await loadCiGateContract(process.cwd())),
|
||||
) as Record<string, any>;
|
||||
const command = contract.commands.find(
|
||||
(entry: Record<string, any>) => entry.id === "test-runtime-schema",
|
||||
);
|
||||
command.script = "test:identical-evidence-rewrite";
|
||||
const evidence = contract.artifacts.find(
|
||||
(entry: Record<string, any>) =>
|
||||
entry.path === "artifacts/tests/runtime-schema.xml",
|
||||
);
|
||||
const evidenceBytes =
|
||||
'<testsuite name="deterministic" tests="0" failures="0"/>\n';
|
||||
const packageDocument = JSON.parse(
|
||||
await readFile("package.json", "utf8"),
|
||||
) as { scripts: Record<string, string> };
|
||||
packageDocument.scripts[command.script] =
|
||||
`node -e 'require("node:fs").writeFileSync("${evidence.path}", Buffer.from("${Buffer.from(evidenceBytes).toString("base64")}", "base64"))'`;
|
||||
await writeFile(
|
||||
path.join(root, "config/ci/gates.json"),
|
||||
`${JSON.stringify(contract)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(root, "package.json"),
|
||||
`${JSON.stringify(packageDocument)}\n`,
|
||||
);
|
||||
await writeFile(path.join(root, evidence.path), evidenceBytes);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"],
|
||||
{ cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/FE-GATE-004 runtime-schema: PASS/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CI-runner gate output budget", () => {
|
||||
it("caps aggregate gate output at the log schema before later commands can accumulate", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "ci-gate-output-budget-"));
|
||||
temporaryRoots.push(root);
|
||||
await mkdir(path.join(root, "config/ci"), { recursive: true });
|
||||
const contract = JSON.parse(
|
||||
JSON.stringify(await loadCiGateContract(process.cwd())),
|
||||
) as Record<string, any>;
|
||||
const gate = contract.gates.find(
|
||||
(entry: Record<string, any>) => entry.id === "FE-GATE-001",
|
||||
);
|
||||
const command = contract.commands.find(
|
||||
(entry: Record<string, any>) => entry.id === gate.commandIds[0],
|
||||
);
|
||||
command.script = "test:huge-output";
|
||||
const logArtifact = contract.artifacts.find(
|
||||
(entry: Record<string, any>) => entry.id === gate.logArtifactId,
|
||||
);
|
||||
const logSchema = contract.artifactSchemas.find(
|
||||
(entry: Record<string, any>) => entry.id === logArtifact.schemaId,
|
||||
);
|
||||
logSchema.maxBytes = 8_192;
|
||||
const packageDocument = JSON.parse(
|
||||
await readFile("package.json", "utf8"),
|
||||
) as { scripts: Record<string, string> };
|
||||
packageDocument.scripts["test:huge-output"] =
|
||||
"node -e \"process.stdout.write('x'.repeat(20000))\"";
|
||||
await writeFile(
|
||||
path.join(root, "config/ci/gates.json"),
|
||||
`${JSON.stringify(contract)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(root, "package.json"),
|
||||
`${JSON.stringify(packageDocument)}\n`,
|
||||
);
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-001"],
|
||||
{
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, CI: "false" },
|
||||
timeout: 15_000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
const log = await readFile(path.join(root, logArtifact.path));
|
||||
expect(log.byteLength).toBeLessThanOrEqual(8_192);
|
||||
expect(log.toString("utf8")).toMatch(
|
||||
/aggregate output|INFRASTRUCTURE_FAILURE/i,
|
||||
);
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { runProviderProcess } from "../../../scripts/lib/provider-process-runner.ts";
|
||||
|
||||
describe("CI-runner provider process-group lifecycle", () => {
|
||||
it("kills and reaps a stubborn provider process group including its descendant", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "provider-process-group-"));
|
||||
const descendantPidPath = path.join(root, "descendant.pid");
|
||||
try {
|
||||
const source = [
|
||||
"const { spawn } = require('node:child_process');",
|
||||
"const { writeFileSync } = require('node:fs');",
|
||||
"const child = spawn(process.execPath, ['-e', `process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)`], { stdio: 'ignore' });",
|
||||
"writeFileSync(process.env.DESCENDANT_PID_PATH, String(child.pid));",
|
||||
"process.on('SIGTERM', () => {});",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n");
|
||||
|
||||
const running = runProviderProcess({
|
||||
executable: process.execPath,
|
||||
arguments: ["-e", source],
|
||||
environment: {
|
||||
PATH: process.env.PATH,
|
||||
DESCENDANT_PID_PATH: descendantPidPath,
|
||||
},
|
||||
timeoutMs: 250,
|
||||
});
|
||||
|
||||
await expect(running).rejects.toThrow(/timed out.*process close/u);
|
||||
const descendantPid = Number(await readFile(descendantPidPath, "utf8"));
|
||||
expect(Number.isSafeInteger(descendantPid) && descendantPid > 0).toBe(true);
|
||||
expect(() => process.kill(descendantPid, 0)).toThrow(/ESRCH|no such process/u);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type BrowserRpcStreamFrame,
|
||||
type BrowserRpcTransport,
|
||||
} from "../../../src/adapters/browser-rpc/index.ts";
|
||||
import type { Result } from "../../../src/application/result.ts";
|
||||
import type { Result } from "../../../src/contracts/result.ts";
|
||||
import type { AppFailure } from "../../../src/contracts/errors.ts";
|
||||
import {
|
||||
MAPPERS,
|
||||
|
||||
@@ -189,11 +189,11 @@ describe("CI gate contract", () => {
|
||||
),
|
||||
);
|
||||
expect(contract.jobs).toHaveLength(9);
|
||||
expect(contract.commands).toHaveLength(82);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(94);
|
||||
expect(contract.commands).toHaveLength(84);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(96);
|
||||
expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(86);
|
||||
expect(contract.artifacts).toHaveLength(107);
|
||||
expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(88);
|
||||
expect(contract.artifacts).toHaveLength(109);
|
||||
expect(contract.stages).toHaveLength(5);
|
||||
expect(contract.retention.classes).toHaveLength(5);
|
||||
expect(index.gates.get("FE-GATE-015")?.commandIds).toHaveLength(2);
|
||||
@@ -1335,106 +1335,6 @@ describe("CI gate contract", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("caps aggregate gate output at the log schema before later commands can accumulate", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "ci-gate-output-budget-"));
|
||||
temporaryRoots.push(root);
|
||||
await mkdir(path.join(root, "config/ci"), { recursive: true });
|
||||
const contract = JSON.parse(JSON.stringify(await loadCiGateContract(process.cwd()))) as Record<string, any>;
|
||||
const gate = contract.gates.find((entry: any) => entry.id === "FE-GATE-001");
|
||||
const command = contract.commands.find((entry: any) => entry.id === gate.commandIds[0]);
|
||||
command.script = "test:huge-output";
|
||||
const logArtifact = contract.artifacts.find((entry: any) => entry.id === gate.logArtifactId);
|
||||
const logSchema = contract.artifactSchemas.find((entry: any) => entry.id === logArtifact.schemaId);
|
||||
logSchema.maxBytes = 8_192;
|
||||
const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as { scripts: Record<string, string> };
|
||||
packageDocument.scripts["test:huge-output"] = "node -e \"process.stdout.write('x'.repeat(20000))\"";
|
||||
await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`);
|
||||
await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`);
|
||||
const environment = { ...process.env, CI: "false" };
|
||||
const result = spawnSync(process.execPath, [path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-001"], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
env: environment,
|
||||
timeout: 15_000,
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
const log = await readFile(path.join(root, logArtifact.path));
|
||||
expect(log.byteLength).toBeLessThanOrEqual(8_192);
|
||||
expect(log.toString("utf8")).toMatch(/aggregate output|INFRASTRUCTURE_FAILURE/i);
|
||||
}, 20_000);
|
||||
|
||||
it("rejects stale command-generated evidence from a successful no-op producer", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "ci-gate-stale-evidence-"));
|
||||
temporaryRoots.push(root);
|
||||
await mkdir(path.join(root, "config/ci"), { recursive: true });
|
||||
await mkdir(path.join(root, "artifacts/tests"), { recursive: true });
|
||||
const contract = JSON.parse(
|
||||
JSON.stringify(await loadCiGateContract(process.cwd())),
|
||||
) as Record<string, any>;
|
||||
const command = contract.commands.find(
|
||||
(entry: Record<string, any>) => entry.id === "test-runtime-schema",
|
||||
);
|
||||
command.script = "test:stale-evidence-noop";
|
||||
const evidence = contract.artifacts.find(
|
||||
(entry: Record<string, any>) => entry.path === "artifacts/tests/runtime-schema.xml",
|
||||
);
|
||||
const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
packageDocument.scripts[command.script] = "true";
|
||||
await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`);
|
||||
await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`);
|
||||
await writeFile(
|
||||
path.join(root, evidence.path),
|
||||
'<testsuite name="stale" tests="0" failures="0"/>\n',
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"],
|
||||
{ cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(
|
||||
await readFile(path.join(root, "artifacts/quality/gates/FE-GATE-004.txt"), "utf8"),
|
||||
).toMatch(/not freshly produced/i);
|
||||
});
|
||||
|
||||
it("accepts a fresh deterministic rewrite with identical evidence bytes", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "ci-gate-identical-rewrite-"));
|
||||
temporaryRoots.push(root);
|
||||
await mkdir(path.join(root, "config/ci"), { recursive: true });
|
||||
await mkdir(path.join(root, "artifacts/tests"), { recursive: true });
|
||||
const contract = JSON.parse(
|
||||
JSON.stringify(await loadCiGateContract(process.cwd())),
|
||||
) as Record<string, any>;
|
||||
const command = contract.commands.find(
|
||||
(entry: Record<string, any>) => entry.id === "test-runtime-schema",
|
||||
);
|
||||
command.script = "test:identical-evidence-rewrite";
|
||||
const evidence = contract.artifacts.find(
|
||||
(entry: Record<string, any>) => entry.path === "artifacts/tests/runtime-schema.xml",
|
||||
);
|
||||
const evidenceBytes = '<testsuite name="deterministic" tests="0" failures="0"/>\n';
|
||||
const packageDocument = JSON.parse(await readFile("package.json", "utf8")) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
packageDocument.scripts[command.script] =
|
||||
`node -e 'require("node:fs").writeFileSync("${evidence.path}", Buffer.from("${Buffer.from(evidenceBytes).toString("base64")}", "base64"))'`;
|
||||
await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`);
|
||||
await writeFile(path.join(root, "package.json"), `${JSON.stringify(packageDocument)}\n`);
|
||||
await writeFile(path.join(root, evidence.path), evidenceBytes);
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[path.resolve("scripts/run-ci-gate.ts"), "FE-GATE-004"],
|
||||
{ cwd: root, encoding: "utf8", env: { ...process.env, CI: "false" } },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toMatch(/FE-GATE-004 runtime-schema: PASS/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CI workflow generation", () => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isVersionCompatible,
|
||||
parseNumericVersion,
|
||||
verifyCompatibilityTuple,
|
||||
} from "../../src/application/policies/compatibility.ts";
|
||||
} from "../../src/contracts/compatibility.ts";
|
||||
|
||||
describe("contract compatibility", () => {
|
||||
it("uses numeric version parsing rather than lexical comparison", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/cursor-pagination-runtime.ts";
|
||||
import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/index.ts";
|
||||
|
||||
const profile = {
|
||||
profileId: "bounded-cursor-v1",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
canRetryTransport,
|
||||
isRetryableHttpStatus,
|
||||
isRetryableSemantics,
|
||||
jitteredDelay,
|
||||
retryDelayFor,
|
||||
} from "../../src/adapters/http/http-retry-lifecycle.ts";
|
||||
|
||||
describe("HTTP retry lifecycle policy", () => {
|
||||
it("allows KEYED replay only before a physical dispatch", () => {
|
||||
expect(canRetryTransport("KEYED", "PREPARING")).toBe(true);
|
||||
expect(canRetryTransport("KEYED", "READY_TO_SEND")).toBe(true);
|
||||
expect(canRetryTransport("KEYED", "DISPATCHED")).toBe(false);
|
||||
expect(canRetryTransport("KEYED", "RESPONSE_HEADERS")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps SAFE and IDEMPOTENT retryable while NEVER is terminal", () => {
|
||||
expect(isRetryableSemantics("SAFE")).toBe(true);
|
||||
expect(isRetryableSemantics("IDEMPOTENT")).toBe(true);
|
||||
expect(isRetryableSemantics("KEYED")).toBe(false);
|
||||
expect(isRetryableSemantics("NEVER")).toBe(false);
|
||||
|
||||
expect(canRetryTransport("SAFE", "DISPATCHED")).toBe(true);
|
||||
expect(canRetryTransport("IDEMPOTENT", "READING_BODY")).toBe(true);
|
||||
expect(canRetryTransport("NEVER", "PREPARING")).toBe(false);
|
||||
});
|
||||
|
||||
it("owns the closed retryable status vocabulary", () => {
|
||||
for (const status of [408, 425, 429, 502, 503, 504]) {
|
||||
expect(isRetryableHttpStatus(status)).toBe(true);
|
||||
}
|
||||
for (const status of [400, 401, 403, 404, 409, 500, 501]) {
|
||||
expect(isRetryableHttpStatus(status)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses bounded full jitter and rejects excessive Retry-After", () => {
|
||||
expect(jitteredDelay(0, () => 0)).toBe(0);
|
||||
expect(jitteredDelay(0, () => 0.5)).toBe(125);
|
||||
expect(jitteredDelay(8, () => 0.5)).toBe(1_000);
|
||||
|
||||
expect(retryDelayFor(null, 0, () => 0.5)).toBe(125);
|
||||
expect(retryDelayFor(400, 0, () => 0.5)).toBe(400);
|
||||
expect(retryDelayFor(5_001, 0, () => 0.5)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,652 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
ImageProbeRequest,
|
||||
} from "../../src/application/ports/browser-transfer/image-cdn.ts";
|
||||
import { createBrowserImageProbe } from "../../src/adapters/browser-transfer/image-cdn/browser-image-probe.ts";
|
||||
import {
|
||||
avifBytes,
|
||||
jpegBytes,
|
||||
manualImageProbeScheduler,
|
||||
pngBytes,
|
||||
publicImageHeaders,
|
||||
responseAt,
|
||||
webpBytes,
|
||||
} from "./image-cdn-test-fixture.ts";
|
||||
|
||||
describe("browser image probe", () => {
|
||||
const imageUrl =
|
||||
"https://images.example.test/v1/assets/a/rev?format=png";
|
||||
const request = (
|
||||
overrides: Partial<ImageProbeRequest> = {},
|
||||
): ImageProbeRequest => ({
|
||||
absoluteUrl: imageUrl,
|
||||
expectedMediaType: "image/png",
|
||||
expectedWidth: 640,
|
||||
expectedHeight: 360,
|
||||
maxEncodedBytes: 1_024,
|
||||
maxDecodedPixels: 230_400,
|
||||
maxDecodedBytes: 921_600,
|
||||
delivery: "PUBLIC_IMMUTABLE",
|
||||
minimumPublicMaxAgeSeconds: 31_536_000,
|
||||
referrerPolicy: "no-referrer",
|
||||
signal: new AbortController().signal,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("parses all supported static headers before decode and closes each bitmap", async () => {
|
||||
const samples = [
|
||||
{
|
||||
mediaType: "image/png" as const,
|
||||
bytes: pngBytes(640, 360),
|
||||
},
|
||||
{
|
||||
mediaType: "image/jpeg" as const,
|
||||
bytes: jpegBytes(640, 360),
|
||||
},
|
||||
{
|
||||
mediaType: "image/webp" as const,
|
||||
bytes: webpBytes(640, 360),
|
||||
},
|
||||
{
|
||||
mediaType: "image/avif" as const,
|
||||
bytes: avifBytes(640, 360),
|
||||
},
|
||||
];
|
||||
const close = vi.fn();
|
||||
for (const sample of samples) {
|
||||
const exactUrl = imageUrl.replace(
|
||||
"format=png",
|
||||
`format=${sample.mediaType.slice("image/".length)}`,
|
||||
);
|
||||
const fetcher = vi.fn(async () =>
|
||||
responseAt(exactUrl, sample.bytes, {
|
||||
status: 200,
|
||||
headers: publicImageHeaders(
|
||||
sample.mediaType,
|
||||
sample.bytes.byteLength,
|
||||
),
|
||||
}),
|
||||
);
|
||||
const probe = createBrowserImageProbe({
|
||||
fetcher: fetcher as typeof fetch,
|
||||
createBitmap: vi.fn(async () => ({
|
||||
width: 640,
|
||||
height: 360,
|
||||
close,
|
||||
})),
|
||||
});
|
||||
|
||||
await expect(
|
||||
probe.probe(
|
||||
request({
|
||||
absoluteUrl: exactUrl,
|
||||
expectedMediaType: sample.mediaType,
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
absoluteUrl: exactUrl,
|
||||
mediaType: sample.mediaType,
|
||||
encodedBytes: sample.bytes.byteLength,
|
||||
decodedWidth: 640,
|
||||
decodedHeight: 360,
|
||||
},
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
exactUrl,
|
||||
expect.objectContaining({
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
mode: "cors",
|
||||
cache: "no-store",
|
||||
referrerPolicy: "no-referrer",
|
||||
}),
|
||||
);
|
||||
}
|
||||
expect(close).toHaveBeenCalledTimes(samples.length);
|
||||
});
|
||||
|
||||
it("fails closed on duplicate/conflicting cache directives and oversized bodies", async () => {
|
||||
const png = pngBytes(640, 360);
|
||||
const createBitmap = vi.fn(async () => ({
|
||||
width: 640,
|
||||
height: 360,
|
||||
close: vi.fn(),
|
||||
}));
|
||||
for (const cacheControl of [
|
||||
// BT-IMG-02. Unmatched quotes must not be unwrapped into a bare number.
|
||||
'public, max-age="31536000, immutable',
|
||||
'public, max-age=31536000", immutable',
|
||||
'public, max-age="31536000\\", immutable',
|
||||
"public, public, max-age=31536000, immutable",
|
||||
"public, max-age=31536000, s-maxage=60, immutable",
|
||||
"public, max-age=31536000, immutable, must-revalidate",
|
||||
"public=1, max-age=31536000, immutable",
|
||||
"public, max-age=31536000, immutable=true",
|
||||
]) {
|
||||
const probe = createBrowserImageProbe({
|
||||
fetcher: (async () =>
|
||||
responseAt(imageUrl, png, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control": cacheControl,
|
||||
"content-type": "image/png",
|
||||
},
|
||||
})) as typeof fetch,
|
||||
createBitmap,
|
||||
});
|
||||
await expect(probe.probe(request())).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
}
|
||||
|
||||
const probe = createBrowserImageProbe({
|
||||
fetcher: (async () =>
|
||||
responseAt(imageUrl, new Uint8Array(2_048), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control":
|
||||
"public, max-age=31536000, immutable",
|
||||
"content-type": "image/png",
|
||||
},
|
||||
})) as typeof fetch,
|
||||
createBitmap,
|
||||
});
|
||||
await expect(probe.probe(request())).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
expect(createBitmap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-identity content encoding and mismatched declared lengths", async () => {
|
||||
const png = pngBytes(640, 360);
|
||||
const createBitmap = vi.fn();
|
||||
const cases = [
|
||||
{
|
||||
headers: {
|
||||
"content-encoding": "gzip",
|
||||
"content-length": String(png.byteLength),
|
||||
},
|
||||
code: "POLICY_REJECTED",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"content-length": String(png.byteLength + 1),
|
||||
},
|
||||
code: "INTEGRITY_FAILED",
|
||||
},
|
||||
];
|
||||
for (const invalid of cases) {
|
||||
const headers = publicImageHeaders("image/png");
|
||||
for (const [name, value] of Object.entries(invalid.headers)) {
|
||||
headers.set(name, value);
|
||||
}
|
||||
const probe = createBrowserImageProbe({
|
||||
fetcher: (async () =>
|
||||
responseAt(imageUrl, png, {
|
||||
status: 200,
|
||||
headers,
|
||||
})) as typeof fetch,
|
||||
createBitmap,
|
||||
});
|
||||
await expect(probe.probe(request())).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: invalid.code },
|
||||
});
|
||||
}
|
||||
expect(createBitmap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malicious dimensions and animated PNG/WebP before native decode", async () => {
|
||||
const createBitmap = vi.fn(async () => ({
|
||||
width: 640,
|
||||
height: 360,
|
||||
close: vi.fn(),
|
||||
}));
|
||||
const cases = [
|
||||
{
|
||||
bytes: pngBytes(20_000, 20_000),
|
||||
mediaType: "image/png" as const,
|
||||
code: "LIMIT_EXCEEDED",
|
||||
},
|
||||
{
|
||||
bytes: pngBytes(320, 180),
|
||||
mediaType: "image/png" as const,
|
||||
code: "INTEGRITY_FAILED",
|
||||
},
|
||||
{
|
||||
bytes: jpegBytes(320, 180),
|
||||
mediaType: "image/jpeg" as const,
|
||||
code: "INTEGRITY_FAILED",
|
||||
},
|
||||
{
|
||||
bytes: webpBytes(10_000, 10_000),
|
||||
mediaType: "image/webp" as const,
|
||||
code: "LIMIT_EXCEEDED",
|
||||
},
|
||||
{
|
||||
bytes: avifBytes(20_000, 20_000),
|
||||
mediaType: "image/avif" as const,
|
||||
code: "LIMIT_EXCEEDED",
|
||||
},
|
||||
{
|
||||
bytes: pngBytes(640, 360, true),
|
||||
mediaType: "image/png" as const,
|
||||
code: "INTEGRITY_FAILED",
|
||||
},
|
||||
{
|
||||
bytes: webpBytes(640, 360, true),
|
||||
mediaType: "image/webp" as const,
|
||||
code: "INTEGRITY_FAILED",
|
||||
},
|
||||
{
|
||||
bytes: avifBytes(640, 360, "avis"),
|
||||
mediaType: "image/avif" as const,
|
||||
code: "INTEGRITY_FAILED",
|
||||
},
|
||||
];
|
||||
for (const malicious of cases) {
|
||||
const url = imageUrl.replace(
|
||||
"format=png",
|
||||
`format=${malicious.mediaType.slice("image/".length)}`,
|
||||
);
|
||||
const probe = createBrowserImageProbe({
|
||||
fetcher: (async () =>
|
||||
responseAt(url, malicious.bytes, {
|
||||
status: 200,
|
||||
headers: publicImageHeaders(
|
||||
malicious.mediaType,
|
||||
malicious.bytes.byteLength,
|
||||
),
|
||||
})) as typeof fetch,
|
||||
createBitmap,
|
||||
});
|
||||
await expect(
|
||||
probe.probe(
|
||||
request({
|
||||
absoluteUrl: url,
|
||||
expectedMediaType: malicious.mediaType,
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: malicious.code },
|
||||
});
|
||||
}
|
||||
expect(createBitmap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enforces private no-store, omitted credentials and the exact final URL", async () => {
|
||||
const png = pngBytes(640, 360);
|
||||
const fetcher = vi.fn(async () =>
|
||||
responseAt(imageUrl, png, {
|
||||
status: 200,
|
||||
headers: {
|
||||
// TR-RR-09. A private response carries `no-store` and nothing else
|
||||
// that describes cacheability.
|
||||
"cache-control": "no-store",
|
||||
"content-type": "image/png",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const probe = createBrowserImageProbe({
|
||||
fetcher: fetcher as typeof fetch,
|
||||
createBitmap: async () => ({
|
||||
width: 640,
|
||||
height: 360,
|
||||
close: vi.fn(),
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
probe.probe(
|
||||
request({
|
||||
delivery: "PRIVATE_SIGNED",
|
||||
minimumPublicMaxAgeSeconds: 0,
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
imageUrl,
|
||||
expect.objectContaining({
|
||||
cache: "no-store",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
}),
|
||||
);
|
||||
|
||||
for (const response of [
|
||||
responseAt(imageUrl, png, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control": "private, no-store=value",
|
||||
"content-type": "image/png",
|
||||
},
|
||||
}),
|
||||
responseAt(imageUrl, png, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control": "public, no-store",
|
||||
"content-type": "image/png",
|
||||
},
|
||||
}),
|
||||
responseAt(
|
||||
"https://images.example.test/v1/assets/other",
|
||||
png,
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control": "private, no-store",
|
||||
"content-type": "image/png",
|
||||
},
|
||||
},
|
||||
),
|
||||
]) {
|
||||
const rejectingProbe = createBrowserImageProbe({
|
||||
fetcher: (async () => response) as typeof fetch,
|
||||
createBitmap: async () => ({
|
||||
width: 640,
|
||||
height: 360,
|
||||
close: vi.fn(),
|
||||
}),
|
||||
});
|
||||
await expect(
|
||||
rejectingProbe.probe(
|
||||
request({
|
||||
delivery: "PRIVATE_SIGNED",
|
||||
minimumPublicMaxAgeSeconds: 0,
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
}
|
||||
|
||||
await expect(
|
||||
probe.probe(
|
||||
request({
|
||||
expectedMediaType: "image/svg+xml",
|
||||
} as unknown as Partial<ImageProbeRequest>),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TR-RR-09. The recorded BT-IMG-02 contract for a private response is a
|
||||
* fail-closed matrix. Accepting `no-store` next to a directive that describes
|
||||
* cacheability lets a self-contradictory policy read as acceptable.
|
||||
*/
|
||||
it("applies the full private Cache-Control matrix", async () => {
|
||||
const png = pngBytes(640, 360);
|
||||
const probeWith = async (cacheControl: string) => {
|
||||
const probe = createBrowserImageProbe({
|
||||
fetcher: (async () =>
|
||||
responseAt(imageUrl, png, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control": cacheControl,
|
||||
"content-type": "image/png",
|
||||
},
|
||||
})) as typeof fetch,
|
||||
createBitmap: async () => ({
|
||||
width: 640,
|
||||
height: 360,
|
||||
close: vi.fn(),
|
||||
}),
|
||||
});
|
||||
return await probe.probe(
|
||||
request({
|
||||
delivery: "PRIVATE_SIGNED",
|
||||
minimumPublicMaxAgeSeconds: 0,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// Only `no-store`, plus a syntactically valid unknown extension.
|
||||
expect(await probeWith("no-store")).toMatchObject({ ok: true });
|
||||
expect(await probeWith('no-store, x-vendor="a,b"')).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
for (const companion of [
|
||||
"public",
|
||||
"private",
|
||||
"immutable",
|
||||
"max-age=60",
|
||||
"s-maxage=60",
|
||||
"no-cache",
|
||||
"must-revalidate",
|
||||
"proxy-revalidate",
|
||||
]) {
|
||||
expect(await probeWith(`no-store, ${companion}`)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
}
|
||||
|
||||
for (const withoutNoStore of ["private", "no-cache", "max-age=0"]) {
|
||||
expect(await probeWith(withoutNoStore)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("times out a stalled body, aborts the composed signal and cancels its reader", async () => {
|
||||
const manual = manualImageProbeScheduler();
|
||||
const cancel = vi.fn(async () => undefined);
|
||||
const releaseLock = vi.fn();
|
||||
const read = vi.fn(
|
||||
() =>
|
||||
new Promise<ReadableStreamReadResult<Uint8Array>>(
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
const response = {
|
||||
body: {
|
||||
getReader: () => ({ cancel, read, releaseLock }),
|
||||
},
|
||||
headers: publicImageHeaders("image/png"),
|
||||
ok: true,
|
||||
redirected: false,
|
||||
status: 200,
|
||||
type: "cors",
|
||||
url: imageUrl,
|
||||
} as unknown as Response;
|
||||
const fetcher = vi.fn(
|
||||
async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
response,
|
||||
);
|
||||
const probe = createBrowserImageProbe({
|
||||
fetcher: fetcher as typeof fetch,
|
||||
createBitmap: vi.fn(),
|
||||
timeoutMs: 1_000,
|
||||
scheduler: manual.scheduler,
|
||||
});
|
||||
const probeRequest = request();
|
||||
const pending = probe.probe(probeRequest);
|
||||
await vi.waitFor(() => {
|
||||
expect(read).toHaveBeenCalledOnce();
|
||||
});
|
||||
const composedSignal = fetcher.mock.calls[0]?.[1]?.signal as
|
||||
| AbortSignal
|
||||
| null
|
||||
| undefined;
|
||||
expect(composedSignal).not.toBe(probeRequest.signal);
|
||||
manual.fire();
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAVAILABLE",
|
||||
retryable: true,
|
||||
recovery: "RETRY",
|
||||
},
|
||||
});
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
expect(releaseLock).toHaveBeenCalledOnce();
|
||||
expect(composedSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("times out stalled decode and closes a bitmap that resolves late", async () => {
|
||||
const manual = manualImageProbeScheduler();
|
||||
const close = vi.fn();
|
||||
let finishDecode:
|
||||
((bitmap: {
|
||||
width: number;
|
||||
height: number;
|
||||
close(): void;
|
||||
}) => void) | undefined;
|
||||
const createBitmap = vi.fn(
|
||||
() =>
|
||||
new Promise<{
|
||||
width: number;
|
||||
height: number;
|
||||
close(): void;
|
||||
}>((resolve) => {
|
||||
finishDecode = resolve;
|
||||
}),
|
||||
);
|
||||
const png = pngBytes(640, 360);
|
||||
const probe = createBrowserImageProbe({
|
||||
fetcher: (async () =>
|
||||
responseAt(imageUrl, png, {
|
||||
status: 200,
|
||||
headers: publicImageHeaders("image/png", png.byteLength),
|
||||
})) as typeof fetch,
|
||||
createBitmap,
|
||||
timeoutMs: 1_000,
|
||||
scheduler: manual.scheduler,
|
||||
});
|
||||
const pending = probe.probe(request());
|
||||
await vi.waitFor(() => {
|
||||
expect(createBitmap).toHaveBeenCalledOnce();
|
||||
});
|
||||
manual.fire();
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE" },
|
||||
});
|
||||
|
||||
finishDecode?.({ width: 640, height: 360, close });
|
||||
await vi.waitFor(() => {
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* X-AUDIT-02. `probe()` promises a `BrowserDataResult`. A scheduler that
|
||||
* cannot install the probe deadline must close the probe inside that contract
|
||||
* rather than rejecting it, and must not leave the caller's listener behind.
|
||||
*/
|
||||
describe("scheduler boundary", () => {
|
||||
const trackedSignal = () => {
|
||||
const controller = new AbortController();
|
||||
const added: string[] = [];
|
||||
const removed: string[] = [];
|
||||
const add = controller.signal.addEventListener.bind(controller.signal);
|
||||
const remove = controller.signal.removeEventListener.bind(
|
||||
controller.signal,
|
||||
);
|
||||
Object.defineProperty(controller.signal, "addEventListener", {
|
||||
configurable: true,
|
||||
value: (type: string, ...rest: readonly unknown[]) => {
|
||||
added.push(type);
|
||||
return (add as (...args: readonly unknown[]) => unknown)(
|
||||
type,
|
||||
...rest,
|
||||
);
|
||||
},
|
||||
});
|
||||
Object.defineProperty(controller.signal, "removeEventListener", {
|
||||
configurable: true,
|
||||
value: (type: string, ...rest: readonly unknown[]) => {
|
||||
removed.push(type);
|
||||
return (remove as (...args: readonly unknown[]) => unknown)(
|
||||
type,
|
||||
...rest,
|
||||
);
|
||||
},
|
||||
});
|
||||
return { controller, added, removed };
|
||||
};
|
||||
|
||||
it("closes the probe when the scheduler cannot install the deadline", async () => {
|
||||
const fetcher = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
const { controller, added, removed } = trackedSignal();
|
||||
const probe = createBrowserImageProbe({
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
createBitmap: vi.fn(),
|
||||
timeoutMs: 1_000,
|
||||
scheduler: {
|
||||
setTimeout: () => {
|
||||
throw new TypeError("image scheduler install exploded");
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
probe.probe({ ...request(), signal: controller.signal }),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", retryable: true, recovery: "RETRY" },
|
||||
});
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
expect(added.filter((type) => type === "abort")).toHaveLength(1);
|
||||
expect(removed.filter((type) => type === "abort")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("starts no timer and no fetch for an already aborted caller", async () => {
|
||||
const fetcher = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
const setTimeout_ = vi.fn(() => 1);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const probe = createBrowserImageProbe({
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
createBitmap: vi.fn(),
|
||||
timeoutMs: 1_000,
|
||||
scheduler: { setTimeout: setTimeout_, clearTimeout: vi.fn() },
|
||||
});
|
||||
|
||||
await expect(
|
||||
probe.probe({ ...request(), signal: controller.signal }),
|
||||
).resolves.toMatchObject({ ok: false, error: { code: "ABORTED" } });
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
expect(setTimeout_).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the classified outcome when clearing the deadline throws", async () => {
|
||||
const png = pngBytes(640, 360);
|
||||
const probe = createBrowserImageProbe({
|
||||
fetcher: (async () =>
|
||||
responseAt(imageUrl, png, {
|
||||
status: 200,
|
||||
headers: publicImageHeaders("image/png", png.byteLength),
|
||||
})) as typeof fetch,
|
||||
createBitmap: vi.fn(async () => ({
|
||||
width: 640,
|
||||
height: 360,
|
||||
close: vi.fn(),
|
||||
})),
|
||||
timeoutMs: 1_000,
|
||||
scheduler: {
|
||||
setTimeout: (callback: () => void, milliseconds: number) =>
|
||||
setTimeout(callback, milliseconds),
|
||||
clearTimeout: () => {
|
||||
throw new TypeError("image scheduler clear exploded");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(probe.probe(request())).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createP256ImageCapabilityVerifier } from "../../src/adapters/browser-transfer/image-cdn/p256-image-capability-verifier.ts";
|
||||
import { base64Url } from "./image-cdn-test-fixture.ts";
|
||||
|
||||
describe("P-256 image capability verifier", () => {
|
||||
it("rejects an ECDSA public key on any curve other than P-256", async () => {
|
||||
const generated = await globalThis.crypto.subtle.generateKey(
|
||||
{ name: "ECDSA", namedCurve: "P-384" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
if (!("publicKey" in generated)) {
|
||||
throw new TypeError("Expected an ECDSA key pair.");
|
||||
}
|
||||
expect(() =>
|
||||
createP256ImageCapabilityVerifier({
|
||||
subtle: globalThis.crypto.subtle,
|
||||
publicKeys: [
|
||||
{
|
||||
keyId: "image-signing-wrong-curve",
|
||||
key: generated.publicKey,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(/public key binding/u);
|
||||
});
|
||||
|
||||
it("verifies the exact canonical payload and rejects tampering", async () => {
|
||||
const generated = await globalThis.crypto.subtle.generateKey(
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
if (!("privateKey" in generated)) {
|
||||
throw new TypeError("Expected an ECDSA key pair.");
|
||||
}
|
||||
const payload = new TextEncoder().encode(
|
||||
'["image-cdn-capability-v1","bound"]',
|
||||
);
|
||||
const payloadBuffer = new Uint8Array(payload.byteLength);
|
||||
payloadBuffer.set(payload);
|
||||
const signature = await globalThis.crypto.subtle.sign(
|
||||
{ name: "ECDSA", hash: "SHA-256" },
|
||||
generated.privateKey,
|
||||
payloadBuffer.buffer,
|
||||
);
|
||||
const verifier = createP256ImageCapabilityVerifier({
|
||||
subtle: globalThis.crypto.subtle,
|
||||
publicKeys: [
|
||||
{
|
||||
keyId: "image-signing-2026-01",
|
||||
key: generated.publicKey,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(verifier.acceptsKey("image-signing-2026-01")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(verifier.acceptsKey("image-signing-unknown")).toBe(
|
||||
false,
|
||||
);
|
||||
const signatureBase64Url = base64Url(
|
||||
new Uint8Array(signature),
|
||||
);
|
||||
await expect(
|
||||
verifier.verify({
|
||||
algorithm: "ECDSA_P256_SHA256",
|
||||
keyId: "image-signing-2026-01",
|
||||
canonicalPayload: payload,
|
||||
signatureBase64Url,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
const tampered = Uint8Array.from(payload);
|
||||
tampered[0] ^= 1;
|
||||
await expect(
|
||||
verifier.verify({
|
||||
algorithm: "ECDSA_P256_SHA256",
|
||||
keyId: "image-signing-2026-01",
|
||||
canonicalPayload: tampered,
|
||||
signatureBase64Url,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
import type {
|
||||
ImageProbeScheduler,
|
||||
} from "../../src/adapters/browser-transfer/image-cdn/browser-image-probe.ts";
|
||||
import type {
|
||||
ImageCapabilityVerificationScheduler,
|
||||
} from "../../src/adapters/browser-transfer/image-cdn/image-cdn-runtime.ts";
|
||||
|
||||
export function responseAt(
|
||||
url: string,
|
||||
body: Uint8Array,
|
||||
init: ResponseInit,
|
||||
): Response {
|
||||
const responseBytes = new Uint8Array(body.byteLength);
|
||||
responseBytes.set(body);
|
||||
const response = new Response(responseBytes.buffer, init);
|
||||
Object.defineProperty(response, "url", {
|
||||
configurable: false,
|
||||
enumerable: true,
|
||||
value: url,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export function publicImageHeaders(
|
||||
mediaType: string,
|
||||
contentLength?: number,
|
||||
): Headers {
|
||||
const headers = new Headers({
|
||||
"cache-control":
|
||||
"public, max-age=31536000, s-maxage=31536000, immutable",
|
||||
"content-type": mediaType,
|
||||
vary: "Accept-Encoding",
|
||||
});
|
||||
if (contentLength !== undefined) {
|
||||
headers.set("content-length", String(contentLength));
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function pngBytes(
|
||||
width: number,
|
||||
height: number,
|
||||
animated = false,
|
||||
): Uint8Array {
|
||||
const header = new Uint8Array(13);
|
||||
const headerView = new DataView(header.buffer);
|
||||
headerView.setUint32(0, width);
|
||||
headerView.setUint32(4, height);
|
||||
header[8] = 8;
|
||||
header[9] = 6;
|
||||
return concatenateBytes([
|
||||
Uint8Array.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]),
|
||||
pngChunk("IHDR", header),
|
||||
...(animated
|
||||
? [pngChunk("acTL", new Uint8Array(8))]
|
||||
: []),
|
||||
pngChunk("IDAT", new Uint8Array()),
|
||||
pngChunk("IEND", new Uint8Array()),
|
||||
]);
|
||||
}
|
||||
|
||||
function pngChunk(type: string, payload: Uint8Array): Uint8Array {
|
||||
const chunk = new Uint8Array(12 + payload.byteLength);
|
||||
const view = new DataView(chunk.buffer);
|
||||
view.setUint32(0, payload.byteLength);
|
||||
writeAscii(chunk, 4, type);
|
||||
chunk.set(payload, 8);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
export function jpegBytes(width: number, height: number): Uint8Array {
|
||||
return Uint8Array.from([
|
||||
0xff,
|
||||
0xd8,
|
||||
0xff,
|
||||
0xc0,
|
||||
0x00,
|
||||
0x11,
|
||||
0x08,
|
||||
(height >>> 8) & 0xff,
|
||||
height & 0xff,
|
||||
(width >>> 8) & 0xff,
|
||||
width & 0xff,
|
||||
0x03,
|
||||
0x01,
|
||||
0x11,
|
||||
0x00,
|
||||
0x02,
|
||||
0x11,
|
||||
0x00,
|
||||
0x03,
|
||||
0x11,
|
||||
0x00,
|
||||
0xff,
|
||||
0xda,
|
||||
]);
|
||||
}
|
||||
|
||||
export function webpBytes(
|
||||
width: number,
|
||||
height: number,
|
||||
animated = false,
|
||||
): Uint8Array {
|
||||
const chunkType = animated ? "VP8X" : "VP8 ";
|
||||
const payload = new Uint8Array(10);
|
||||
if (animated) {
|
||||
payload[0] = 0x02;
|
||||
writeUint24LittleEndian(payload, 4, width - 1);
|
||||
writeUint24LittleEndian(payload, 7, height - 1);
|
||||
} else {
|
||||
payload.set([0x9d, 0x01, 0x2a], 3);
|
||||
const view = new DataView(payload.buffer);
|
||||
view.setUint16(6, width, true);
|
||||
view.setUint16(8, height, true);
|
||||
}
|
||||
const chunk = concatenateBytes([
|
||||
asciiBytes(chunkType),
|
||||
littleEndianUint32(payload.byteLength),
|
||||
payload,
|
||||
]);
|
||||
return concatenateBytes([
|
||||
asciiBytes("RIFF"),
|
||||
littleEndianUint32(4 + chunk.byteLength),
|
||||
asciiBytes("WEBP"),
|
||||
chunk,
|
||||
]);
|
||||
}
|
||||
|
||||
export function avifBytes(
|
||||
width: number,
|
||||
height: number,
|
||||
brand = "avif",
|
||||
): Uint8Array {
|
||||
const fileType = isoBox(
|
||||
"ftyp",
|
||||
concatenateBytes([
|
||||
asciiBytes(brand),
|
||||
new Uint8Array(4),
|
||||
asciiBytes(brand),
|
||||
]),
|
||||
);
|
||||
const spatialExtent = new Uint8Array(12);
|
||||
const extentView = new DataView(spatialExtent.buffer);
|
||||
extentView.setUint32(4, width);
|
||||
extentView.setUint32(8, height);
|
||||
const primaryItem = new Uint8Array(6);
|
||||
new DataView(primaryItem.buffer).setUint16(4, 1);
|
||||
const itemInfoEntry = new Uint8Array(13);
|
||||
itemInfoEntry[0] = 2;
|
||||
const itemInfoView = new DataView(itemInfoEntry.buffer);
|
||||
itemInfoView.setUint16(4, 1);
|
||||
writeAscii(itemInfoEntry, 8, "av01");
|
||||
const itemInfo = new Uint8Array(6);
|
||||
new DataView(itemInfo.buffer).setUint16(4, 1);
|
||||
const propertyAssociation = new Uint8Array(12);
|
||||
const associationView = new DataView(
|
||||
propertyAssociation.buffer,
|
||||
);
|
||||
associationView.setUint32(4, 1);
|
||||
associationView.setUint16(8, 1);
|
||||
propertyAssociation[10] = 1;
|
||||
propertyAssociation[11] = 0x81;
|
||||
const properties = isoBox(
|
||||
"iprp",
|
||||
concatenateBytes([
|
||||
isoBox("ipco", isoBox("ispe", spatialExtent)),
|
||||
isoBox("ipma", propertyAssociation),
|
||||
]),
|
||||
);
|
||||
const metadata = isoBox(
|
||||
"meta",
|
||||
concatenateBytes([
|
||||
new Uint8Array(4),
|
||||
isoBox("pitm", primaryItem),
|
||||
isoBox(
|
||||
"iinf",
|
||||
concatenateBytes([
|
||||
itemInfo,
|
||||
isoBox("infe", itemInfoEntry),
|
||||
]),
|
||||
),
|
||||
properties,
|
||||
]),
|
||||
);
|
||||
return concatenateBytes([
|
||||
fileType,
|
||||
metadata,
|
||||
isoBox("mdat", Uint8Array.of(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
export function isoBox(type: string, payload: Uint8Array): Uint8Array {
|
||||
const box = new Uint8Array(8 + payload.byteLength);
|
||||
const view = new DataView(box.buffer);
|
||||
view.setUint32(0, box.byteLength);
|
||||
writeAscii(box, 4, type);
|
||||
box.set(payload, 8);
|
||||
return box;
|
||||
}
|
||||
|
||||
export function littleEndianUint32(value: number): Uint8Array {
|
||||
const bytes = new Uint8Array(4);
|
||||
new DataView(bytes.buffer).setUint32(0, value, true);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function writeUint24LittleEndian(
|
||||
bytes: Uint8Array,
|
||||
offset: number,
|
||||
value: number,
|
||||
): void {
|
||||
bytes[offset] = value & 0xff;
|
||||
bytes[offset + 1] = (value >>> 8) & 0xff;
|
||||
bytes[offset + 2] = (value >>> 16) & 0xff;
|
||||
}
|
||||
|
||||
export function asciiBytes(value: string): Uint8Array {
|
||||
return Uint8Array.from(
|
||||
[...value].map((character) => character.charCodeAt(0)),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeAscii(
|
||||
target: Uint8Array,
|
||||
offset: number,
|
||||
value: string,
|
||||
): void {
|
||||
target.set(asciiBytes(value), offset);
|
||||
}
|
||||
|
||||
export function concatenateBytes(
|
||||
chunks: readonly Uint8Array[],
|
||||
): Uint8Array {
|
||||
const combined = new Uint8Array(
|
||||
chunks.reduce((total, chunk) => total + chunk.byteLength, 0),
|
||||
);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return combined;
|
||||
}
|
||||
|
||||
export function manualImageProbeScheduler(): Readonly<{
|
||||
scheduler: ImageProbeScheduler;
|
||||
fire(): void;
|
||||
}> {
|
||||
let callback: (() => void) | undefined;
|
||||
return {
|
||||
scheduler: {
|
||||
setTimeout(nextCallback) {
|
||||
callback = nextCallback;
|
||||
return 1;
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
},
|
||||
fire() {
|
||||
if (!callback) {
|
||||
throw new TypeError("No image probe timeout is scheduled.");
|
||||
}
|
||||
callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function manualCapabilityVerificationScheduler(): Readonly<{
|
||||
scheduler: ImageCapabilityVerificationScheduler;
|
||||
delays: readonly number[];
|
||||
clearTimeout: ReturnType<typeof vi.fn>;
|
||||
fire(): void;
|
||||
}> {
|
||||
let callback: (() => void) | undefined;
|
||||
const delays: number[] = [];
|
||||
const clearTimeout = vi.fn();
|
||||
return {
|
||||
scheduler: {
|
||||
setTimeout(nextCallback, milliseconds) {
|
||||
callback = nextCallback;
|
||||
delays.push(milliseconds);
|
||||
return 1;
|
||||
},
|
||||
clearTimeout,
|
||||
},
|
||||
delays,
|
||||
clearTimeout,
|
||||
fire() {
|
||||
if (!callback) {
|
||||
throw new TypeError(
|
||||
"No capability verification timeout is scheduled.",
|
||||
);
|
||||
}
|
||||
callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function base64Url(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary)
|
||||
.replace(/\+/gu, "-")
|
||||
.replace(/\//gu, "_")
|
||||
.replace(/=+$/gu, "");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/cursor-pagination-runtime.ts";
|
||||
import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/index.ts";
|
||||
import {
|
||||
defineMutationIntent,
|
||||
isValidIdempotencyKey,
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
import type {
|
||||
CursorPage,
|
||||
CursorPaginationProfile,
|
||||
} from "../../src/contracts/cursor-pagination.ts";
|
||||
import type { Result } from "../../src/application/result.ts";
|
||||
} from "../../src/adapters/query-cache/index.ts";
|
||||
import type { Result } from "../../src/contracts/result.ts";
|
||||
|
||||
const PROFILE: CursorPaginationProfile = Object.freeze({
|
||||
profileId: "TEST_PAGINATION_V1",
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
BrowserFilePolicyRegistry,
|
||||
browserFilePolicyReference,
|
||||
} from "../../src/adapters/browser-files/browser-file-policy-registry.ts";
|
||||
import {
|
||||
createDownloadDeliveryAdapter,
|
||||
type SaveFileHandle,
|
||||
} from "../../src/adapters/browser-files/download-delivery-adapter.ts";
|
||||
import {
|
||||
CHECKSUM_HEADER,
|
||||
CONTROL_ENDPOINT,
|
||||
DATA_ORIGIN,
|
||||
DIGEST_HEADER,
|
||||
DOWNLOAD_HREF,
|
||||
DOWNLOAD_PATH,
|
||||
NOW,
|
||||
POLICY_HEADER,
|
||||
REQUEST_BINDING_SHA256,
|
||||
UPLOAD_SESSION_ID,
|
||||
collect,
|
||||
createHarness,
|
||||
downloadCapabilityPayload,
|
||||
downloadResponse,
|
||||
jsonResponse,
|
||||
responseWithUrl,
|
||||
uploadCapabilityPayload,
|
||||
} from "./presigned-transfer-fixture.ts";
|
||||
|
||||
describe("presigned DownloadDelivery integration", () => {
|
||||
it("connects an issued capability to DownloadDeliveryPort without caller digest input", async () => {
|
||||
const bytes = new TextEncoder().encode("verified");
|
||||
const payload = downloadCapabilityPayload(bytes);
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input) === CONTROL_ENDPOINT
|
||||
? jsonResponse(payload)
|
||||
: downloadResponse(bytes.slice().buffer, payload),
|
||||
) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({ fetcher });
|
||||
const issued = await provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
|
||||
const policy = browserFilePolicyReference(
|
||||
"download",
|
||||
"presigned-stream",
|
||||
);
|
||||
const policies = new BrowserFilePolicyRegistry({
|
||||
profiles: [
|
||||
{
|
||||
reference: policy,
|
||||
download: {
|
||||
strategy: "PROMPT_AND_STREAM",
|
||||
mediaType: "application/octet-stream",
|
||||
safeExtension: ".bin",
|
||||
maxTransferBytes: 64,
|
||||
maxBufferedBytes: 8,
|
||||
integrity: "REQUIRED",
|
||||
},
|
||||
},
|
||||
],
|
||||
hardLimits: {
|
||||
maxInspectionBytes: 64,
|
||||
maxRetainedFileBytes: 64,
|
||||
maxPreviewBytes: 64,
|
||||
maxObjectUrlBytes: 64,
|
||||
maxTransferBytes: 64,
|
||||
},
|
||||
});
|
||||
const written: number[] = [];
|
||||
const handle: SaveFileHandle = {
|
||||
async createWritable() {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
written.push(...chunk);
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
const downloads = createDownloadDeliveryAdapter({
|
||||
host: { handoff() {} },
|
||||
policies,
|
||||
hardMaxObjectUrlBytes: 64,
|
||||
hardMaxTransferBytes: 64,
|
||||
browserManagedCapabilities: {
|
||||
resolve() {
|
||||
throw new TypeError("not used");
|
||||
},
|
||||
},
|
||||
openAuthorizedSource:
|
||||
executor.downloadSources.open.bind(executor.downloadSources),
|
||||
showSaveFilePicker: async () => handle,
|
||||
userActivation: { isActive: true },
|
||||
now: () => NOW,
|
||||
});
|
||||
const result = await downloads.deliver({
|
||||
policy,
|
||||
source: {
|
||||
kind: "AUTHORIZED_STREAM_RESOURCE",
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
},
|
||||
suggestedFileName: "artifact.bin",
|
||||
signal: new AbortController().signal,
|
||||
onProgress() {},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "SAVED",
|
||||
integrity: "VERIFIED",
|
||||
bytesWritten: bytes.byteLength,
|
||||
},
|
||||
});
|
||||
expect(written).toEqual([...bytes]);
|
||||
});
|
||||
|
||||
/**
|
||||
* TR-RR-04. A presigned byte source owns a fetch reader and a capability
|
||||
* lease, and its port requires `close()`. The delivery consumer never called
|
||||
* it, so every outcome — success, validation failure, writer failure and
|
||||
* abort — leaked both.
|
||||
*/
|
||||
it.each([
|
||||
{ label: "success", mode: "SUCCESS" as const },
|
||||
{ label: "writer failure", mode: "WRITER_FAILURE" as const },
|
||||
{ label: "abort", mode: "ABORT" as const },
|
||||
])("closes the presigned source exactly once on $label", async ({ mode }) => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
let closes = 0;
|
||||
const controller = new AbortController();
|
||||
const source = {
|
||||
byteLength: bytes.byteLength,
|
||||
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const,
|
||||
capability: undefined as never,
|
||||
close() {
|
||||
closes += 1;
|
||||
},
|
||||
async *stream() {
|
||||
if (mode === "ABORT") controller.abort();
|
||||
yield { ok: true as const, value: bytes };
|
||||
},
|
||||
};
|
||||
const capability = Object.freeze({
|
||||
capabilityReceipt: "capability-close-1",
|
||||
method: "GET" as const,
|
||||
binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" },
|
||||
mediaType: "application/octet-stream",
|
||||
byteLength: bytes.byteLength,
|
||||
maxBytes: bytes.byteLength,
|
||||
expectedSha256: "a".repeat(64),
|
||||
expiresAtEpochMs: NOW + 60_000,
|
||||
});
|
||||
source.capability = capability as never;
|
||||
|
||||
const closePolicy = browserFilePolicyReference(
|
||||
"download",
|
||||
"presigned-close",
|
||||
);
|
||||
const policies = new BrowserFilePolicyRegistry({
|
||||
profiles: [
|
||||
{
|
||||
reference: closePolicy,
|
||||
download: {
|
||||
strategy: "PROMPT_AND_STREAM",
|
||||
mediaType: "application/octet-stream",
|
||||
safeExtension: ".bin",
|
||||
maxTransferBytes: 64,
|
||||
maxBufferedBytes: 8,
|
||||
integrity: "REQUIRED",
|
||||
},
|
||||
},
|
||||
],
|
||||
hardLimits: {
|
||||
maxInspectionBytes: 64,
|
||||
maxRetainedFileBytes: 64,
|
||||
maxPreviewBytes: 64,
|
||||
maxObjectUrlBytes: 64,
|
||||
maxTransferBytes: 64,
|
||||
},
|
||||
});
|
||||
const handle: SaveFileHandle = {
|
||||
async createWritable() {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write() {
|
||||
if (mode === "WRITER_FAILURE") {
|
||||
throw new TypeError("writer exploded");
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
const downloads = createDownloadDeliveryAdapter({
|
||||
host: { handoff() {} },
|
||||
policies,
|
||||
hardMaxObjectUrlBytes: 64,
|
||||
hardMaxTransferBytes: 64,
|
||||
browserManagedCapabilities: {
|
||||
resolve() {
|
||||
throw new TypeError("not used");
|
||||
},
|
||||
},
|
||||
openAuthorizedSource: async () =>
|
||||
({ ok: true, value: source }) as never,
|
||||
showSaveFilePicker: async () => handle,
|
||||
userActivation: { isActive: true },
|
||||
now: () => NOW,
|
||||
});
|
||||
|
||||
const deliveryResult = await downloads.deliver({
|
||||
policy: closePolicy,
|
||||
source: {
|
||||
kind: "AUTHORIZED_STREAM_RESOURCE",
|
||||
resourceId: "resource-1",
|
||||
capability: capability as never,
|
||||
},
|
||||
suggestedFileName: "artifact.bin",
|
||||
signal: controller.signal,
|
||||
onProgress() {},
|
||||
});
|
||||
|
||||
void deliveryResult;
|
||||
expect(closes).toBe(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* TR-02. A lease that resolved after the abort already ended the delivery
|
||||
* never reached the holder, so nothing closed it: the fetch reader and the
|
||||
* capability lease outlived the terminal result.
|
||||
*/
|
||||
it("closes a source lease that arrives after the delivery was aborted", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
let closes = 0;
|
||||
const controller = new AbortController();
|
||||
let releaseOpen:
|
||||
| ((value: { ok: true; value: unknown }) => void)
|
||||
| undefined;
|
||||
const source = {
|
||||
byteLength: bytes.byteLength,
|
||||
integrity: "VERIFIED_ON_SUCCESSFUL_EXHAUSTION" as const,
|
||||
capability: undefined as never,
|
||||
close() {
|
||||
closes += 1;
|
||||
},
|
||||
async *stream() {
|
||||
yield { ok: true as const, value: bytes };
|
||||
},
|
||||
};
|
||||
const capability = Object.freeze({
|
||||
capabilityReceipt: "capability-late-1",
|
||||
method: "GET" as const,
|
||||
binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" },
|
||||
mediaType: "application/octet-stream",
|
||||
byteLength: bytes.byteLength,
|
||||
maxBytes: bytes.byteLength,
|
||||
expectedSha256: "a".repeat(64),
|
||||
expiresAtEpochMs: NOW + 60_000,
|
||||
});
|
||||
source.capability = capability as never;
|
||||
|
||||
const latePolicy = browserFilePolicyReference("download", "presigned-late");
|
||||
const policies = new BrowserFilePolicyRegistry({
|
||||
profiles: [
|
||||
{
|
||||
reference: latePolicy,
|
||||
download: {
|
||||
strategy: "PROMPT_AND_STREAM",
|
||||
mediaType: "application/octet-stream",
|
||||
safeExtension: ".bin",
|
||||
maxTransferBytes: 64,
|
||||
maxBufferedBytes: 8,
|
||||
integrity: "REQUIRED",
|
||||
},
|
||||
},
|
||||
],
|
||||
hardLimits: {
|
||||
maxInspectionBytes: 64,
|
||||
maxRetainedFileBytes: 64,
|
||||
maxPreviewBytes: 64,
|
||||
maxObjectUrlBytes: 64,
|
||||
maxTransferBytes: 64,
|
||||
},
|
||||
});
|
||||
const downloads = createDownloadDeliveryAdapter({
|
||||
host: { handoff() {} },
|
||||
policies,
|
||||
hardMaxObjectUrlBytes: 64,
|
||||
hardMaxTransferBytes: 64,
|
||||
browserManagedCapabilities: {
|
||||
resolve() {
|
||||
throw new TypeError("not used");
|
||||
},
|
||||
},
|
||||
// Ignores the signal entirely and resolves only when the test says so.
|
||||
openAuthorizedSource: () =>
|
||||
new Promise((resolve) => {
|
||||
releaseOpen = resolve as never;
|
||||
}) as never,
|
||||
showSaveFilePicker: async () => ({
|
||||
async createWritable() {
|
||||
return new WritableStream<Uint8Array>({ write() {} });
|
||||
},
|
||||
}),
|
||||
userActivation: { isActive: true },
|
||||
now: () => NOW,
|
||||
});
|
||||
|
||||
const delivering = downloads.deliver({
|
||||
policy: latePolicy,
|
||||
source: {
|
||||
kind: "AUTHORIZED_STREAM_RESOURCE",
|
||||
resourceId: "resource-1",
|
||||
capability: capability as never,
|
||||
},
|
||||
suggestedFileName: "artifact.bin",
|
||||
signal: controller.signal,
|
||||
onProgress() {},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
controller.abort();
|
||||
const delivered = await delivering;
|
||||
expect(delivered.ok).toBe(false);
|
||||
|
||||
// The lease arrives only now, long after the terminal result.
|
||||
releaseOpen?.({ ok: true, value: source });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(closes).toBe(1);
|
||||
});
|
||||
|
||||
it("does not leave a late rejection unhandled after an abort", async () => {
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
let rejectOpen: ((reason: unknown) => void) | undefined;
|
||||
const rejectPolicy = browserFilePolicyReference(
|
||||
"download",
|
||||
"presigned-late-reject",
|
||||
);
|
||||
const policies = new BrowserFilePolicyRegistry({
|
||||
profiles: [
|
||||
{
|
||||
reference: rejectPolicy,
|
||||
download: {
|
||||
strategy: "PROMPT_AND_STREAM",
|
||||
mediaType: "application/octet-stream",
|
||||
safeExtension: ".bin",
|
||||
maxTransferBytes: 64,
|
||||
maxBufferedBytes: 8,
|
||||
integrity: "REQUIRED",
|
||||
},
|
||||
},
|
||||
],
|
||||
hardLimits: {
|
||||
maxInspectionBytes: 64,
|
||||
maxRetainedFileBytes: 64,
|
||||
maxPreviewBytes: 64,
|
||||
maxObjectUrlBytes: 64,
|
||||
maxTransferBytes: 64,
|
||||
},
|
||||
});
|
||||
const downloads = createDownloadDeliveryAdapter({
|
||||
host: { handoff() {} },
|
||||
policies,
|
||||
hardMaxObjectUrlBytes: 64,
|
||||
hardMaxTransferBytes: 64,
|
||||
browserManagedCapabilities: {
|
||||
resolve() {
|
||||
throw new TypeError("not used");
|
||||
},
|
||||
},
|
||||
openAuthorizedSource: () =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectOpen = reject;
|
||||
}) as never,
|
||||
showSaveFilePicker: async () => ({
|
||||
async createWritable() {
|
||||
return new WritableStream<Uint8Array>({ write() {} });
|
||||
},
|
||||
}),
|
||||
userActivation: { isActive: true },
|
||||
now: () => NOW,
|
||||
});
|
||||
|
||||
const delivering = downloads.deliver({
|
||||
policy: rejectPolicy,
|
||||
source: {
|
||||
kind: "AUTHORIZED_STREAM_RESOURCE",
|
||||
resourceId: "resource-1",
|
||||
capability: Object.freeze({
|
||||
capabilityReceipt: "capability-late-2",
|
||||
method: "GET" as const,
|
||||
binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" },
|
||||
mediaType: "application/octet-stream",
|
||||
byteLength: 3,
|
||||
maxBytes: 3,
|
||||
expectedSha256: "a".repeat(64),
|
||||
expiresAtEpochMs: NOW + 60_000,
|
||||
}) as never,
|
||||
},
|
||||
suggestedFileName: "artifact.bin",
|
||||
signal: controller.signal,
|
||||
onProgress() {},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
controller.abort();
|
||||
await delivering;
|
||||
|
||||
rejectOpen?.(new Error("late open failure"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
}
|
||||
});});
|
||||
@@ -0,0 +1,650 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PRESIGNED_TRANSFER_PROTOCOL } from "../../src/application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import { createPresignedCapabilityVault } from "../../src/adapters/browser-transfer/presigned/presigned-capability-vault.ts";
|
||||
import { sha256Hex } from "../../src/adapters/browser-transfer/presigned/incremental-sha256.ts";
|
||||
import {
|
||||
CHECKSUM_HEADER,
|
||||
CONTROL_ENDPOINT,
|
||||
DATA_ORIGIN,
|
||||
DIGEST_HEADER,
|
||||
DOWNLOAD_HREF,
|
||||
DOWNLOAD_PATH,
|
||||
NOW,
|
||||
POLICY_HEADER,
|
||||
REQUEST_BINDING_SHA256,
|
||||
UPLOAD_SESSION_ID,
|
||||
collect,
|
||||
createHarness,
|
||||
downloadCapabilityPayload,
|
||||
downloadResponse,
|
||||
jsonResponse,
|
||||
responseWithUrl,
|
||||
uploadCapabilityPayload,
|
||||
} from "./presigned-transfer-fixture.ts";
|
||||
|
||||
describe("presigned download stream lifecycle", () => {
|
||||
describe("TR-01 the stored capability is the one that was validated", () => {
|
||||
const baseRegistration = () => ({
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt: "capability-snapshot-1",
|
||||
method: "GET" as const,
|
||||
binding: { kind: "DOWNLOAD" as const, resourceId: "resource-1" },
|
||||
href: `${DATA_ORIGIN}${DOWNLOAD_PATH}`,
|
||||
origin: DATA_ORIGIN,
|
||||
path: DOWNLOAD_PATH,
|
||||
allowedQueryParameters: [],
|
||||
requestHeaders: [{ name: "x-safe", value: "1" }],
|
||||
requiredResponseHeaders: [],
|
||||
digestRequestHeader: null,
|
||||
digestResponseHeader: null,
|
||||
receiptResponseHeader: null,
|
||||
expectedStatus: 200,
|
||||
expectedResponseByteLength: 3,
|
||||
mediaType: "application/octet-stream",
|
||||
byteLength: 3,
|
||||
maxBytes: 3,
|
||||
expectedSha256: "a".repeat(64),
|
||||
expiresAtEpochMs: NOW + 60_000,
|
||||
});
|
||||
|
||||
const freshVault = () =>
|
||||
createPresignedCapabilityVault({
|
||||
now: () => NOW,
|
||||
maxActiveCapabilities: 4,
|
||||
});
|
||||
|
||||
it("refuses a header row that answers differently on a second read", () => {
|
||||
const vault = freshVault();
|
||||
let nameReads = 0;
|
||||
const header = new Proxy(
|
||||
{ name: "x-safe", value: "1" },
|
||||
{
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
if (key === "name") {
|
||||
nameReads += 1;
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: nameReads > 1 ? "authorization" : "x-safe",
|
||||
};
|
||||
}
|
||||
return Reflect.getOwnPropertyDescriptor(target, key);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const registered = vault.register({
|
||||
...baseRegistration(),
|
||||
requestHeaders: [header],
|
||||
} as never);
|
||||
|
||||
if (registered.ok) {
|
||||
// A single read means the value that was checked is the value stored.
|
||||
const resolved = vault.resolve(registered.value);
|
||||
expect(resolved.ok).toBe(true);
|
||||
if (resolved.ok) {
|
||||
expect(resolved.value.requestHeaders.map((row) => row.name)).toEqual([
|
||||
"x-safe",
|
||||
]);
|
||||
}
|
||||
}
|
||||
vault.dispose();
|
||||
});
|
||||
|
||||
const hostileRegistrations: readonly (readonly [string, () => unknown])[] = [
|
||||
[
|
||||
"an accessor field",
|
||||
() =>
|
||||
Object.defineProperty(baseRegistration(), "href", {
|
||||
enumerable: true,
|
||||
get: () => `${DATA_ORIGIN}${DOWNLOAD_PATH}`,
|
||||
}),
|
||||
],
|
||||
[
|
||||
"an inherited field",
|
||||
() => Object.assign(Object.create({ injected: true }), baseRegistration()),
|
||||
],
|
||||
[
|
||||
"a symbol field",
|
||||
() => ({ ...baseRegistration(), [Symbol.for("injected")]: true }),
|
||||
],
|
||||
[
|
||||
"a non-enumerable own field",
|
||||
() =>
|
||||
Object.defineProperty(baseRegistration(), "injected", {
|
||||
enumerable: false,
|
||||
value: true,
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a throwing ownKeys trap",
|
||||
() =>
|
||||
new Proxy(baseRegistration(), {
|
||||
ownKeys() {
|
||||
throw new TypeError("hostile ownKeys trap");
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a null header array",
|
||||
() => ({ ...baseRegistration(), requestHeaders: null }),
|
||||
],
|
||||
[
|
||||
"a non-iterable header array",
|
||||
() => ({ ...baseRegistration(), requestHeaders: { length: 1 } }),
|
||||
],
|
||||
[
|
||||
"a header row with an extra field",
|
||||
() => ({
|
||||
...baseRegistration(),
|
||||
requestHeaders: [{ name: "x-safe", value: "1", injected: true }],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"an accessor header name",
|
||||
() => ({
|
||||
...baseRegistration(),
|
||||
requestHeaders: [
|
||||
Object.defineProperty({ value: "1" }, "name", {
|
||||
enumerable: true,
|
||||
get: () => "x-safe",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a binding with an extra field",
|
||||
() => ({
|
||||
...baseRegistration(),
|
||||
binding: { kind: "DOWNLOAD", resourceId: "r", injected: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a null binding",
|
||||
() => ({ ...baseRegistration(), binding: null }),
|
||||
],
|
||||
];
|
||||
|
||||
for (const [label, build] of hostileRegistrations) {
|
||||
it(`rejects ${label} as POLICY_REJECTED`, () => {
|
||||
const vault = freshVault();
|
||||
expect(vault.register(build() as never)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
vault.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
it("does not observe a mutation of the issuer's object after registration", () => {
|
||||
const vault = freshVault();
|
||||
const registration = baseRegistration();
|
||||
const registered = vault.register(registration as never);
|
||||
expect(registered.ok).toBe(true);
|
||||
if (!registered.ok) return;
|
||||
|
||||
registration.requestHeaders[0]!.name = "authorization";
|
||||
registration.expiresAtEpochMs = NOW + 999_999;
|
||||
|
||||
const resolved = vault.resolve(registered.value);
|
||||
expect(resolved.ok).toBe(true);
|
||||
if (!resolved.ok) return;
|
||||
expect(resolved.value.requestHeaders.map((row) => row.name)).toEqual([
|
||||
"x-safe",
|
||||
]);
|
||||
expect(resolved.value.expiresAtEpochMs).toBe(NOW + 60_000);
|
||||
vault.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not fetch a presigned download until stream consumption", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const responsePayload = downloadCapabilityPayload(bytes);
|
||||
let downloadFetches = 0;
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) === CONTROL_ENDPOINT) {
|
||||
return jsonResponse(responsePayload);
|
||||
}
|
||||
downloadFetches += 1;
|
||||
return downloadResponse(bytes.slice().buffer, responsePayload);
|
||||
}) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({ fetcher });
|
||||
const signal = new AbortController().signal;
|
||||
const issued = await provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
|
||||
const opened = await executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal,
|
||||
});
|
||||
expect(opened.ok).toBe(true);
|
||||
if (!opened.ok) return;
|
||||
// BT-PRE-01. open() performs no network I/O.
|
||||
expect(downloadFetches).toBe(0);
|
||||
|
||||
for await (const chunk of opened.value.stream(signal)) {
|
||||
expect(chunk.ok).toBe(true);
|
||||
}
|
||||
expect(downloadFetches).toBe(1);
|
||||
opened.value.close();
|
||||
});
|
||||
|
||||
it("closes an unused download source without network I/O", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const responsePayload = downloadCapabilityPayload(bytes);
|
||||
let downloadFetches = 0;
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) === CONTROL_ENDPOINT) {
|
||||
return jsonResponse(responsePayload);
|
||||
}
|
||||
downloadFetches += 1;
|
||||
return downloadResponse(bytes.slice().buffer, responsePayload);
|
||||
}) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({ fetcher });
|
||||
const signal = new AbortController().signal;
|
||||
const issued = await provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
|
||||
const opened = await executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal,
|
||||
});
|
||||
expect(opened.ok).toBe(true);
|
||||
if (!opened.ok) return;
|
||||
|
||||
opened.value.close();
|
||||
// close() is idempotent and never starts the transfer.
|
||||
opened.value.close();
|
||||
expect(downloadFetches).toBe(0);
|
||||
|
||||
// A stream after close is one terminal conflict, still without fetching.
|
||||
const results = [];
|
||||
for await (const chunk of opened.value.stream(signal)) {
|
||||
results.push(chunk);
|
||||
}
|
||||
expect(results).toMatchObject([
|
||||
{ ok: false, error: { code: "CONFLICT" } },
|
||||
]);
|
||||
expect(downloadFetches).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "truncation",
|
||||
body: new Uint8Array([1, 2]),
|
||||
expectedCode: "INTEGRITY_FAILED",
|
||||
},
|
||||
{
|
||||
name: "overrun",
|
||||
body: new Uint8Array([1, 2, 3, 4]),
|
||||
expectedCode: "INTEGRITY_FAILED",
|
||||
},
|
||||
])("closes $name as a terminal stream failure", async ({ body, expectedCode }) => {
|
||||
const declared = new Uint8Array([1, 2, 3]);
|
||||
const payload = downloadCapabilityPayload(declared);
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input) === CONTROL_ENDPOINT
|
||||
? jsonResponse(payload)
|
||||
: downloadResponse(body.slice().buffer, payload),
|
||||
) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({ fetcher });
|
||||
const issued = await provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
const opened = await executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(opened.ok).toBe(true);
|
||||
if (!opened.ok) return;
|
||||
const results = await collect(opened.value);
|
||||
expect(results.at(-1)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: expectedCode },
|
||||
});
|
||||
const firstFailure = results.findIndex((result) => !result.ok);
|
||||
expect(results.slice(firstFailure + 1)).toEqual([]);
|
||||
});
|
||||
|
||||
it("closes native body errors without throwing across the port", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const payload = downloadCapabilityPayload(bytes);
|
||||
const failingBody = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.error(new DOMException("secret native detail", "NetworkError"));
|
||||
},
|
||||
});
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input) === CONTROL_ENDPOINT
|
||||
? jsonResponse(payload)
|
||||
: downloadResponse(failingBody, payload),
|
||||
) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({ fetcher });
|
||||
const issued = await provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
const opened = await executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(opened.ok).toBe(true);
|
||||
if (!opened.ok) return;
|
||||
await expect(collect(opened.value)).resolves.toMatchObject([
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: "NOT_READABLE",
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("closes active abort and timeout without leaking native rejection", async () => {
|
||||
const bytes = new Uint8Array([1]);
|
||||
const payload = downloadCapabilityPayload(bytes);
|
||||
const neverBody = () =>
|
||||
new ReadableStream<Uint8Array>({ pull() {} });
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input) === CONTROL_ENDPOINT
|
||||
? jsonResponse(payload)
|
||||
: downloadResponse(neverBody(), payload),
|
||||
) as unknown as typeof fetch;
|
||||
const controller = new AbortController();
|
||||
let harness = createHarness({ fetcher });
|
||||
let issued = await harness.provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
let opened = await harness.executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(opened.ok).toBe(true);
|
||||
if (!opened.ok) return;
|
||||
const aborted = collect(opened.value, controller.signal);
|
||||
controller.abort("user");
|
||||
expect(await aborted).toMatchObject([
|
||||
{ ok: false, error: { code: "ABORTED" } },
|
||||
]);
|
||||
|
||||
let timeoutCallback: (() => void) | undefined;
|
||||
const scheduler = {
|
||||
setTimeout(callback: () => void) {
|
||||
timeoutCallback = callback;
|
||||
return 1;
|
||||
},
|
||||
clearTimeout() {},
|
||||
};
|
||||
harness = createHarness({ fetcher, scheduler });
|
||||
issued = await harness.provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
opened = await harness.executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(opened.ok).toBe(true);
|
||||
if (!opened.ok) return;
|
||||
const timedOut = collect(opened.value);
|
||||
timeoutCallback?.();
|
||||
expect(await timedOut).toMatchObject([
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAVAILABLE",
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects an expired capability before data-plane fetch", async () => {
|
||||
const bytes = new Uint8Array([1]);
|
||||
const payload = downloadCapabilityPayload(bytes);
|
||||
let current = NOW;
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input) === CONTROL_ENDPOINT
|
||||
? jsonResponse(payload)
|
||||
: downloadResponse(bytes.slice().buffer, payload),
|
||||
) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({
|
||||
fetcher,
|
||||
now: () => current,
|
||||
});
|
||||
const issued = await provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
current = Number(payload.expiresAtEpochMs) + 1;
|
||||
expect(
|
||||
await executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "EXPIRED_RESOURCE",
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
},
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects capabilities below the configured minimum remaining lifetime", async () => {
|
||||
const bytes = new Uint8Array([1]);
|
||||
const nearExpiryPayload = downloadCapabilityPayload(bytes, {
|
||||
expiresAtEpochMs: NOW + 999,
|
||||
});
|
||||
let fetcher = vi.fn(async () =>
|
||||
jsonResponse(nearExpiryPayload),
|
||||
) as unknown as typeof fetch;
|
||||
let harness = createHarness({ fetcher });
|
||||
expect(
|
||||
await harness.provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "EXPIRED_RESOURCE",
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
},
|
||||
});
|
||||
|
||||
const acceptedPayload = downloadCapabilityPayload(bytes, {
|
||||
expiresAtEpochMs: NOW + 2_000,
|
||||
});
|
||||
let current = NOW;
|
||||
fetcher = vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input) === CONTROL_ENDPOINT
|
||||
? jsonResponse(acceptedPayload)
|
||||
: downloadResponse(bytes.slice().buffer, acceptedPayload),
|
||||
) as unknown as typeof fetch;
|
||||
harness = createHarness({
|
||||
fetcher,
|
||||
now: () => current,
|
||||
});
|
||||
const issued = await harness.provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
current = NOW + 1_001;
|
||||
expect(
|
||||
await harness.executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "EXPIRED_RESOURCE",
|
||||
recovery: "REISSUE_CAPABILITY",
|
||||
},
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("closes malformed AbortSignal inputs at every public boundary", async () => {
|
||||
const bytes = new Uint8Array([1, 2]);
|
||||
const payload = downloadCapabilityPayload(bytes);
|
||||
const uploadChecksum = sha256Hex(bytes);
|
||||
const uploadPayload = uploadCapabilityPayload({
|
||||
bytes,
|
||||
checksum: uploadChecksum,
|
||||
});
|
||||
const fetcher = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input) === CONTROL_ENDPOINT) {
|
||||
const request = JSON.parse(String(init?.body)) as {
|
||||
method: string;
|
||||
};
|
||||
return jsonResponse(
|
||||
request.method === "GET" ? payload : uploadPayload,
|
||||
);
|
||||
}
|
||||
if (String(input) === DOWNLOAD_HREF) {
|
||||
return downloadResponse(bytes.slice().buffer, payload);
|
||||
}
|
||||
return responseWithUrl(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
[POLICY_HEADER]: "v1",
|
||||
"Content-Length": "0",
|
||||
ETag: "\"part-etag-1\"",
|
||||
},
|
||||
}),
|
||||
String(uploadPayload.href),
|
||||
);
|
||||
},
|
||||
) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({ fetcher });
|
||||
const malformed = {} as AbortSignal;
|
||||
|
||||
expect(
|
||||
await provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: malformed,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
expect(
|
||||
await provider.issueUploadPart({
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: uploadChecksum,
|
||||
mediaType: "application/octet-stream",
|
||||
idempotencyKey: "part-attempt-1",
|
||||
signal: malformed,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
|
||||
const issuedDownload = await provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issuedDownload.ok).toBe(true);
|
||||
if (!issuedDownload.ok) return;
|
||||
expect(
|
||||
await executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issuedDownload.value,
|
||||
signal: malformed,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
const opened = await executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issuedDownload.value,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(opened.ok).toBe(true);
|
||||
if (!opened.ok) return;
|
||||
expect(await collect(opened.value, malformed)).toMatchObject([
|
||||
{ ok: false, error: { code: "INVALID_INPUT" } },
|
||||
]);
|
||||
expect(await collect(opened.value)).toMatchObject([
|
||||
{ ok: false, error: { code: "CONFLICT" } },
|
||||
]);
|
||||
|
||||
const issuedUpload = await provider.issueUploadPart({
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: uploadChecksum,
|
||||
mediaType: "application/octet-stream",
|
||||
idempotencyKey: "part-attempt-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issuedUpload.ok).toBe(true);
|
||||
if (!issuedUpload.ok) return;
|
||||
expect(
|
||||
await executor.uploadParts.put({
|
||||
capability: issuedUpload.value,
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: uploadChecksum,
|
||||
idempotencyKey: "part-attempt-1",
|
||||
bytes,
|
||||
signal: malformed,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
import { PRESIGNED_TRANSFER_PROTOCOL } from "../../src/application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import type {
|
||||
PresignedDownloadByteSource,
|
||||
} from "../../src/application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
||||
import type { BrowserDataObservation } from "../../src/application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
createPresignedCapabilityVault,
|
||||
createSingleUsePresignedReplayGuard,
|
||||
} from "../../src/adapters/browser-transfer/presigned/presigned-capability-vault.ts";
|
||||
import { createPresignedCapabilityHttpProvider } from "../../src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts";
|
||||
import {
|
||||
createPresignedTransferExecutor,
|
||||
type PresignedTransferExecutorOptions,
|
||||
} from "../../src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts";
|
||||
import { sha256Hex } from "../../src/adapters/browser-transfer/presigned/incremental-sha256.ts";
|
||||
|
||||
export const NOW = 1_000_000;
|
||||
export const CONTROL_ENDPOINT = "https://api.example/capabilities";
|
||||
export const DATA_ORIGIN = "https://objects.example";
|
||||
export const DOWNLOAD_PATH = "/files/resource-1";
|
||||
export const DOWNLOAD_HREF =
|
||||
`${DATA_ORIGIN}${DOWNLOAD_PATH}?sig=do-not-log-this`;
|
||||
export const POLICY_HEADER = "x-policy-version";
|
||||
export const DIGEST_HEADER = "x-content-sha256";
|
||||
export const CHECKSUM_HEADER = "x-checksum-sha256";
|
||||
export const UPLOAD_SESSION_ID = "upload-session-1";
|
||||
export const REQUEST_BINDING_SHA256 = "c".repeat(64);
|
||||
|
||||
export function downloadCapabilityPayload(
|
||||
bytes: Uint8Array,
|
||||
overrides: Readonly<Record<string, unknown>> = {},
|
||||
) {
|
||||
const digest = sha256Hex(bytes);
|
||||
return {
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt: "capability-download-1",
|
||||
method: "GET",
|
||||
binding: {
|
||||
kind: "DOWNLOAD",
|
||||
resourceId: "resource-1",
|
||||
},
|
||||
href: DOWNLOAD_HREF,
|
||||
origin: DATA_ORIGIN,
|
||||
path: DOWNLOAD_PATH,
|
||||
allowedQueryParameters: ["sig"],
|
||||
requestHeaders: [
|
||||
{ name: "accept", value: "application/octet-stream" },
|
||||
],
|
||||
requiredResponseHeaders: [
|
||||
{ name: POLICY_HEADER, value: "v1" },
|
||||
],
|
||||
digestRequestHeader: null,
|
||||
digestResponseHeader: DIGEST_HEADER,
|
||||
receiptResponseHeader: null,
|
||||
expectedStatus: 200,
|
||||
expectedResponseByteLength: null,
|
||||
mediaType: "application/octet-stream",
|
||||
byteLength: bytes.byteLength,
|
||||
maxBytes: 64,
|
||||
expectedSha256: digest,
|
||||
expiresAtEpochMs: NOW + 30_000,
|
||||
singleUse: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export type CapabilityPayload = ReturnType<typeof downloadCapabilityPayload>;
|
||||
|
||||
export function uploadCapabilityPayload(input: Readonly<{
|
||||
bytes: Uint8Array;
|
||||
checksum: string;
|
||||
}>, overrides: Readonly<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt: "capability-upload-1",
|
||||
method: "PUT",
|
||||
binding: {
|
||||
kind: "UPLOAD_PART",
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
idempotencyKey: "part-attempt-1",
|
||||
},
|
||||
href: `${DATA_ORIGIN}/uploads/session-1/part-1?sig=secret`,
|
||||
origin: DATA_ORIGIN,
|
||||
path: "/uploads/session-1/part-1",
|
||||
allowedQueryParameters: ["sig"],
|
||||
requestHeaders: [
|
||||
{ name: "content-type", value: "application/octet-stream" },
|
||||
{ name: CHECKSUM_HEADER, value: input.checksum },
|
||||
],
|
||||
requiredResponseHeaders: [
|
||||
{ name: POLICY_HEADER, value: "v1" },
|
||||
],
|
||||
digestRequestHeader: CHECKSUM_HEADER,
|
||||
digestResponseHeader: null,
|
||||
receiptResponseHeader: "etag",
|
||||
expectedStatus: 200,
|
||||
expectedResponseByteLength: 0,
|
||||
mediaType: "application/octet-stream",
|
||||
byteLength: input.bytes.byteLength,
|
||||
maxBytes: 64,
|
||||
expectedSha256: input.checksum,
|
||||
expiresAtEpochMs: NOW + 30_000,
|
||||
singleUse: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function jsonResponse(
|
||||
value: unknown,
|
||||
url = CONTROL_ENDPOINT,
|
||||
): Response {
|
||||
return responseWithUrl(
|
||||
new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadResponse(
|
||||
body: BodyInit | null,
|
||||
payload: CapabilityPayload,
|
||||
headers: Record<string, string> = {},
|
||||
): Response {
|
||||
return responseWithUrl(
|
||||
new Response(body, {
|
||||
status: payload.expectedStatus as number,
|
||||
headers: {
|
||||
"Content-Type": String(payload.mediaType),
|
||||
"Content-Length": String(payload.byteLength),
|
||||
[DIGEST_HEADER]: String(payload.expectedSha256),
|
||||
[POLICY_HEADER]: "v1",
|
||||
...headers,
|
||||
},
|
||||
}),
|
||||
String(payload.href),
|
||||
);
|
||||
}
|
||||
|
||||
export function responseWithUrl(response: Response, href: string): Response {
|
||||
Object.defineProperty(response, "url", {
|
||||
configurable: true,
|
||||
value: href,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
export function createHarness(input: Readonly<{
|
||||
fetcher: typeof fetch;
|
||||
maxActiveCapabilities?: number;
|
||||
now?: () => number;
|
||||
digestBytes?: PresignedTransferExecutorOptions["digestBytes"];
|
||||
scheduler?: PresignedTransferExecutorOptions["scheduler"];
|
||||
observer?: Readonly<{
|
||||
record(observation: BrowserDataObservation): void;
|
||||
}>;
|
||||
}>) {
|
||||
const now = input.now ?? (() => NOW);
|
||||
const vault = createPresignedCapabilityVault({
|
||||
maxActiveCapabilities: input.maxActiveCapabilities ?? 16,
|
||||
now,
|
||||
});
|
||||
const replayGuard = createSingleUsePresignedReplayGuard();
|
||||
const provider = createPresignedCapabilityHttpProvider({
|
||||
endpoint: CONTROL_ENDPOINT,
|
||||
vault,
|
||||
allowedDataOrigins: [DATA_ORIGIN],
|
||||
allowedDataPathPrefixes: ["/files/", "/uploads/"],
|
||||
allowedQueryParameters: ["sig"],
|
||||
allowedRequestHeaders: [
|
||||
"accept",
|
||||
"content-type",
|
||||
CHECKSUM_HEADER,
|
||||
],
|
||||
allowedResponseHeaders: [
|
||||
POLICY_HEADER,
|
||||
DIGEST_HEADER,
|
||||
"etag",
|
||||
],
|
||||
hardMaxTransferBytes: 64,
|
||||
hardMaxUploadResponseBytes: 16,
|
||||
maxCapabilityTtlMs: 60_000,
|
||||
minimumRemainingLifetimeMs: 1_000,
|
||||
timeoutMs: 5_000,
|
||||
fetcher: input.fetcher,
|
||||
now,
|
||||
scheduler: input.scheduler,
|
||||
observer: input.observer,
|
||||
});
|
||||
const executor = createPresignedTransferExecutor({
|
||||
vault,
|
||||
replayGuard,
|
||||
hardMaxTransferBytes: 64,
|
||||
hardMaxChunkBytes: 2,
|
||||
hardMaxUploadResponseBytes: 16,
|
||||
minimumRemainingLifetimeMs: 1_000,
|
||||
timeoutMs: 5_000,
|
||||
fetcher: input.fetcher,
|
||||
now,
|
||||
scheduler: input.scheduler,
|
||||
digestBytes: input.digestBytes,
|
||||
observer: input.observer,
|
||||
});
|
||||
return { provider, executor, vault };
|
||||
}
|
||||
|
||||
export async function collect(
|
||||
source: PresignedDownloadByteSource,
|
||||
signal = new AbortController().signal,
|
||||
) {
|
||||
const results = [];
|
||||
for await (const result of source.stream(signal)) {
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* BT-PRE-01. The download lease is lazy, so a response-shape rejection is
|
||||
* observed on first consumption rather than at open().
|
||||
*/
|
||||
export async function firstStreamResult(
|
||||
opened: Awaited<
|
||||
ReturnType<
|
||||
ReturnType<typeof createHarness>["executor"]["downloadSources"]["open"]
|
||||
>
|
||||
>,
|
||||
): Promise<unknown> {
|
||||
if (!opened.ok) return opened;
|
||||
try {
|
||||
for await (const chunk of opened.value.stream(
|
||||
new AbortController().signal,
|
||||
)) {
|
||||
if (!chunk.ok) return chunk;
|
||||
}
|
||||
return { ok: true };
|
||||
} finally {
|
||||
opened.value.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,549 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { BrowserDataObservation } from "../../src/application/ports/browser-file-storage/shared.ts";
|
||||
import { sha256Hex } from "../../src/adapters/browser-transfer/presigned/incremental-sha256.ts";
|
||||
import {
|
||||
CHECKSUM_HEADER,
|
||||
CONTROL_ENDPOINT,
|
||||
DATA_ORIGIN,
|
||||
DIGEST_HEADER,
|
||||
DOWNLOAD_HREF,
|
||||
DOWNLOAD_PATH,
|
||||
NOW,
|
||||
POLICY_HEADER,
|
||||
REQUEST_BINDING_SHA256,
|
||||
UPLOAD_SESSION_ID,
|
||||
collect,
|
||||
createHarness,
|
||||
downloadCapabilityPayload,
|
||||
downloadResponse,
|
||||
jsonResponse,
|
||||
responseWithUrl,
|
||||
uploadCapabilityPayload,
|
||||
} from "./presigned-transfer-fixture.ts";
|
||||
|
||||
describe("presigned upload part execution", () => {
|
||||
it("snapshots, verifies and uploads a PUT part with a separate response receipt", async () => {
|
||||
const original = new Uint8Array([9, 8, 7]);
|
||||
const checksum = sha256Hex(original);
|
||||
const payload = uploadCapabilityPayload({
|
||||
bytes: original,
|
||||
checksum,
|
||||
});
|
||||
let releaseDigest: (() => void) | undefined;
|
||||
const digestGate = new Promise<void>((resolve) => {
|
||||
releaseDigest = resolve;
|
||||
});
|
||||
const sentBodies: number[][] = [];
|
||||
const dataCalls: RequestInit[] = [];
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input) === CONTROL_ENDPOINT) return jsonResponse(payload);
|
||||
dataCalls.push(init ?? {});
|
||||
sentBodies.push([
|
||||
...new Uint8Array(init?.body as ArrayBuffer),
|
||||
]);
|
||||
return responseWithUrl(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
[POLICY_HEADER]: "v1",
|
||||
"Content-Length": "0",
|
||||
ETag: "\"part-etag-1\"",
|
||||
},
|
||||
}),
|
||||
String(payload.href),
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({
|
||||
fetcher,
|
||||
digestBytes: async (bytes) => {
|
||||
await digestGate;
|
||||
return sha256Hex(bytes);
|
||||
},
|
||||
});
|
||||
const issued = await provider.issueUploadPart({
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: original.byteLength,
|
||||
checksumSha256: checksum,
|
||||
mediaType: "application/octet-stream",
|
||||
idempotencyKey: "part-attempt-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
const request = {
|
||||
capability: issued.value,
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: original.byteLength,
|
||||
checksumSha256: checksum,
|
||||
idempotencyKey: "part-attempt-1",
|
||||
bytes: original,
|
||||
signal: new AbortController().signal,
|
||||
};
|
||||
const pending = executor.uploadParts.put(request);
|
||||
original.fill(0);
|
||||
request.sessionId = "mutated-session";
|
||||
request.requestBindingSha256 = "d".repeat(64);
|
||||
request.checksumSha256 = "f".repeat(64);
|
||||
releaseDigest?.();
|
||||
|
||||
expect(await pending).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
bytesWritten: 3,
|
||||
checksumSha256: checksum,
|
||||
receiptToken: "part-etag-1",
|
||||
},
|
||||
});
|
||||
expect(sentBodies).toEqual([[9, 8, 7]]);
|
||||
expect(dataCalls[0]).toMatchObject({
|
||||
method: "PUT",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
});
|
||||
expect(
|
||||
(dataCalls[0]?.headers as Headers).get(CHECKSUM_HEADER),
|
||||
).toBe(checksum);
|
||||
});
|
||||
|
||||
it.each(["sessionId", "requestBindingSha256"] as const)(
|
||||
"rejects an actual PUT whose %s differs from the capability",
|
||||
async (field) => {
|
||||
const bytes = new Uint8Array([3, 2, 1]);
|
||||
const checksum = sha256Hex(bytes);
|
||||
const payload = uploadCapabilityPayload({ bytes, checksum });
|
||||
const fetcher = vi.fn(async () =>
|
||||
jsonResponse(payload),
|
||||
) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({ fetcher });
|
||||
const issued = await provider.issueUploadPart({
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: checksum,
|
||||
mediaType: "application/octet-stream",
|
||||
idempotencyKey: "part-attempt-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
expect(
|
||||
await executor.uploadParts.put({
|
||||
capability: issued.value,
|
||||
sessionId:
|
||||
field === "sessionId"
|
||||
? "different-session"
|
||||
: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256:
|
||||
field === "requestBindingSha256"
|
||||
? "d".repeat(64)
|
||||
: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: checksum,
|
||||
idempotencyKey: "part-attempt-1",
|
||||
bytes,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects URL-shaped upload receipts", async () => {
|
||||
const bytes = new Uint8Array([4, 5, 6]);
|
||||
const checksum = sha256Hex(bytes);
|
||||
const payload = uploadCapabilityPayload({ bytes, checksum });
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) === CONTROL_ENDPOINT) {
|
||||
return jsonResponse(payload);
|
||||
}
|
||||
return responseWithUrl(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
[POLICY_HEADER]: "v1",
|
||||
"Content-Length": "0",
|
||||
ETag: "\"https://objects.example/authorizing-token\"",
|
||||
},
|
||||
}),
|
||||
String(payload.href),
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({ fetcher });
|
||||
const issued = await provider.issueUploadPart({
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: checksum,
|
||||
mediaType: "application/octet-stream",
|
||||
idempotencyKey: "part-attempt-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
expect(
|
||||
await executor.uploadParts.put({
|
||||
capability: issued.value,
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: checksum,
|
||||
idempotencyKey: "part-attempt-1",
|
||||
bytes,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INTEGRITY_FAILED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("drains a bounded successful PUT acknowledgement without cancelling it", async () => {
|
||||
const bytes = new Uint8Array([4, 5, 6]);
|
||||
const checksum = sha256Hex(bytes);
|
||||
const payload = uploadCapabilityPayload(
|
||||
{ bytes, checksum },
|
||||
{ expectedResponseByteLength: 2 },
|
||||
);
|
||||
let cancelled = false;
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) === CONTROL_ENDPOINT) {
|
||||
return jsonResponse(payload);
|
||||
}
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([8, 9]));
|
||||
controller.close();
|
||||
},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
return responseWithUrl(
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
[POLICY_HEADER]: "v1",
|
||||
"Content-Length": "2",
|
||||
ETag: "\"part-etag-1\"",
|
||||
},
|
||||
}),
|
||||
String(payload.href),
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({ fetcher });
|
||||
const issued = await provider.issueUploadPart({
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: checksum,
|
||||
mediaType: "application/octet-stream",
|
||||
idempotencyKey: "part-attempt-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
expect(
|
||||
await executor.uploadParts.put({
|
||||
capability: issued.value,
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: checksum,
|
||||
idempotencyKey: "part-attempt-1",
|
||||
bytes,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { receiptToken: "part-etag-1" },
|
||||
});
|
||||
expect(cancelled).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an empty 204 PUT acknowledgement", async () => {
|
||||
const bytes = new Uint8Array([4, 5, 6]);
|
||||
const checksum = sha256Hex(bytes);
|
||||
const payload = uploadCapabilityPayload(
|
||||
{ bytes, checksum },
|
||||
{
|
||||
expectedStatus: 204,
|
||||
expectedResponseByteLength: 0,
|
||||
},
|
||||
);
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) =>
|
||||
String(input) === CONTROL_ENDPOINT
|
||||
? jsonResponse(payload)
|
||||
: responseWithUrl(
|
||||
new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
[POLICY_HEADER]: "v1",
|
||||
ETag: "\"part-etag-204\"",
|
||||
},
|
||||
}),
|
||||
String(payload.href),
|
||||
),
|
||||
) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({ fetcher });
|
||||
const issued = await provider.issueUploadPart({
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: checksum,
|
||||
mediaType: "application/octet-stream",
|
||||
idempotencyKey: "part-attempt-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
expect(
|
||||
await executor.uploadParts.put({
|
||||
capability: issued.value,
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: checksum,
|
||||
idempotencyKey: "part-attempt-1",
|
||||
bytes,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { receiptToken: "part-etag-204" },
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels a PUT acknowledgement whose declared length violates its binding", async () => {
|
||||
const bytes = new Uint8Array([4, 5, 6]);
|
||||
const checksum = sha256Hex(bytes);
|
||||
const payload = uploadCapabilityPayload({ bytes, checksum });
|
||||
let cancelled = false;
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (String(input) === CONTROL_ENDPOINT) {
|
||||
return jsonResponse(payload);
|
||||
}
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull() {},
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
});
|
||||
return responseWithUrl(
|
||||
new Response(body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
[POLICY_HEADER]: "v1",
|
||||
"Content-Length": "1",
|
||||
ETag: "\"part-etag-1\"",
|
||||
},
|
||||
}),
|
||||
String(payload.href),
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({ fetcher });
|
||||
const issued = await provider.issueUploadPart({
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: checksum,
|
||||
mediaType: "application/octet-stream",
|
||||
idempotencyKey: "part-attempt-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
expect(
|
||||
await executor.uploadParts.put({
|
||||
capability: issued.value,
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: checksum,
|
||||
idempotencyKey: "part-attempt-1",
|
||||
bytes,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
expect(cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it("observes only safe operation, outcome, failure and byte buckets", async () => {
|
||||
const bytes = new Uint8Array([7, 8, 9]);
|
||||
const downloadPayload = downloadCapabilityPayload(bytes);
|
||||
const checksum = sha256Hex(bytes);
|
||||
const uploadPayload = uploadCapabilityPayload({ bytes, checksum });
|
||||
const observations: BrowserDataObservation[] = [];
|
||||
const fetcher = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input) === CONTROL_ENDPOINT) {
|
||||
const request = JSON.parse(String(init?.body)) as {
|
||||
method: string;
|
||||
};
|
||||
return jsonResponse(
|
||||
request.method === "GET"
|
||||
? downloadPayload
|
||||
: uploadPayload,
|
||||
);
|
||||
}
|
||||
if (String(input) === DOWNLOAD_HREF) {
|
||||
return downloadResponse(
|
||||
bytes.slice().buffer,
|
||||
downloadPayload,
|
||||
);
|
||||
}
|
||||
return responseWithUrl(
|
||||
new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
[POLICY_HEADER]: "v1",
|
||||
"Content-Length": "0",
|
||||
ETag: "\"part-etag-secret\"",
|
||||
},
|
||||
}),
|
||||
String(uploadPayload.href),
|
||||
);
|
||||
},
|
||||
) as unknown as typeof fetch;
|
||||
const { provider, executor } = createHarness({
|
||||
fetcher,
|
||||
observer: {
|
||||
record(observation) {
|
||||
observations.push(observation);
|
||||
},
|
||||
},
|
||||
});
|
||||
const issuedDownload = await provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issuedDownload.ok).toBe(true);
|
||||
if (!issuedDownload.ok) return;
|
||||
const opened = await executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issuedDownload.value,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(opened.ok).toBe(true);
|
||||
if (!opened.ok) return;
|
||||
expect((await collect(opened.value)).every((result) => result.ok)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const issuedUpload = await provider.issueUploadPart({
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: checksum,
|
||||
mediaType: "application/octet-stream",
|
||||
idempotencyKey: "part-attempt-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(issuedUpload.ok).toBe(true);
|
||||
if (!issuedUpload.ok) return;
|
||||
expect(
|
||||
await executor.uploadParts.put({
|
||||
capability: issuedUpload.value,
|
||||
sessionId: UPLOAD_SESSION_ID,
|
||||
requestBindingSha256: REQUEST_BINDING_SHA256,
|
||||
uploadBindingSha256: "b".repeat(64),
|
||||
partNumber: 1,
|
||||
offset: 0,
|
||||
byteLength: bytes.byteLength,
|
||||
checksumSha256: checksum,
|
||||
idempotencyKey: "part-attempt-1",
|
||||
bytes,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
|
||||
expect(observations).toEqual([
|
||||
{
|
||||
operation: "PRESIGNED_TRANSFER",
|
||||
outcome: "SUCCEEDED",
|
||||
byteBucket: "LT1MIB",
|
||||
},
|
||||
{
|
||||
operation: "PRESIGNED_TRANSFER",
|
||||
outcome: "SUCCEEDED",
|
||||
byteBucket: "LT1MIB",
|
||||
},
|
||||
{
|
||||
operation: "DOWNLOAD",
|
||||
outcome: "SUCCEEDED",
|
||||
byteBucket: "LT1MIB",
|
||||
},
|
||||
{
|
||||
operation: "PRESIGNED_TRANSFER",
|
||||
outcome: "SUCCEEDED",
|
||||
byteBucket: "LT1MIB",
|
||||
},
|
||||
{
|
||||
operation: "UPLOAD_PART",
|
||||
outcome: "SUCCEEDED",
|
||||
byteBucket: "LT1MIB",
|
||||
},
|
||||
]);
|
||||
const serialized = JSON.stringify(observations);
|
||||
for (const secret of [
|
||||
DOWNLOAD_HREF,
|
||||
"do-not-log-this",
|
||||
checksum,
|
||||
"capability-download-1",
|
||||
"capability-upload-1",
|
||||
"part-etag-secret",
|
||||
"resource-1",
|
||||
]) {
|
||||
expect(serialized).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -965,36 +965,6 @@ describe("security follow-up contracts", () => {
|
||||
await expect(running).rejects.toThrow(/timed out.*kill failed.*close/u);
|
||||
});
|
||||
|
||||
it("kills and reaps a stubborn provider process group including its descendant", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "provider-process-group-"));
|
||||
const descendantPidPath = path.join(root, "descendant.pid");
|
||||
try {
|
||||
const source = [
|
||||
"const { spawn } = require('node:child_process');",
|
||||
"const { writeFileSync } = require('node:fs');",
|
||||
"const child = spawn(process.execPath, ['-e', `process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)`], { stdio: 'ignore' });",
|
||||
"writeFileSync(process.env.DESCENDANT_PID_PATH, String(child.pid));",
|
||||
"process.on('SIGTERM', () => {});",
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n");
|
||||
const running = runProviderProcess({
|
||||
executable: process.execPath,
|
||||
arguments: ["-e", source],
|
||||
environment: {
|
||||
PATH: process.env.PATH,
|
||||
DESCENDANT_PID_PATH: descendantPidPath,
|
||||
},
|
||||
timeoutMs: 250,
|
||||
});
|
||||
await expect(running).rejects.toThrow(/timed out.*process close/u);
|
||||
const descendantPid = Number(await readFile(descendantPidPath, "utf8"));
|
||||
expect(Number.isSafeInteger(descendantPid) && descendantPid > 0).toBe(true);
|
||||
expect(() => process.kill(descendantPid, 0)).toThrow(/ESRCH|no such process/u);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["open failure", "partial write failure"])(
|
||||
"cleans finalized staging from memory when GITHUB_OUTPUT has a %s",
|
||||
async (failureKind) => {
|
||||
|
||||
@@ -182,9 +182,9 @@ describe("selective Task 3 contract closure", () => {
|
||||
it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => {
|
||||
const canonical = await loadCiGateContract(process.cwd());
|
||||
expect(canonical.gates).toHaveLength(27);
|
||||
expect(canonical.commands).toHaveLength(82);
|
||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(94);
|
||||
expect(canonical.artifacts).toHaveLength(107);
|
||||
expect(canonical.commands).toHaveLength(84);
|
||||
expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(96);
|
||||
expect(canonical.artifacts).toHaveLength(109);
|
||||
expect(canonical.stages).toHaveLength(5);
|
||||
expect(canonical.retention.classes).toHaveLength(5);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user