chore: sync the frontend template from 4dc033c to 8157ad4
The product was materialized from the template at `4dc033c` and has stayed on it through 43 template commits, so it was missing all three rounds of adapter remediation — including files it never had, such as the shared `abortable-operation` primitive and the `exact-snapshot` decoder that later fixes are written against. Taking only the newest round was not possible for that reason: the delta is coherent only as a whole. The product had not touched `src/adapters` at all since materialization, so the 140-file delta applied with a three-way merge and no conflicts. `package.json` was the single overlap and merged cleanly: the product owns `name`, the template contributed `check:adapter-inventory`, `check:remediation-ledger` and the image-resolve-signal type fixture. All 24 product-owned files — README, index.html, CI workflow, i18n catalog, home page, generated schemas, evidence scripts, component and visual snapshots — are byte-identical to `main`. `template.lock.json` now pins the synced revision and tree. Verified in this repository, not inherited from the template: six type projects, lint, nine gates (adapter inventory, remediation ledger, registries, diagnostics, realtime boundaries, architecture, browser file/storage boundaries, optional recipes, documentation), the production build, and 2,054 of 2,073 tests. The 19 failures are all in `tests/unit/ci-artifact-contract.test.ts` and are the same pre-existing sandbox RLIMIT, EMFILE, umask and `/tmp` permission behaviour the template records; four suites that failed once under parallel load pass in isolation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
002ba3624e
commit
4bff9ca151
@@ -0,0 +1,371 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
compensateLateHandle,
|
||||
createAbortableOperation,
|
||||
} from "../../src/adapters/platform/abortable-operation.ts";
|
||||
|
||||
/**
|
||||
* BT-X-01 golden tests. The utility owns abort mechanics only: it must never
|
||||
* import or imply a subsystem result taxonomy.
|
||||
*/
|
||||
describe("shared abortable operation mechanics", () => {
|
||||
it("records the first terminal owner and never overwrites it", async () => {
|
||||
const caller = new AbortController();
|
||||
const timers: Array<() => void> = [];
|
||||
const operation = createAbortableOperation({
|
||||
signal: caller.signal,
|
||||
timeoutMs: 10,
|
||||
setTimer: (callback) => {
|
||||
timers.push(callback);
|
||||
return timers.length;
|
||||
},
|
||||
clearTimer: () => {},
|
||||
});
|
||||
|
||||
expect(operation.terminal()).toBeNull();
|
||||
caller.abort();
|
||||
expect(operation.terminal()).toBe("CALLER_ABORT");
|
||||
|
||||
// A later deadline or close cannot rewrite the owner.
|
||||
timers.forEach((callback) => callback());
|
||||
operation.close();
|
||||
expect(operation.terminal()).toBe("CALLER_ABORT");
|
||||
});
|
||||
|
||||
it("reports a deadline owner and aborts the composed signal", async () => {
|
||||
const timers: Array<() => void> = [];
|
||||
const operation = createAbortableOperation({
|
||||
timeoutMs: 5,
|
||||
setTimer: (callback) => {
|
||||
timers.push(callback);
|
||||
return timers.length;
|
||||
},
|
||||
clearTimer: () => {},
|
||||
});
|
||||
timers[0]?.();
|
||||
expect(operation.terminal()).toBe("DEADLINE");
|
||||
expect(operation.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("race returns a terminal owner instead of a bare value", async () => {
|
||||
const caller = new AbortController();
|
||||
const operation = createAbortableOperation({ signal: caller.signal });
|
||||
let release: ((value: string) => void) | undefined;
|
||||
const pending = new Promise<string>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const raced = operation.race(pending);
|
||||
caller.abort();
|
||||
await expect(raced).resolves.toEqual({
|
||||
kind: "TERMINAL",
|
||||
terminal: "CALLER_ABORT",
|
||||
});
|
||||
|
||||
// The late value is observed and discarded.
|
||||
release?.("late");
|
||||
await expect(operation.race(pending)).resolves.toEqual({
|
||||
kind: "TERMINAL",
|
||||
terminal: "CALLER_ABORT",
|
||||
});
|
||||
});
|
||||
|
||||
it("race returns the value when nothing terminal happened", async () => {
|
||||
const operation = createAbortableOperation();
|
||||
await expect(operation.race(Promise.resolve(7))).resolves.toEqual({
|
||||
kind: "VALUE",
|
||||
value: 7,
|
||||
});
|
||||
operation.close();
|
||||
expect(operation.terminal()).toBe("CLOSED");
|
||||
});
|
||||
|
||||
/**
|
||||
* TR-RR-05. A collaborator's own rejection is evidence about the work.
|
||||
* Reporting it as `TERMINAL/CLOSED` erased the reason the operation failed
|
||||
* and made `race()` disagree with `terminal()`, which still said no owner.
|
||||
*/
|
||||
it("keeps a rejection distinct from a terminal owner", async () => {
|
||||
const operation = createAbortableOperation();
|
||||
const reason = new Error("upstream failed");
|
||||
await expect(
|
||||
operation.race(Promise.reject(reason)),
|
||||
).resolves.toEqual({ kind: "REJECTED", reason });
|
||||
expect(operation.terminal()).toBeNull();
|
||||
});
|
||||
|
||||
it("agrees with terminal() on the first owner", async () => {
|
||||
const caller = new AbortController();
|
||||
const operation = createAbortableOperation({ signal: caller.signal });
|
||||
const pending = operation.race(new Promise<never>(() => {}));
|
||||
caller.abort();
|
||||
// A later close cannot overwrite the first owner in either place.
|
||||
operation.close();
|
||||
await expect(pending).resolves.toEqual({
|
||||
kind: "TERMINAL",
|
||||
terminal: "CALLER_ABORT",
|
||||
});
|
||||
expect(operation.terminal()).toBe("CALLER_ABORT");
|
||||
});
|
||||
|
||||
it("compensates a late value exactly once instead of admitting it", async () => {
|
||||
const caller = new AbortController();
|
||||
const operation = createAbortableOperation({ signal: caller.signal });
|
||||
const compensated: string[] = [];
|
||||
let release: ((value: string) => void) | undefined;
|
||||
const pending = operation.race(
|
||||
new Promise<string>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
(value) => compensated.push(value),
|
||||
);
|
||||
caller.abort();
|
||||
expect(await pending).toEqual({
|
||||
kind: "TERMINAL",
|
||||
terminal: "CALLER_ABORT",
|
||||
});
|
||||
release?.("late-value");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(compensated).toEqual(["late-value"]);
|
||||
});
|
||||
|
||||
it("ignores a scheduler method replaced after construction", async () => {
|
||||
const scheduler = {
|
||||
setTimer: (callback: () => void, delayMs: number) =>
|
||||
setTimeout(callback, delayMs),
|
||||
clearTimer: (handle: unknown) => {
|
||||
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
const operation = createAbortableOperation({
|
||||
timeoutMs: 5,
|
||||
setTimer: scheduler.setTimer,
|
||||
clearTimer: scheduler.clearTimer,
|
||||
});
|
||||
scheduler.setTimer = () => {
|
||||
throw new TypeError("replaced after construction");
|
||||
};
|
||||
await expect(
|
||||
operation.race(new Promise<never>(() => {})),
|
||||
).resolves.toEqual({ kind: "TERMINAL", terminal: "DEADLINE" });
|
||||
});
|
||||
|
||||
it("close is idempotent and releases listeners and timers exactly once", () => {
|
||||
const caller = new AbortController();
|
||||
const remove = vi.spyOn(caller.signal, "removeEventListener");
|
||||
const clearTimer = vi.fn();
|
||||
const operation = createAbortableOperation({
|
||||
signal: caller.signal,
|
||||
timeoutMs: 10,
|
||||
setTimer: () => "handle",
|
||||
clearTimer,
|
||||
});
|
||||
|
||||
operation.close();
|
||||
operation.close();
|
||||
operation.close();
|
||||
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
expect(clearTimer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* TR-RR-05. A scheduler that cannot install the deadline leaves the operation
|
||||
* unbounded. Removing the caller listener and leaving no terminal owner made
|
||||
* every later abort invisible, so installation failure is itself terminal and
|
||||
* closes atomically with the resources already created.
|
||||
*/
|
||||
it("closes atomically when the scheduler cannot install the deadline", async () => {
|
||||
const caller = new AbortController();
|
||||
const remove = vi.spyOn(caller.signal, "removeEventListener");
|
||||
const operation = createAbortableOperation({
|
||||
signal: caller.signal,
|
||||
timeoutMs: 10,
|
||||
setTimer: () => {
|
||||
throw new TypeError("scheduler exploded");
|
||||
},
|
||||
clearTimer: () => {},
|
||||
});
|
||||
|
||||
expect(operation.terminal()).toBe("CLOSED");
|
||||
expect(remove).toHaveBeenCalledTimes(1);
|
||||
// The operation is bounded, so a caller abort afterwards cannot be lost.
|
||||
caller.abort();
|
||||
await expect(
|
||||
operation.race(new Promise<never>(() => {})),
|
||||
).resolves.toEqual({ kind: "TERMINAL", terminal: "CLOSED" });
|
||||
});
|
||||
|
||||
/**
|
||||
* X-AUDIT-01. The outcome must be decided by which callback was actually
|
||||
* observed first, not by how many microtasks the primitive happens to drain
|
||||
* before it gives up waiting. A terminal owner fixed synchronously owns the
|
||||
* result even when the task rejects later in the same call stack.
|
||||
*/
|
||||
describe("settle-once ownership across callback orderings", () => {
|
||||
const terminalOwners = [
|
||||
{
|
||||
label: "caller abort",
|
||||
owner: "CALLER_ABORT" as const,
|
||||
trigger: (caller: AbortController, operation: { close(): void }, timers: Array<() => void>) => {
|
||||
void operation;
|
||||
void timers;
|
||||
caller.abort();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "deadline",
|
||||
owner: "DEADLINE" as const,
|
||||
trigger: (
|
||||
caller: AbortController,
|
||||
operation: { close(): void },
|
||||
timers: Array<() => void>,
|
||||
) => {
|
||||
void caller;
|
||||
void operation;
|
||||
timers.forEach((callback) => callback());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "close",
|
||||
owner: "CLOSED" as const,
|
||||
trigger: (
|
||||
caller: AbortController,
|
||||
operation: { close(): void },
|
||||
timers: Array<() => void>,
|
||||
) => {
|
||||
void caller;
|
||||
void timers;
|
||||
operation.close();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const createHarness = () => {
|
||||
const caller = new AbortController();
|
||||
const timers: Array<() => void> = [];
|
||||
const operation = createAbortableOperation({
|
||||
signal: caller.signal,
|
||||
timeoutMs: 1_000,
|
||||
setTimer: (callback) => {
|
||||
timers.push(callback);
|
||||
return timers.length;
|
||||
},
|
||||
clearTimer: () => {},
|
||||
});
|
||||
return { caller, timers, operation };
|
||||
};
|
||||
|
||||
for (const { label, owner, trigger } of terminalOwners) {
|
||||
it(`keeps ${label} as the owner when the task rejects in the same call stack`, async () => {
|
||||
const { caller, timers, operation } = createHarness();
|
||||
let reject: ((reason: unknown) => void) | undefined;
|
||||
const task = new Promise<never>((_resolve, rejectTask) => {
|
||||
reject = rejectTask;
|
||||
});
|
||||
const raced = operation.race(task);
|
||||
|
||||
trigger(caller, operation, timers);
|
||||
expect(operation.terminal()).toBe(owner);
|
||||
reject?.(new Error("rejected after the terminal owner was fixed"));
|
||||
|
||||
await expect(raced).resolves.toEqual({ kind: "TERMINAL", terminal: owner });
|
||||
expect(operation.terminal()).toBe(owner);
|
||||
});
|
||||
|
||||
it(`keeps ${label} as the owner regardless of how many microtasks drain`, async () => {
|
||||
for (const drains of [0, 1, 2, 3, 5, 9]) {
|
||||
const { caller, timers, operation } = createHarness();
|
||||
let reject: ((reason: unknown) => void) | undefined;
|
||||
const task = new Promise<never>((_resolve, rejectTask) => {
|
||||
reject = rejectTask;
|
||||
});
|
||||
const raced = operation.race(task);
|
||||
|
||||
trigger(caller, operation, timers);
|
||||
for (let turn = 0; turn < drains; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
reject?.(new Error("late rejection"));
|
||||
|
||||
await expect(raced).resolves.toEqual({
|
||||
kind: "TERMINAL",
|
||||
terminal: owner,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps a rejection that was observed before any terminal owner", async () => {
|
||||
const { caller, operation } = createHarness();
|
||||
const reason = new Error("task failed first");
|
||||
const raced = operation.race(Promise.reject(reason));
|
||||
|
||||
// Let the rejection callback actually run before the abort is requested.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
caller.abort();
|
||||
|
||||
await expect(raced).resolves.toEqual({ kind: "REJECTED", reason });
|
||||
});
|
||||
|
||||
it("keeps a value that was observed before any terminal owner", async () => {
|
||||
const { caller, operation } = createHarness();
|
||||
const compensated: string[] = [];
|
||||
const raced = operation.race(Promise.resolve("early"), (value) =>
|
||||
compensated.push(value),
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
caller.abort();
|
||||
|
||||
await expect(raced).resolves.toEqual({ kind: "VALUE", value: "early" });
|
||||
expect(compensated).toEqual([]);
|
||||
});
|
||||
|
||||
it("never compensates or re-raises a rejection that lands after the owner", async () => {
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
try {
|
||||
const { caller, operation } = createHarness();
|
||||
const compensated: unknown[] = [];
|
||||
let reject: ((reason: unknown) => void) | undefined;
|
||||
const task = new Promise<never>((_resolve, rejectTask) => {
|
||||
reject = rejectTask;
|
||||
});
|
||||
const raced = operation.race(task, (value) => compensated.push(value));
|
||||
|
||||
caller.abort();
|
||||
reject?.(new Error("late rejection"));
|
||||
await expect(raced).resolves.toEqual({
|
||||
kind: "TERMINAL",
|
||||
terminal: "CALLER_ABORT",
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(compensated).toEqual([]);
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("compensates a late native handle without changing the outcome", async () => {
|
||||
const cancel = vi.fn(async () => {});
|
||||
compensateLateHandle(Promise.resolve({ body: { cancel } }));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
|
||||
// A rejecting handle is swallowed.
|
||||
compensateLateHandle(Promise.reject(new Error("late")));
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { readBoundedJson } from "../../src/adapters/http/bounded-json.ts";
|
||||
|
||||
/**
|
||||
* N-08. The legacy V2 reader delegates to the common bounded reader. These
|
||||
* cases pin the preserved failure codes and prove that a throwing cancel or
|
||||
* releaseLock cannot escape the closed result.
|
||||
*/
|
||||
function responseWith(
|
||||
body: ReadableStream<Uint8Array> | string | null,
|
||||
init: ResponseInit = {},
|
||||
): Response {
|
||||
return new Response(body, init);
|
||||
}
|
||||
|
||||
describe("legacy bounded JSON compatibility", () => {
|
||||
it("preserves RESPONSE_BODY_LIMIT for a declared oversize body", async () => {
|
||||
const response = responseWith("{}", {
|
||||
headers: { "content-length": "9999" },
|
||||
});
|
||||
await expect(readBoundedJson(response, 8)).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "RESPONSE_BODY_LIMIT",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves RESPONSE_BODY_LIMIT for a headerless oversize body", async () => {
|
||||
const response = responseWith("[1,2,3,4,5,6,7,8,9,10]");
|
||||
await expect(readBoundedJson(response, 4)).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "RESPONSE_BODY_LIMIT",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["invalid UTF-8", new Uint8Array([0xff, 0xfe, 0xfd])],
|
||||
["malformed JSON", new TextEncoder().encode("{")],
|
||||
["an empty body", new Uint8Array(0)],
|
||||
])("maps %s to MALFORMED_JSON", async (_label, bytes) => {
|
||||
const response = responseWith(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
if (bytes.byteLength > 0) controller.enqueue(bytes);
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(readBoundedJson(response, 1_024)).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "MALFORMED_JSON",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a closed result when reader cancel or releaseLock throws", async () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("[1,2,3,4,5,6,7,8]"));
|
||||
},
|
||||
});
|
||||
const response = responseWith(stream);
|
||||
const body = response.body!;
|
||||
const nativeGetReader = body.getReader.bind(body);
|
||||
vi.spyOn(body, "getReader").mockImplementation((() => {
|
||||
const actual = nativeGetReader();
|
||||
return {
|
||||
...actual,
|
||||
read: actual.read.bind(actual),
|
||||
cancel: async () => {
|
||||
throw new TypeError("cancel exploded");
|
||||
},
|
||||
releaseLock: () => {
|
||||
throw new TypeError("releaseLock exploded");
|
||||
},
|
||||
};
|
||||
}) as never);
|
||||
|
||||
await expect(readBoundedJson(response, 4)).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "RESPONSE_BODY_LIMIT",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads a bounded JSON value successfully", async () => {
|
||||
const response = responseWith('{"a":1}');
|
||||
await expect(readBoundedJson(response, 1_024)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { a: 1 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -248,12 +248,49 @@ describe("browser download delivery", () => {
|
||||
ok: true,
|
||||
value: { kind: "BROWSER_HANDOFF", transferId: "transfer:1" },
|
||||
});
|
||||
// STO-02. Parse once, then execute exactly what was validated.
|
||||
expect(handoff).toHaveBeenCalledWith(
|
||||
"/downloads/artifact-1",
|
||||
"https://app.example/downloads/artifact-1",
|
||||
"invoice_exe.pdf",
|
||||
);
|
||||
});
|
||||
|
||||
it("executes the canonical target instead of a document-base-relative href", async () => {
|
||||
const handoff = vi.fn();
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff },
|
||||
// A relative href the host would otherwise resolve against a hostile
|
||||
// document <base href="https://evil.example/">.
|
||||
browserManagedCapabilities: capabilityResolver(() => "downloads/a"),
|
||||
baseOrigin: "https://app.example",
|
||||
createTransferId: () => "transfer:1",
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
|
||||
expect(
|
||||
await adapter.deliver(
|
||||
deliveryInput(
|
||||
{
|
||||
kind: "BROWSER_MANAGED_RESOURCE",
|
||||
resourceId: "artifact-1",
|
||||
capabilityReceipt,
|
||||
},
|
||||
"BROWSER_MANAGED",
|
||||
),
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(handoff).toHaveBeenCalledWith(
|
||||
"https://app.example/downloads/a",
|
||||
"invoice_exe.pdf",
|
||||
);
|
||||
for (const [href] of handoff.mock.calls) {
|
||||
expect(String(href).startsWith("https://app.example/")).toBe(true);
|
||||
expect(String(href)).not.toContain("evil.example");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects cross-origin or query-bearing browser-managed targets", async () => {
|
||||
const handoff = vi.fn();
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
composeBrowserRpcRequestEncoderRegistry,
|
||||
defineBrowserRpcOperation,
|
||||
defineBrowserRpcProviderProfile,
|
||||
installBrowserRpcContractBindings,
|
||||
validateBrowserRpcContractBindings,
|
||||
type BrowserRpcProviderProfile,
|
||||
} from "../../../src/contracts/browser-rpc.ts";
|
||||
@@ -23,6 +24,91 @@ import {
|
||||
} from "./fixture.ts";
|
||||
|
||||
describe("Browser RPC contract registry", () => {
|
||||
it("snapshots installed bindings before later source mutation", () => {
|
||||
const operations: Record<string, ReturnType<typeof unaryOperation>> = {
|
||||
GET_RPC_RESOURCE: unaryOperation(),
|
||||
};
|
||||
const installed = installBrowserRpcContractBindings({
|
||||
operations,
|
||||
profiles: { [unaryProfile().runtimeProfileId]: unaryProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER },
|
||||
});
|
||||
const before = installed.operations.get("GET_RPC_RESOURCE");
|
||||
expect(before?.totalDeadlineMs).toBeDefined();
|
||||
|
||||
// R-04. A post-validation mutation of the source registry must not reach
|
||||
// the installed snapshot.
|
||||
operations.GET_RPC_RESOURCE = {
|
||||
...operations.GET_RPC_RESOURCE!,
|
||||
totalDeadlineMs: 999_999,
|
||||
};
|
||||
expect(installed.operations.get("GET_RPC_RESOURCE")).toBe(before);
|
||||
expect(
|
||||
installed.operations.get("GET_RPC_RESOURCE")?.totalDeadlineMs,
|
||||
).not.toBe(999_999);
|
||||
});
|
||||
|
||||
it("rejects extra accessor and symbol keys without invoking getters", () => {
|
||||
let getterCalls = 0;
|
||||
const accessorOperation = Object.defineProperty(
|
||||
{ ...unaryOperation() },
|
||||
"totalDeadlineMs",
|
||||
{
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get() {
|
||||
getterCalls += 1;
|
||||
return 1_000;
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(() =>
|
||||
installBrowserRpcContractBindings({
|
||||
operations: { GET_RPC_RESOURCE: accessorOperation },
|
||||
profiles: { [unaryProfile().runtimeProfileId]: unaryProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER },
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(getterCalls).toBe(0);
|
||||
|
||||
const extraKeyOperation = {
|
||||
...unaryOperation(),
|
||||
unexpectedKey: "smuggled",
|
||||
};
|
||||
expect(() =>
|
||||
installBrowserRpcContractBindings({
|
||||
operations: {
|
||||
GET_RPC_RESOURCE: extraKeyOperation as never,
|
||||
},
|
||||
profiles: { [unaryProfile().runtimeProfileId]: unaryProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER },
|
||||
}),
|
||||
).toThrow(/unexpected key/u);
|
||||
|
||||
const symbolRegistry: Record<string, unknown> = {
|
||||
GET_RPC_RESOURCE: unaryOperation(),
|
||||
};
|
||||
Object.defineProperty(symbolRegistry, Symbol("hidden"), {
|
||||
enumerable: true,
|
||||
value: unaryOperation(),
|
||||
});
|
||||
expect(() =>
|
||||
installBrowserRpcContractBindings({
|
||||
operations: symbolRegistry as never,
|
||||
profiles: { [unaryProfile().runtimeProfileId]: unaryProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER },
|
||||
}),
|
||||
).toThrow(/symbol keys/u);
|
||||
});
|
||||
|
||||
it("closes exact operation, provider, schema, mapper and encoder bindings", () => {
|
||||
const operation = unaryOperation();
|
||||
const profile = unaryProfile();
|
||||
|
||||
@@ -0,0 +1,790 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createBrowserRpcRuntime,
|
||||
defineBrowserRpcTransport,
|
||||
type BrowserRpcServerStreamLease,
|
||||
type BrowserRpcStreamFrame,
|
||||
type BrowserRpcTransport,
|
||||
} from "../../../src/adapters/browser-rpc/index.ts";
|
||||
import { decodeServerStreamLease } from "../../../src/adapters/browser-rpc/transport.ts";
|
||||
import { installBrowserRpcContractBindings } from "../../../src/contracts/browser-rpc.ts";
|
||||
import {
|
||||
MAPPERS,
|
||||
SCHEMA_CODECS,
|
||||
STREAM_ENCODER,
|
||||
UNARY_ENCODER,
|
||||
isResourceView,
|
||||
streamOperation,
|
||||
streamProfile,
|
||||
unaryOperation,
|
||||
unaryProfile,
|
||||
} from "./fixture.ts";
|
||||
|
||||
function unaryRuntime(
|
||||
transport: BrowserRpcTransport,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
return createBrowserRpcRuntime({
|
||||
operations: { GET_RPC_RESOURCE: unaryOperation() },
|
||||
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER },
|
||||
transports: { CONNECT_REFERENCE_UNARY: transport },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function streamingRuntime(
|
||||
transport: BrowserRpcTransport,
|
||||
extra: Record<string, unknown> = {},
|
||||
) {
|
||||
return createBrowserRpcRuntime({
|
||||
operations: { WATCH_RPC_RESOURCES: streamOperation() },
|
||||
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER },
|
||||
transports: { CONNECT_REFERENCE_STREAM: transport },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function unaryTransport(
|
||||
invokeUnary: BrowserRpcTransport["invokeUnary"],
|
||||
): BrowserRpcTransport {
|
||||
return defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "UNARY",
|
||||
invokeUnary,
|
||||
});
|
||||
}
|
||||
|
||||
type LeaseProbe = Readonly<{
|
||||
transport: BrowserRpcTransport;
|
||||
cancels: string[];
|
||||
readonly opened: number;
|
||||
close(): void;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* RPC-RR-01. A transport whose physical stream does not close on its own. The
|
||||
* runtime must cancel it exactly once and must not admit a second stream for
|
||||
* the same operation until `waitClosed()` settles.
|
||||
*/
|
||||
function nonCooperativeStreamTransport(): LeaseProbe {
|
||||
const cancels: string[] = [];
|
||||
const counter = { opened: 0 };
|
||||
let release: (() => void) | undefined;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const openServerStream = (): BrowserRpcServerStreamLease => {
|
||||
counter.opened += 1;
|
||||
return {
|
||||
streamId: `physical-${counter.opened}`,
|
||||
frames: {
|
||||
[Symbol.asyncIterator]: () =>
|
||||
({
|
||||
// Never yields and never settles: the runtime's own deadline is the
|
||||
// only thing that can end the call.
|
||||
next: () => new Promise<never>(() => {}),
|
||||
}) as AsyncIterator<BrowserRpcStreamFrame>,
|
||||
},
|
||||
cancel(reason: string) {
|
||||
cancels.push(reason);
|
||||
},
|
||||
waitClosed: () => closed,
|
||||
};
|
||||
};
|
||||
const transport = defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
openServerStream,
|
||||
});
|
||||
return Object.freeze({
|
||||
transport,
|
||||
cancels,
|
||||
get opened() {
|
||||
return counter.opened;
|
||||
},
|
||||
close: () => release?.(),
|
||||
}) as LeaseProbe;
|
||||
}
|
||||
|
||||
function streamTransport(
|
||||
frames: readonly BrowserRpcStreamFrame[],
|
||||
): BrowserRpcTransport {
|
||||
return defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
openServerStream: () => ({
|
||||
streamId: "remediation-stream",
|
||||
frames: {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const frame of frames) yield frame;
|
||||
},
|
||||
},
|
||||
cancel() {},
|
||||
async waitClosed() {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function collect(
|
||||
source: AsyncIterable<unknown>,
|
||||
): Promise<readonly unknown[]> {
|
||||
const seen: unknown[] = [];
|
||||
for await (const value of source) seen.push(value);
|
||||
return seen;
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC-RR-02. A collaborator that throws synchronously must not escape the
|
||||
* Result contract. A generation fence captured outside the protected boundary
|
||||
* and a `clock.sleep` invoked outside a promise boundary both did exactly that,
|
||||
* and the second also skipped the listener and timer release.
|
||||
*/
|
||||
describe("RPC-RR-02 synchronous collaborator throws stay inside Result", () => {
|
||||
const throwingFence = {
|
||||
capture: () => {
|
||||
throw new TypeError("fence exploded");
|
||||
},
|
||||
isCurrent: () => true,
|
||||
};
|
||||
|
||||
it("closes a unary call whose fence throws", async () => {
|
||||
const runtime = unaryRuntime(
|
||||
unaryTransport(async () => ({
|
||||
ok: true,
|
||||
message: { id: "a", name: "A" },
|
||||
encodedBytes: 8,
|
||||
})),
|
||||
{ generationFence: throwingFence },
|
||||
);
|
||||
|
||||
const result = await runtime
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "resource-1" });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("closes a stream whose fence throws", async () => {
|
||||
const runtime = streamingRuntime(
|
||||
streamTransport([{ kind: "TERMINAL", ok: true }]),
|
||||
{ generationFence: throwingFence },
|
||||
);
|
||||
|
||||
const results = await collect(
|
||||
runtime
|
||||
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||
.open({ resourceId: "scope-1" }),
|
||||
);
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("closes a unary call whose clock throws synchronously", async () => {
|
||||
const runtime = unaryRuntime(
|
||||
unaryTransport(() => new Promise(() => {})),
|
||||
{
|
||||
clock: {
|
||||
now: () => 0,
|
||||
sleep: () => {
|
||||
throw new TypeError("clock exploded");
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runtime
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "resource-1" });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* RPC-RR-03. The runtime reads a validated snapshot, never the caller's
|
||||
* objects: an accessor is refused without being invoked, and no installed
|
||||
* registry exposes a mutator.
|
||||
*/
|
||||
describe("RPC-RR-03 transport and binding registries are snapshots", () => {
|
||||
it("never invokes a transport accessor", () => {
|
||||
let getterCalls = 0;
|
||||
const hostile = {} as Record<string, unknown>;
|
||||
Object.defineProperties(hostile, {
|
||||
runtimeProfileId: {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
getterCalls += 1;
|
||||
return "CONNECT_REFERENCE_UNARY";
|
||||
},
|
||||
},
|
||||
providerId: { enumerable: true, value: "REFERENCE_RPC" },
|
||||
protocol: { enumerable: true, value: "CONNECT_HTTP" },
|
||||
rpcKind: { enumerable: true, value: "UNARY" },
|
||||
invokeUnary: {
|
||||
enumerable: true,
|
||||
value: async () => ({ ok: true, message: {}, encodedBytes: 1 }),
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
unaryRuntime(hostile as unknown as BrowserRpcTransport),
|
||||
).toThrow(TypeError);
|
||||
expect(getterCalls).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an unexpected own field on a transport row", () => {
|
||||
const transport = unaryTransport(async () => ({
|
||||
ok: true,
|
||||
message: { id: "a", name: "A" },
|
||||
encodedBytes: 8,
|
||||
}));
|
||||
const widened = { ...transport, injected: true };
|
||||
expect(() =>
|
||||
unaryRuntime(widened as unknown as BrowserRpcTransport),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("exposes no mutation API on any installed binding registry", () => {
|
||||
const installed = installBrowserRpcContractBindings({
|
||||
operations: { GET_RPC_RESOURCE: unaryOperation() },
|
||||
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER },
|
||||
});
|
||||
for (const registry of Object.values(installed)) {
|
||||
const record = registry as unknown as Record<string, unknown>;
|
||||
for (const mutator of ["set", "delete", "clear"]) {
|
||||
expect(record[mutator]).toBeUndefined();
|
||||
}
|
||||
expect(() =>
|
||||
Map.prototype.clear.call(registry as never),
|
||||
).toThrow();
|
||||
}
|
||||
expect(installed.operations.get("GET_RPC_RESOURCE")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* RPC-RR-04. Transport values are decoded, not adopted. `in` and a direct
|
||||
* property read run accessors and admit inherited or extra fields, and keeping
|
||||
* the caller's object lets it change after validation.
|
||||
*/
|
||||
describe("RPC-RR-04 transport results and frames are exactly decoded", () => {
|
||||
const hostileResults: readonly (readonly [string, () => unknown])[] = [
|
||||
["extra own field", () => ({ ok: true, message: {}, encodedBytes: 1, injected: 1 })],
|
||||
[
|
||||
"inherited fields",
|
||||
() =>
|
||||
Object.create({ ok: true, message: {}, encodedBytes: 1 }) as object,
|
||||
],
|
||||
[
|
||||
"throwing getter",
|
||||
() => {
|
||||
const value: Record<string, unknown> = { message: {}, encodedBytes: 1 };
|
||||
Object.defineProperty(value, "ok", {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
throw new TypeError("hostile getter");
|
||||
},
|
||||
});
|
||||
return value;
|
||||
},
|
||||
],
|
||||
[
|
||||
"symbol key",
|
||||
() => ({
|
||||
ok: true,
|
||||
message: {},
|
||||
encodedBytes: 1,
|
||||
[Symbol("injected")]: 1,
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
for (const [label, build] of hostileResults) {
|
||||
it(`refuses a unary result with ${label}`, async () => {
|
||||
const runtime = unaryRuntime(
|
||||
unaryTransport(async () => build() as never),
|
||||
);
|
||||
const result = await runtime
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "resource-1" });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
it("refuses an unknown transport failure code", async () => {
|
||||
const runtime = unaryRuntime(
|
||||
unaryTransport(async () => ({
|
||||
ok: false,
|
||||
failure: { code: "MADE_UP_CODE" },
|
||||
}) as never),
|
||||
);
|
||||
const result = await runtime
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "resource-1" });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a retryAfterMs outside the hard ceiling", async () => {
|
||||
const runtime = unaryRuntime(
|
||||
unaryTransport(async () => ({
|
||||
ok: false,
|
||||
failure: { code: "NETWORK_UNREACHABLE", retryAfterMs: -1 },
|
||||
}) as never),
|
||||
);
|
||||
const result = await runtime
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "resource-1" });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a stream frame with an extra own field", async () => {
|
||||
const runtime = streamingRuntime(
|
||||
streamTransport([
|
||||
{
|
||||
kind: "MESSAGE",
|
||||
message: { id: "a", name: "A" },
|
||||
encodedBytes: 4,
|
||||
injected: 1,
|
||||
} as never,
|
||||
]),
|
||||
);
|
||||
const results = await collect(
|
||||
runtime
|
||||
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||
.open({ resourceId: "scope-1" }),
|
||||
);
|
||||
expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* RPC-RR-01. A bare `AsyncIterable` gave the runtime no way to cancel the
|
||||
* physical stream or to learn when it actually closed, so a timed-out call left
|
||||
* the first stream running against the server while a second was admitted.
|
||||
*/
|
||||
describe("RPC-RR-01 server stream leases and the DRAINING fence", () => {
|
||||
const shortDeadline = () =>
|
||||
streamOperation({ totalDeadlineMs: 25, idleDeadlineMs: 25 });
|
||||
|
||||
it("cancels the physical stream exactly once after a timeout", async () => {
|
||||
const probe = nonCooperativeStreamTransport();
|
||||
const runtime = createBrowserRpcRuntime({
|
||||
operations: { WATCH_RPC_RESOURCES: shortDeadline() },
|
||||
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER },
|
||||
transports: { CONNECT_REFERENCE_STREAM: probe.transport },
|
||||
});
|
||||
|
||||
const results = await collect(
|
||||
runtime
|
||||
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||
.open({ resourceId: "scope-1" }),
|
||||
);
|
||||
|
||||
expect(results.every((value) => (value as { ok: boolean }).ok === false)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(probe.cancels.length).toBe(1);
|
||||
});
|
||||
|
||||
it("refuses a second stream while the first has not confirmed closure", async () => {
|
||||
const probe = nonCooperativeStreamTransport();
|
||||
const runtime = createBrowserRpcRuntime({
|
||||
operations: { WATCH_RPC_RESOURCES: shortDeadline() },
|
||||
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER },
|
||||
transports: { CONNECT_REFERENCE_STREAM: probe.transport },
|
||||
});
|
||||
const stream = runtime.bindServerStream(
|
||||
"WATCH_RPC_RESOURCES",
|
||||
isResourceView,
|
||||
);
|
||||
|
||||
await collect(stream.open({ resourceId: "scope-1" }));
|
||||
expect(probe.opened).toBe(1);
|
||||
|
||||
const second = await collect(stream.open({ resourceId: "scope-1" }));
|
||||
expect(second).toHaveLength(1);
|
||||
expect(second[0]).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "RPC_STREAM_DRAINING" },
|
||||
});
|
||||
// The refused call never reached the transport.
|
||||
expect(probe.opened).toBe(1);
|
||||
|
||||
// Once the transport confirms closure the operation admits work again.
|
||||
probe.close();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await collect(stream.open({ resourceId: "scope-1" }));
|
||||
expect(probe.opened).toBe(2);
|
||||
});
|
||||
|
||||
it("refuses a malformed lease as a protocol failure", async () => {
|
||||
for (const malformed of [
|
||||
{ frames: { [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }, cancel() {}, async waitClosed() {} },
|
||||
{ streamId: "", frames: { [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }, cancel() {}, async waitClosed() {} },
|
||||
{ streamId: "s1", frames: {}, cancel() {}, async waitClosed() {} },
|
||||
{ streamId: "s1", frames: { [Symbol.asyncIterator]: () => ({ next: async () => ({ done: true, value: undefined }) }) }, cancel: 1, async waitClosed() {} },
|
||||
]) {
|
||||
const runtime = createBrowserRpcRuntime({
|
||||
operations: { WATCH_RPC_RESOURCES: streamOperation() },
|
||||
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER },
|
||||
transports: {
|
||||
CONNECT_REFERENCE_STREAM: defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
openServerStream: () => malformed as never,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const results = await collect(
|
||||
runtime
|
||||
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||
.open({ resourceId: "scope-1" }),
|
||||
);
|
||||
expect(
|
||||
results.every((value) => (value as { ok: boolean }).ok === false),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* RPC-01. A `waitClosed()` that rejects, throws, or does not even return a
|
||||
* promise is not evidence that the physical stream closed. Absorbing all three
|
||||
* into a fulfilled `undefined` let the registry prune a live stream and admit a
|
||||
* second one for the same operation against the same server.
|
||||
*/
|
||||
describe("RPC-01 only a positive receipt confirms physical closure", () => {
|
||||
const shortDeadline = () =>
|
||||
streamOperation({ totalDeadlineMs: 25, idleDeadlineMs: 25 });
|
||||
|
||||
function leaseProbeWith(
|
||||
waitClosed: () => unknown,
|
||||
): Readonly<{
|
||||
transport: BrowserRpcTransport;
|
||||
cancels: string[];
|
||||
opened(): number;
|
||||
}> {
|
||||
const cancels: string[] = [];
|
||||
const counter = { opened: 0 };
|
||||
const transport = defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
openServerStream: () => {
|
||||
counter.opened += 1;
|
||||
return {
|
||||
streamId: `physical-${counter.opened}`,
|
||||
frames: {
|
||||
[Symbol.asyncIterator]: () =>
|
||||
({
|
||||
next: () => new Promise<never>(() => {}),
|
||||
}) as AsyncIterator<BrowserRpcStreamFrame>,
|
||||
},
|
||||
cancel(reason: string) {
|
||||
cancels.push(reason);
|
||||
},
|
||||
waitClosed: waitClosed as () => Promise<void>,
|
||||
};
|
||||
},
|
||||
});
|
||||
return Object.freeze({
|
||||
transport,
|
||||
cancels,
|
||||
opened: () => counter.opened,
|
||||
});
|
||||
}
|
||||
|
||||
const negativeReceipts = [
|
||||
{
|
||||
label: "rejects",
|
||||
waitClosed: () => Promise.reject(new Error("never closed")),
|
||||
},
|
||||
{
|
||||
label: "throws synchronously",
|
||||
waitClosed: () => {
|
||||
throw new TypeError("waitClosed exploded");
|
||||
},
|
||||
},
|
||||
{ label: "returns a non-promise", waitClosed: () => "closed" },
|
||||
{ label: "never settles", waitClosed: () => new Promise<void>(() => {}) },
|
||||
];
|
||||
|
||||
for (const { label, waitClosed } of negativeReceipts) {
|
||||
it(`keeps the operation DRAINING when waitClosed ${label}`, async () => {
|
||||
const probe = leaseProbeWith(waitClosed);
|
||||
const runtime = createBrowserRpcRuntime({
|
||||
operations: { WATCH_RPC_RESOURCES: shortDeadline() },
|
||||
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER },
|
||||
transports: { CONNECT_REFERENCE_STREAM: probe.transport },
|
||||
});
|
||||
const stream = runtime.bindServerStream(
|
||||
"WATCH_RPC_RESOURCES",
|
||||
isResourceView,
|
||||
);
|
||||
|
||||
await collect(stream.open({ resourceId: "scope-1" }));
|
||||
expect(probe.opened()).toBe(1);
|
||||
expect(probe.cancels).toHaveLength(1);
|
||||
|
||||
const second = await collect(stream.open({ resourceId: "scope-1" }));
|
||||
expect(second).toHaveLength(1);
|
||||
expect(second[0]).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "RPC_STREAM_DRAINING" },
|
||||
});
|
||||
// No second physical stream was ever opened.
|
||||
expect(probe.opened()).toBe(1);
|
||||
});
|
||||
}
|
||||
|
||||
it("admits a second stream once waitClosed fulfils", async () => {
|
||||
let release: (() => void) | undefined;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const probe = leaseProbeWith(() => closed);
|
||||
const runtime = createBrowserRpcRuntime({
|
||||
operations: { WATCH_RPC_RESOURCES: shortDeadline() },
|
||||
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER },
|
||||
transports: { CONNECT_REFERENCE_STREAM: probe.transport },
|
||||
});
|
||||
const stream = runtime.bindServerStream(
|
||||
"WATCH_RPC_RESOURCES",
|
||||
isResourceView,
|
||||
);
|
||||
|
||||
await collect(stream.open({ resourceId: "scope-1" }));
|
||||
release?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await collect(stream.open({ resourceId: "scope-1" }));
|
||||
expect(probe.opened()).toBe(2);
|
||||
});
|
||||
|
||||
/**
|
||||
* RPC-02. Cleanup reads foreign state, so it must stay inside the result
|
||||
* boundary: a throwing `return` accessor replaced the already selected
|
||||
* outcome with a native rejection and skipped the rest of the teardown.
|
||||
*/
|
||||
it("keeps the selected outcome when the iterator return accessor throws", async () => {
|
||||
const cancels: string[] = [];
|
||||
const transport = defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
openServerStream: () => ({
|
||||
streamId: "physical-hostile",
|
||||
frames: {
|
||||
[Symbol.asyncIterator]: () =>
|
||||
Object.defineProperty(
|
||||
{ next: () => new Promise<never>(() => {}) },
|
||||
"return",
|
||||
{
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new TypeError("return getter escaped");
|
||||
},
|
||||
},
|
||||
) as AsyncIterator<BrowserRpcStreamFrame>,
|
||||
},
|
||||
cancel(reason: string) {
|
||||
cancels.push(reason);
|
||||
},
|
||||
waitClosed: async () => {},
|
||||
}),
|
||||
});
|
||||
const runtime = createBrowserRpcRuntime({
|
||||
operations: { WATCH_RPC_RESOURCES: shortDeadline() },
|
||||
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceStreamRequestEncoder: STREAM_ENCODER },
|
||||
transports: { CONNECT_REFERENCE_STREAM: transport },
|
||||
});
|
||||
|
||||
const results = await collect(
|
||||
runtime
|
||||
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||
.open({ resourceId: "scope-1" }),
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "RPC_TOTAL_DEADLINE_EXCEEDED" },
|
||||
});
|
||||
expect(cancels).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("decodes a hostile frames iterator to null instead of throwing", async () => {
|
||||
const hostile = {
|
||||
streamId: "physical-hostile",
|
||||
frames: Object.defineProperty({}, Symbol.asyncIterator, {
|
||||
enumerable: true,
|
||||
get() {
|
||||
throw new TypeError("async iterator getter escaped");
|
||||
},
|
||||
}),
|
||||
cancel() {},
|
||||
async waitClosed() {},
|
||||
};
|
||||
expect(decodeServerStreamLease(hostile)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* RPC-04. An exact union is exactly these own data keys on a plain object. A
|
||||
* custom prototype could otherwise carry metadata past the boundary while the
|
||||
* own shape still looked valid, and a missing wire field could reach a
|
||||
* permissive schema as `undefined`.
|
||||
*/
|
||||
describe("RPC-04 transport results are an exact union", () => {
|
||||
const hostileResults = [
|
||||
{
|
||||
label: "valid own fields with an inherited extra",
|
||||
result: () =>
|
||||
Object.assign(Object.create({ injected: "prototype" }), {
|
||||
ok: true,
|
||||
message: { id: "a", name: "A" },
|
||||
encodedBytes: 8,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "a missing message",
|
||||
result: () => ({ ok: true, encodedBytes: 8 }),
|
||||
},
|
||||
{
|
||||
label: "a non-enumerable own extra",
|
||||
result: () =>
|
||||
Object.defineProperty(
|
||||
{ ok: true, message: { id: "a", name: "A" }, encodedBytes: 8 },
|
||||
"injected",
|
||||
{ enumerable: false, value: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "a failure with an inherited code",
|
||||
result: () => ({
|
||||
ok: false,
|
||||
failure: Object.create({ code: "SERVER_FAILURE" }) as object,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, result } of hostileResults) {
|
||||
it(`rejects ${label} as a protocol mismatch`, async () => {
|
||||
const runtime = unaryRuntime(
|
||||
unaryTransport(async () => result() as never),
|
||||
);
|
||||
await expect(
|
||||
runtime
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "scope-1" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "RPC_PROTOCOL_MISMATCH" },
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* RPC-03. Every registry is snapshotted before anything validates it, so a
|
||||
* hostile accessor never runs, and a row that hides fields behind a prototype
|
||||
* or a non-enumerable key is refused rather than installed.
|
||||
*/
|
||||
describe("RPC-03 registries are snapshotted before they are validated", () => {
|
||||
const build = (operations: Record<string, unknown>) =>
|
||||
createBrowserRpcRuntime({
|
||||
operations: operations as never,
|
||||
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: { RpcResourceRequestEncoder: UNARY_ENCODER },
|
||||
transports: {
|
||||
CONNECT_REFERENCE_UNARY: unaryTransport(async () => ({
|
||||
ok: true,
|
||||
message: { id: "a", name: "A" },
|
||||
encodedBytes: 8,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
it("never invokes a registry accessor", () => {
|
||||
let reads = 0;
|
||||
const operations = Object.defineProperty({}, "GET_RPC_RESOURCE", {
|
||||
enumerable: true,
|
||||
get() {
|
||||
reads += 1;
|
||||
return unaryOperation();
|
||||
},
|
||||
});
|
||||
expect(() => build(operations)).toThrow(TypeError);
|
||||
expect(reads).toBe(0);
|
||||
});
|
||||
|
||||
const hostileRows = [
|
||||
{
|
||||
label: "an inherited extra field",
|
||||
row: () =>
|
||||
Object.assign(Object.create({ injected: true }), unaryOperation()),
|
||||
},
|
||||
{
|
||||
label: "a non-enumerable own extra field",
|
||||
row: () =>
|
||||
Object.defineProperty({ ...unaryOperation() }, "injected", {
|
||||
enumerable: false,
|
||||
value: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "a symbol field",
|
||||
row: () => ({
|
||||
...unaryOperation(),
|
||||
[Symbol.for("injected")]: true,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, row } of hostileRows) {
|
||||
it(`refuses to install a row with ${label}`, () => {
|
||||
expect(() => build({ GET_RPC_RESOURCE: row() })).toThrow(TypeError);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
createUnavailableBrowserRpcTransport,
|
||||
defineBrowserRpcTransport,
|
||||
type BrowserRpcObservation,
|
||||
type BrowserRpcServerStreamLease,
|
||||
type BrowserRpcStreamFrame,
|
||||
type BrowserRpcTransport,
|
||||
} from "../../../src/adapters/browser-rpc/index.ts";
|
||||
@@ -257,8 +258,12 @@ describe("Browser RPC provider-neutral runtime", () => {
|
||||
failure: { code: "UNAVAILABLE" },
|
||||
};
|
||||
},
|
||||
async *openServerStream() {
|
||||
yield Object.freeze({ kind: "TERMINAL", ok: true });
|
||||
openServerStream() {
|
||||
return serverStreamLease(
|
||||
(async function* () {
|
||||
yield Object.freeze({ kind: "TERMINAL" as const, ok: true as const });
|
||||
})(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
).toThrow("transport is invalid");
|
||||
@@ -425,11 +430,41 @@ function streamTransport(
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
openServerStream(call) {
|
||||
return source(call.signal);
|
||||
return serverStreamLease(source(call.signal));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC-RR-01. Wraps a plain frame sequence in the lease the transport contract
|
||||
* requires. `cancel` resolves `waitClosed`, which is what a cooperative
|
||||
* transport does.
|
||||
*/
|
||||
let leaseSequence = 0;
|
||||
|
||||
function serverStreamLease(
|
||||
frames: AsyncIterable<BrowserRpcStreamFrame>,
|
||||
options: Readonly<{
|
||||
onCancel?: (reason: string) => void;
|
||||
closeOnCancel?: boolean;
|
||||
}> = {},
|
||||
): BrowserRpcServerStreamLease {
|
||||
leaseSequence += 1;
|
||||
let release: (() => void) | undefined;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
return Object.freeze({
|
||||
streamId: `test-stream-${leaseSequence}`,
|
||||
frames,
|
||||
cancel(reason: string) {
|
||||
options.onCancel?.(reason);
|
||||
if (options.closeOnCancel !== false) release?.();
|
||||
},
|
||||
waitClosed: () => closed,
|
||||
});
|
||||
}
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
name: string,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
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 { captureCiCandidateArchive, verifyCiCandidateArchive } from "../../scripts/lib/ci-candidate-archive.ts";
|
||||
import {
|
||||
CANDIDATE_ARCHIVE_USAGE,
|
||||
@@ -2131,7 +2132,7 @@ async function ensureProviderBaseFixture(): Promise<string> {
|
||||
recursive: true,
|
||||
});
|
||||
await rm(path.join(root, "artifacts/release"), { recursive: true, force: true });
|
||||
await symlink(path.join(sourceRoot, "node_modules"), path.join(root, "node_modules"), "dir");
|
||||
await linkFixtureNodeModules(root, sourceRoot);
|
||||
const git = spawnSync("git", ["show", "-s", "--format=%H%n%ct", "HEAD"], {
|
||||
cwd: sourceRoot,
|
||||
encoding: "utf8",
|
||||
|
||||
@@ -44,6 +44,31 @@ describe("conditional validator CAS sidecar", () => {
|
||||
expect(store.acceptNotModified(binding, 7, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps colon-bearing validator tuples injective", () => {
|
||||
const selectedScope = scope();
|
||||
// N-05. Both bindings are individually valid and, under a delimiter join,
|
||||
// encode to the same key.
|
||||
const first = {
|
||||
definitionId: "resource:detail",
|
||||
identityToken: "identity-token-00000001",
|
||||
representationVersion: 1,
|
||||
scope: selectedScope.snapshot,
|
||||
};
|
||||
const second = {
|
||||
definitionId: "resource",
|
||||
identityToken: "detail:identity-token-00000001",
|
||||
representationVersion: 1,
|
||||
scope: selectedScope.snapshot,
|
||||
};
|
||||
const store = createConditionalValidatorStore();
|
||||
|
||||
expect(store.install(first, '"etag-a"', 7)).toBe(true);
|
||||
expect(store.install(second, '"etag-b"', 7)).toBe(true);
|
||||
|
||||
expect(store.prepare(first, 7)).toBe('"etag-a"');
|
||||
expect(store.prepare(second, 7)).toBe('"etag-b"');
|
||||
});
|
||||
|
||||
it("rejects malformed validators and bounded-capacity overflow", () => {
|
||||
const firstScope = scope();
|
||||
const store = createConditionalValidatorStore(1);
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
composeContractContributions,
|
||||
type ComposedContractContributions,
|
||||
} from "../../src/contracts/external-contract-runtime.ts";
|
||||
import {
|
||||
installRestAuthProfileRegistry,
|
||||
INSTALLED_REST_AUTH_PROFILES,
|
||||
REST_AUTH_PROFILES,
|
||||
} from "../../src/contracts/rest-profiles.ts";
|
||||
import { TEST_CONTRACT_CONTRIBUTION } from "../helpers/external-contract-fixture.ts";
|
||||
|
||||
/**
|
||||
* LIVE-02 / LIVE-03. `Object.freeze(new Map(...))` freezes the wrapper object,
|
||||
* not the backing store: `set`, `delete` and `clear` keep working. Every
|
||||
* registry the executor consults after composition must therefore be a read
|
||||
* facade over a private store, and the rows it hands back must be exact
|
||||
* own-data snapshots that a later mutation of the source cannot reach.
|
||||
*/
|
||||
|
||||
const MUTATORS = ["set", "delete", "clear"] as const;
|
||||
|
||||
function borrowedMapMutation(
|
||||
facade: unknown,
|
||||
mutator: (typeof MUTATORS)[number],
|
||||
): "THREW" | "MUTATED" {
|
||||
try {
|
||||
switch (mutator) {
|
||||
case "set":
|
||||
Map.prototype.set.call(facade as never, "INJECTED", {} as never);
|
||||
break;
|
||||
case "delete":
|
||||
Map.prototype.delete.call(facade as never, "ANONYMOUS");
|
||||
break;
|
||||
case "clear":
|
||||
Map.prototype.clear.call(facade as never);
|
||||
break;
|
||||
}
|
||||
return "MUTATED";
|
||||
} catch {
|
||||
return "THREW";
|
||||
}
|
||||
}
|
||||
|
||||
describe("LIVE-02 installed REST auth profile registry", () => {
|
||||
it("exposes no mutation API", () => {
|
||||
const registry = INSTALLED_REST_AUTH_PROFILES as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
for (const mutator of MUTATORS) {
|
||||
expect(registry[mutator]).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("survives a cast mutation and a borrowed Map.prototype mutator", () => {
|
||||
const registry = installRestAuthProfileRegistry(REST_AUTH_PROFILES);
|
||||
const before = registry.size;
|
||||
const identity = registry.get("ANONYMOUS");
|
||||
expect(identity).toBeDefined();
|
||||
|
||||
for (const mutator of MUTATORS) {
|
||||
expect(borrowedMapMutation(registry, mutator)).toBe("THREW");
|
||||
}
|
||||
|
||||
expect(registry.size).toBe(before);
|
||||
expect(registry.get("ANONYMOUS")).toBe(identity);
|
||||
expect(registry.get("REFERENCE_EXTERNAL_BEARER")?.credentials).toBe("omit");
|
||||
});
|
||||
|
||||
it("does not observe a post-installation mutation of the source record", () => {
|
||||
const source: Record<string, (typeof REST_AUTH_PROFILES)["ANONYMOUS"]> = {
|
||||
ANONYMOUS: {
|
||||
authProfileId: "ANONYMOUS",
|
||||
transport: "ANONYMOUS",
|
||||
credentials: "omit",
|
||||
allowedCredentialHeaders: [],
|
||||
requiredCredentialHeaders: [],
|
||||
},
|
||||
};
|
||||
const registry = installRestAuthProfileRegistry(source);
|
||||
delete source.ANONYMOUS;
|
||||
expect(registry.get("ANONYMOUS")?.transport).toBe("ANONYMOUS");
|
||||
});
|
||||
|
||||
it("keeps read APIs the executor depends on", () => {
|
||||
const registry = INSTALLED_REST_AUTH_PROFILES;
|
||||
expect(registry.has("ANONYMOUS")).toBe(true);
|
||||
expect(registry.has("NO_SUCH_PROFILE")).toBe(false);
|
||||
expect([...registry.keys()].sort()).toEqual([
|
||||
"ANONYMOUS",
|
||||
"REFERENCE_EXTERNAL_BEARER",
|
||||
]);
|
||||
expect([...registry.entries()].length).toBe(registry.size);
|
||||
expect([...registry.values()].length).toBe(registry.size);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LIVE-03 composed contract registry", () => {
|
||||
function compose(): ComposedContractContributions {
|
||||
return composeContractContributions([TEST_CONTRACT_CONTRIBUTION] as never);
|
||||
}
|
||||
|
||||
it("exposes no mutation API on either lookup", () => {
|
||||
const composed = compose();
|
||||
for (const facade of [composed.httpByOperationId, composed.eventByType]) {
|
||||
const record = facade as unknown as Record<string, unknown>;
|
||||
for (const mutator of MUTATORS) {
|
||||
expect(record[mutator]).toBeUndefined();
|
||||
}
|
||||
for (const mutator of MUTATORS) {
|
||||
expect(borrowedMapMutation(facade, mutator)).toBe("THREW");
|
||||
}
|
||||
}
|
||||
expect(composed.httpByOperationId.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("snapshots the frontend policy so a later source mutation cannot reach it", () => {
|
||||
const mutablePolicy = {
|
||||
...TEST_CONTRACT_CONTRIBUTION.http[0]!.frontend,
|
||||
};
|
||||
const contribution = {
|
||||
...TEST_CONTRACT_CONTRIBUTION,
|
||||
http: [
|
||||
{
|
||||
...TEST_CONTRACT_CONTRIBUTION.http[0]!,
|
||||
frontend: mutablePolicy,
|
||||
},
|
||||
],
|
||||
};
|
||||
const composed = composeContractContributions([contribution] as never);
|
||||
const operationId = [...composed.httpByOperationId.keys()][0]!;
|
||||
const installedBefore =
|
||||
composed.httpByOperationId.get(operationId)!.frontend.totalDeadlineMs;
|
||||
|
||||
mutablePolicy.totalDeadlineMs = 999_999;
|
||||
|
||||
expect(
|
||||
composed.httpByOperationId.get(operationId)!.frontend.totalDeadlineMs,
|
||||
).toBe(installedBefore);
|
||||
expect(installedBefore).not.toBe(999_999);
|
||||
});
|
||||
|
||||
it("rejects an accessor or inherited policy field", () => {
|
||||
const inherited = Object.create({ diagnosticsOperation: "INHERITED" }) as
|
||||
Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(
|
||||
TEST_CONTRACT_CONTRIBUTION.http[0]!.frontend,
|
||||
)) {
|
||||
if (key === "diagnosticsOperation") continue;
|
||||
inherited[key] = value;
|
||||
}
|
||||
const accessor = { ...TEST_CONTRACT_CONTRIBUTION.http[0]!.frontend };
|
||||
Object.defineProperty(accessor, "totalDeadlineMs", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => 10_000,
|
||||
});
|
||||
|
||||
for (const frontend of [inherited, accessor]) {
|
||||
expect(() =>
|
||||
composeContractContributions([
|
||||
{
|
||||
...TEST_CONTRACT_CONTRIBUTION,
|
||||
http: [{ ...TEST_CONTRACT_CONTRIBUTION.http[0]!, frontend }],
|
||||
},
|
||||
] as never),
|
||||
).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* NS-02. Composition validated the caller's object and then read it again to
|
||||
* copy it. Between those two reads a stateful answer could swap a deadline or
|
||||
* a retry budget, so the value that passed the ceiling check and the value the
|
||||
* executor ran with were two different things.
|
||||
*/
|
||||
describe("validate the snapshot, never the source", () => {
|
||||
/** Answers a safe value to a plain read and a hostile one to a copy. */
|
||||
const statefulFrontend = () =>
|
||||
new Proxy(
|
||||
{ ...TEST_CONTRACT_CONTRIBUTION.http[0]!.frontend },
|
||||
{
|
||||
get(target, key, receiver) {
|
||||
if (key === "totalDeadlineMs") return 10_000;
|
||||
return Reflect.get(target, key, receiver);
|
||||
},
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
if (key === "totalDeadlineMs") {
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: 999_999,
|
||||
};
|
||||
}
|
||||
return Reflect.getOwnPropertyDescriptor(target, key);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const contributionWith = (row: unknown) => [
|
||||
{ ...TEST_CONTRACT_CONTRIBUTION, http: [row] },
|
||||
];
|
||||
|
||||
it("validates the deadline it will install, not the one it was shown", () => {
|
||||
// The copied value exceeds the hard ceiling, so composition must stop
|
||||
// rather than install a deadline no check ever saw.
|
||||
expect(() =>
|
||||
composeContractContributions(
|
||||
contributionWith({
|
||||
...TEST_CONTRACT_CONTRIBUTION.http[0]!,
|
||||
frontend: statefulFrontend(),
|
||||
}) as never,
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("keys the registry by the operation id it copied, not the one it was shown", () => {
|
||||
const base = { ...TEST_CONTRACT_CONTRIBUTION.http[0]!.contract };
|
||||
const contract = new Proxy(base, {
|
||||
get(target, key, receiver) {
|
||||
if (key === "operationId") return base.operationId;
|
||||
return Reflect.get(target, key, receiver);
|
||||
},
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
if (key === "operationId") {
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: "SwappedOperation",
|
||||
};
|
||||
}
|
||||
return Reflect.getOwnPropertyDescriptor(target, key);
|
||||
},
|
||||
});
|
||||
const composed = composeContractContributions(
|
||||
contributionWith({
|
||||
...TEST_CONTRACT_CONTRIBUTION.http[0]!,
|
||||
contract,
|
||||
}) as never,
|
||||
);
|
||||
expect([...composed.httpByOperationId.keys()]).toEqual([
|
||||
"SwappedOperation",
|
||||
]);
|
||||
expect(
|
||||
composed.httpByOperationId.get("SwappedOperation")!.contract
|
||||
.operationId,
|
||||
).toBe("SwappedOperation");
|
||||
});
|
||||
|
||||
const hostileRows = [
|
||||
{
|
||||
label: "an installed row with an extra own field",
|
||||
row: () => ({
|
||||
...TEST_CONTRACT_CONTRIBUTION.http[0]!,
|
||||
injected: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "an installed row with a symbol field",
|
||||
row: () => ({
|
||||
...TEST_CONTRACT_CONTRIBUTION.http[0]!,
|
||||
[Symbol.for("injected")]: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "an installed row with a custom prototype",
|
||||
row: () =>
|
||||
Object.assign(Object.create({ injected: true }), {
|
||||
...TEST_CONTRACT_CONTRIBUTION.http[0]!,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "an installed row hiding a non-enumerable own field",
|
||||
row: () =>
|
||||
Object.defineProperty(
|
||||
{ ...TEST_CONTRACT_CONTRIBUTION.http[0]! },
|
||||
"injected",
|
||||
{ enumerable: false, value: true },
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "an installed row behind a throwing ownKeys trap",
|
||||
row: () =>
|
||||
new Proxy(
|
||||
{ ...TEST_CONTRACT_CONTRIBUTION.http[0]! },
|
||||
{
|
||||
ownKeys() {
|
||||
throw new TypeError("hostile ownKeys trap");
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
for (const { label, row } of hostileRows) {
|
||||
it(`refuses to compose ${label}`, () => {
|
||||
expect(() =>
|
||||
composeContractContributions(contributionWith(row()) as never),
|
||||
).toThrow();
|
||||
});
|
||||
}
|
||||
|
||||
it("refuses a contribution whose own shape is not exact", () => {
|
||||
for (const contribution of [
|
||||
{ ...TEST_CONTRACT_CONTRIBUTION, injected: true },
|
||||
Object.assign(Object.create({ injected: true }), {
|
||||
...TEST_CONTRACT_CONTRIBUTION,
|
||||
}),
|
||||
{
|
||||
...TEST_CONTRACT_CONTRIBUTION,
|
||||
[Symbol.for("injected")]: true,
|
||||
},
|
||||
]) {
|
||||
expect(() =>
|
||||
composeContractContributions([contribution] as never),
|
||||
).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses an event contract whose own shape is not exact", () => {
|
||||
const events = TEST_CONTRACT_CONTRIBUTION.events;
|
||||
if (events.length === 0) return;
|
||||
for (const event of [
|
||||
{ ...events[0]!, injected: true },
|
||||
Object.assign(Object.create({ injected: true }), { ...events[0]! }),
|
||||
]) {
|
||||
expect(() =>
|
||||
composeContractContributions([
|
||||
{ ...TEST_CONTRACT_CONTRIBUTION, events: [event] },
|
||||
] as never),
|
||||
).toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -112,9 +112,14 @@ class FakeStorageEventTarget implements StorageEventTargetFacade {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
emit(key: string | null, newValue: string | null): void {
|
||||
emit(
|
||||
key: string | null,
|
||||
newValue: string | null,
|
||||
storageArea: "EXPECTED_LOCAL_STORAGE" | "OTHER_OR_UNKNOWN" =
|
||||
"EXPECTED_LOCAL_STORAGE",
|
||||
): void {
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener({ key, newValue });
|
||||
listener({ key, newValue, storageArea });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,6 +140,9 @@ class FakeStorageBus {
|
||||
});
|
||||
}
|
||||
|
||||
/** Simulates the event arriving from a different Storage area. */
|
||||
emitAsForeignArea = false;
|
||||
|
||||
set(
|
||||
owner: FakeStorageEventTarget,
|
||||
key: string,
|
||||
@@ -142,7 +150,15 @@ class FakeStorageBus {
|
||||
): void {
|
||||
this.values.set(key, value);
|
||||
for (const target of this.targets) {
|
||||
if (target !== owner) target.emit(key, value);
|
||||
if (target !== owner) {
|
||||
target.emit(
|
||||
key,
|
||||
value,
|
||||
this.emitAsForeignArea
|
||||
? "OTHER_OR_UNKNOWN"
|
||||
: "EXPECTED_LOCAL_STORAGE",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,6 +503,50 @@ describe("browser cross-context invalidation transport", () => {
|
||||
second.close();
|
||||
});
|
||||
|
||||
it("rejects storage pulses from another or unknown storage area", () => {
|
||||
const storageBus = new FakeStorageBus();
|
||||
const publisherStorage = storageBus.createEndpoint();
|
||||
const receiverStorage = storageBus.createEndpoint();
|
||||
const storageOnly = {
|
||||
createBroadcastChannel: () => {
|
||||
throw new DOMException("Denied", "SecurityError");
|
||||
},
|
||||
};
|
||||
const publisher = createBrowserCrossContextInvalidation(
|
||||
dependencies("tab-a", {
|
||||
...storageOnly,
|
||||
storage: publisherStorage.storage,
|
||||
storageEvents: publisherStorage.target,
|
||||
}),
|
||||
);
|
||||
const receiver = createBrowserCrossContextInvalidation(
|
||||
dependencies("tab-b", {
|
||||
...storageOnly,
|
||||
storage: receiverStorage.storage,
|
||||
storageEvents: receiverStorage.target,
|
||||
}),
|
||||
);
|
||||
const received = vi.fn();
|
||||
receiver.subscribe(received);
|
||||
|
||||
// N-09. sessionStorage and every other Storage area raise the same event,
|
||||
// so an identical key and value from a foreign area must be ignored.
|
||||
storageBus.emitAsForeignArea = true;
|
||||
expect(
|
||||
publisher.publish({ topic: TOPIC, topicVersion: 1 }),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(received).not.toHaveBeenCalled();
|
||||
|
||||
storageBus.emitAsForeignArea = false;
|
||||
expect(
|
||||
publisher.publish({ topic: TOPIC, topicVersion: 1 }),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(received).toHaveBeenCalledOnce();
|
||||
|
||||
publisher.close();
|
||||
receiver.close();
|
||||
});
|
||||
|
||||
it("enters explicit local-only degradation when every transport fails", () => {
|
||||
const storageBus = new FakeStorageBus();
|
||||
const endpoint = storageBus.createEndpoint();
|
||||
|
||||
@@ -12,6 +12,45 @@ const profile = {
|
||||
} as const;
|
||||
|
||||
describe("bounded cursor pagination runtime", () => {
|
||||
it("returns PAGINATION_ABORTED when a non-cooperative page resolves after abort", async () => {
|
||||
const controller = new AbortController();
|
||||
let releasePage: ((page: unknown) => void) | undefined;
|
||||
const loadPage = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releasePage = resolve as (page: unknown) => void;
|
||||
}),
|
||||
);
|
||||
const runtime = createCursorPaginationRuntime({
|
||||
definitionId: "bounded",
|
||||
profile,
|
||||
loadPage: loadPage as never,
|
||||
});
|
||||
|
||||
const loading = runtime.loadAll({ signal: controller.signal });
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
|
||||
const result = await loading;
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "REQUEST_ABORTED", code: "PAGINATION_ABORTED" },
|
||||
});
|
||||
|
||||
// The late page completion must be ignored, not accumulated.
|
||||
releasePage?.({
|
||||
ok: true,
|
||||
value: {
|
||||
items: ["late"],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
snapshotToken: "snapshot-1",
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(loadPage).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("loads a stable finite chain without exposing cursors in its value", async () => {
|
||||
const loadPage = vi
|
||||
.fn()
|
||||
@@ -119,3 +158,121 @@ describe("bounded cursor pagination runtime", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* NS-07. The profile was validated once and then re-read on every page, so
|
||||
* raising `maxPages` after construction widened a cap that had already been
|
||||
* checked — the runtime issued more requests and returned more items than the
|
||||
* validated profile allowed.
|
||||
*/
|
||||
describe("NS-07 the caps are the ones that were validated", () => {
|
||||
it("keeps the page cap captured at construction", async () => {
|
||||
const mutable: {
|
||||
profileId: string;
|
||||
maxPages: number;
|
||||
maxTotalItems: number;
|
||||
maxEstimatedBytes: number;
|
||||
maxCursorBytes: number;
|
||||
allowSparsePage: boolean;
|
||||
} = { ...profile, maxPages: 1 };
|
||||
const loadPage = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
value: {
|
||||
items: [1],
|
||||
nextCursor: `cursor-${loadPage.mock.calls.length}`,
|
||||
hasMore: true,
|
||||
snapshotToken: "snapshot-a",
|
||||
},
|
||||
}));
|
||||
const runtime = createCursorPaginationRuntime({
|
||||
definitionId: "LIST_ALL",
|
||||
profile: mutable,
|
||||
loadPage,
|
||||
});
|
||||
|
||||
mutable.maxPages = 3;
|
||||
mutable.maxTotalItems = 99;
|
||||
|
||||
await expect(runtime.loadAll({})).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "PAGINATION_PAGE_LIMIT" },
|
||||
});
|
||||
expect(loadPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the loader captured at construction", async () => {
|
||||
const original = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
value: {
|
||||
items: [1],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
snapshotToken: null,
|
||||
},
|
||||
}));
|
||||
const replacement = vi.fn();
|
||||
const dependencies = {
|
||||
definitionId: "LIST_ALL",
|
||||
profile,
|
||||
loadPage: original,
|
||||
};
|
||||
const runtime = createCursorPaginationRuntime(dependencies);
|
||||
dependencies.loadPage = replacement as never;
|
||||
|
||||
await expect(runtime.loadAll({})).resolves.toMatchObject({ ok: true });
|
||||
expect(original).toHaveBeenCalledTimes(1);
|
||||
expect(replacement).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const hostileProfiles: readonly (readonly [string, () => unknown])[] = [
|
||||
[
|
||||
"an accessor cap",
|
||||
() =>
|
||||
Object.defineProperty({ ...profile }, "maxPages", {
|
||||
enumerable: true,
|
||||
get: () => 3,
|
||||
}),
|
||||
],
|
||||
[
|
||||
"an inherited cap",
|
||||
() => Object.create({ ...profile }) as unknown,
|
||||
],
|
||||
["an extra own field", () => ({ ...profile, injected: true })],
|
||||
[
|
||||
"a symbol field",
|
||||
() => ({ ...profile, [Symbol.for("injected")]: true }),
|
||||
],
|
||||
[
|
||||
"a non-enumerable own field",
|
||||
() =>
|
||||
Object.defineProperty({ ...profile }, "injected", {
|
||||
enumerable: false,
|
||||
value: true,
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a throwing ownKeys trap",
|
||||
() =>
|
||||
new Proxy(
|
||||
{ ...profile },
|
||||
{
|
||||
ownKeys() {
|
||||
throw new TypeError("hostile ownKeys trap");
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
for (const [label, build] of hostileProfiles) {
|
||||
it(`refuses to build a runtime from ${label}`, () => {
|
||||
expect(() =>
|
||||
createCursorPaginationRuntime({
|
||||
definitionId: "LIST_ALL",
|
||||
profile: build() as never,
|
||||
loadPage: vi.fn(),
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { mkdtemp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { linkFixtureNodeModules } from "../../scripts/lib/fixture-node-modules.ts";
|
||||
|
||||
/**
|
||||
* A removal or provider fixture runs `pnpm` inside a throwaway copy of the
|
||||
* repository. pnpm does not recognise the modules directory it finds there and
|
||||
* purges it, and with `CI=true` it does so without a prompt. When
|
||||
* `<fixture>/node_modules` was a single directory symlink, that purge followed
|
||||
* the link and deleted the repository's own installed dependencies mid-run —
|
||||
* a test suite uninstalling the workspace it was running in.
|
||||
*/
|
||||
describe("fixture node_modules linking", () => {
|
||||
async function sourceTree() {
|
||||
const source = await mkdtemp(path.join(tmpdir(), "fixture-source-"));
|
||||
const modules = path.join(source, "node_modules");
|
||||
await mkdir(path.join(modules, "left", "lib"), { recursive: true });
|
||||
await mkdir(path.join(modules, "@scope", "right"), { recursive: true });
|
||||
await mkdir(path.join(modules, ".bin"), { recursive: true });
|
||||
await writeFile(path.join(modules, "left", "lib", "index.js"), "export default 1;\n");
|
||||
await writeFile(path.join(modules, "@scope", "right", "package.json"), "{}\n");
|
||||
await writeFile(path.join(modules, ".modules.yaml"), "{}\n");
|
||||
return source;
|
||||
}
|
||||
|
||||
it("survives a recursive delete of the fixture's node_modules", async () => {
|
||||
const source = await sourceTree();
|
||||
const fixture = await mkdtemp(path.join(tmpdir(), "fixture-root-"));
|
||||
try {
|
||||
await linkFixtureNodeModules(fixture, source);
|
||||
|
||||
// The fixture resolves the same packages...
|
||||
expect(
|
||||
await readFile(
|
||||
path.join(fixture, "node_modules/left/lib/index.js"),
|
||||
"utf8",
|
||||
),
|
||||
).toContain("export default 1;");
|
||||
expect(
|
||||
(await readdir(path.join(fixture, "node_modules"))).sort(),
|
||||
).toEqual([".bin", ".modules.yaml", "@scope", "left"]);
|
||||
|
||||
// ...and this is exactly what pnpm's purge does.
|
||||
await rm(path.join(fixture, "node_modules"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
||||
// The repository's real dependencies are untouched.
|
||||
expect(
|
||||
await readFile(
|
||||
path.join(source, "node_modules/left/lib/index.js"),
|
||||
"utf8",
|
||||
),
|
||||
).toContain("export default 1;");
|
||||
expect(
|
||||
await stat(path.join(source, "node_modules/@scope/right/package.json")),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
(await readdir(path.join(source, "node_modules"))).sort(),
|
||||
).toEqual([".bin", ".modules.yaml", "@scope", "left"]);
|
||||
} finally {
|
||||
await rm(source, { recursive: true, force: true });
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("creates node_modules as a real directory, never a symlink", async () => {
|
||||
const source = await sourceTree();
|
||||
const fixture = await mkdtemp(path.join(tmpdir(), "fixture-root-"));
|
||||
try {
|
||||
await linkFixtureNodeModules(fixture, source);
|
||||
const { lstat } = await import("node:fs/promises");
|
||||
const entry = await lstat(path.join(fixture, "node_modules"));
|
||||
expect(entry.isSymbolicLink()).toBe(false);
|
||||
expect(entry.isDirectory()).toBe(true);
|
||||
expect(
|
||||
(await lstat(path.join(fixture, "node_modules/left"))).isSymbolicLink(),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await rm(source, { recursive: true, force: true });
|
||||
await rm(fixture, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
const installed: InstalledHttpContract<unknown, unknown, unknown> =
|
||||
TEST_LIST_HTTP_CONTRACT;
|
||||
|
||||
const ROUTE_ID = "TEST_ROUTE";
|
||||
|
||||
const scope = Object.freeze({
|
||||
generation: 1,
|
||||
fingerprint: "scope-1",
|
||||
@@ -72,7 +74,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher: async () =>
|
||||
Response.json(
|
||||
@@ -82,7 +83,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
executor.execute(installed, { limit: 20 }, { scope }),
|
||||
executor.execute(installed, { limit: 20 }, { routeId: ROUTE_ID, scope }),
|
||||
).resolves.toMatchObject({
|
||||
kind: "RATE_LIMITED",
|
||||
effect: "NOT_APPLICABLE",
|
||||
@@ -104,7 +105,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
const attachCredentials = vi.fn(() => ({
|
||||
kind: "READY" as const,
|
||||
headers: {},
|
||||
credentials: "omit" as const,
|
||||
}));
|
||||
const fetcher = vi.fn();
|
||||
const executor = createContractHttpExecutor({
|
||||
@@ -119,7 +119,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
executor.execute(
|
||||
createInstalled,
|
||||
{ name: "created" },
|
||||
{ scope, ...(intent === undefined ? {} : { intent }) },
|
||||
{ routeId: ROUTE_ID, scope, ...(intent === undefined ? {} : { intent }) },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
kind: "CONTRACT_VIOLATION",
|
||||
@@ -143,7 +143,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
const attachCredentials = vi.fn(() => ({
|
||||
kind: "READY" as const,
|
||||
headers: {},
|
||||
credentials: "omit" as const,
|
||||
}));
|
||||
const fetcher = vi.fn();
|
||||
const executor = createContractHttpExecutor({
|
||||
@@ -154,7 +153,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
executor.execute(installed, { limit: 20 }, { scope, intent }),
|
||||
executor.execute(installed, { limit: 20 }, { routeId: ROUTE_ID, scope, intent }),
|
||||
).resolves.toMatchObject({
|
||||
kind: "CONTRACT_VIOLATION",
|
||||
violation: {
|
||||
@@ -173,14 +172,14 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
label: "query with the canonical reserved header",
|
||||
operation: installed,
|
||||
input: { limit: 20 },
|
||||
context: { scope },
|
||||
context: { routeId: ROUTE_ID, scope },
|
||||
headerName: "Idempotency-Key",
|
||||
},
|
||||
{
|
||||
label: "valid KEYED command with a case-variant reserved header",
|
||||
operation: createInstalled,
|
||||
input: { name: "created" },
|
||||
context: { scope, intent: mutationIntent() },
|
||||
context: { routeId: ROUTE_ID, scope, intent: mutationIntent() },
|
||||
headerName: "iDeMpOtEnCy-KeY",
|
||||
},
|
||||
])(
|
||||
@@ -189,7 +188,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
const attachCredentials = vi.fn(() => ({
|
||||
kind: "READY" as const,
|
||||
headers: { [headerName]: "credential-owned-key" },
|
||||
credentials: "omit" as const,
|
||||
}));
|
||||
const fetcher = vi.fn();
|
||||
const executor = createContractHttpExecutor({
|
||||
@@ -234,7 +232,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher,
|
||||
sleep: async () => {},
|
||||
@@ -252,7 +249,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
executor.execute(
|
||||
retryingCreate,
|
||||
{ name: "created" },
|
||||
{ scope, intent: mutationIntent({ idempotencyKey: "logical-key" }) },
|
||||
{ routeId: ROUTE_ID, scope, intent: mutationIntent({ idempotencyKey: "logical-key" }) },
|
||||
),
|
||||
).resolves.toMatchObject({ kind: "SUCCESS" });
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
@@ -275,7 +272,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher,
|
||||
});
|
||||
@@ -284,7 +280,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
executor.execute(
|
||||
installed,
|
||||
{ limit: 20 },
|
||||
{ scope },
|
||||
{ routeId: ROUTE_ID, scope },
|
||||
),
|
||||
).resolves.toMatchObject({ kind: "SUCCESS" });
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
@@ -300,7 +296,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher: vi.fn(async (input) => {
|
||||
urls.push(String(input));
|
||||
@@ -317,6 +312,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
createInstalled,
|
||||
{ name: "created" },
|
||||
{
|
||||
routeId: ROUTE_ID,
|
||||
scope,
|
||||
intent: Object.freeze({
|
||||
intentId: "private-intent-id",
|
||||
@@ -348,12 +344,11 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher,
|
||||
});
|
||||
|
||||
await expect(executor.execute(installed, input, { scope })).resolves.toMatchObject({
|
||||
await expect(executor.execute(installed, input, { routeId: ROUTE_ID, scope })).resolves.toMatchObject({
|
||||
kind: "SUCCESS",
|
||||
});
|
||||
expect(String((fetcher.mock.calls as unknown[][])[0]?.[0])).toContain(
|
||||
@@ -368,7 +363,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher: vi.fn(),
|
||||
});
|
||||
@@ -389,11 +383,11 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
},
|
||||
} as unknown as typeof installed;
|
||||
|
||||
await expect(executor.execute(throwing, { limit: 20 }, { scope })).resolves.toMatchObject({
|
||||
await expect(executor.execute(throwing, { limit: 20 }, { routeId: ROUTE_ID, scope })).resolves.toMatchObject({
|
||||
kind: "CONTRACT_VIOLATION",
|
||||
effect: "NOT_APPLICABLE",
|
||||
});
|
||||
await expect(executor.execute(malformed, { limit: 20 }, { scope })).resolves.toMatchObject({
|
||||
await expect(executor.execute(malformed, { limit: 20 }, { routeId: ROUTE_ID, scope })).resolves.toMatchObject({
|
||||
kind: "CONTRACT_VIOLATION",
|
||||
effect: "NOT_APPLICABLE",
|
||||
});
|
||||
@@ -406,7 +400,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher: vi.fn(async () => Response.json({ malformed: true }, { status: 201 })),
|
||||
});
|
||||
@@ -416,6 +409,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
createInstalled,
|
||||
{ name: "created" },
|
||||
{
|
||||
routeId: ROUTE_ID,
|
||||
scope,
|
||||
intent: mutationIntent(),
|
||||
},
|
||||
@@ -441,7 +435,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher: vi.fn(async () => {
|
||||
current = false;
|
||||
@@ -458,6 +451,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
createInstalled,
|
||||
{ name: "created" },
|
||||
{
|
||||
routeId: ROUTE_ID,
|
||||
scope: fencedScope,
|
||||
intent: mutationIntent({ intentId: "intent-2", idempotencyKey: "key-2" }),
|
||||
},
|
||||
@@ -469,6 +463,64 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a dispatched command MAYBE_APPLIED when a retry-time fence lands between scope checks", async () => {
|
||||
// Attempt 1 dispatches an idempotent command and receives 429. The retry
|
||||
// sleep resolves, the loop-entry scope check is still current, and only the
|
||||
// pre-dispatch final invariant observes the fence.
|
||||
let armed = false;
|
||||
let checksAfterArming = 0;
|
||||
const idempotentCommand: InstalledHttpContract<unknown, unknown, unknown> = {
|
||||
...createInstalled,
|
||||
contract: {
|
||||
...createInstalled.contract,
|
||||
retrySemantics: "IDEMPOTENT" as const,
|
||||
},
|
||||
frontend: { ...createInstalled.frontend, retryBudget: 1 as const },
|
||||
};
|
||||
const racingScope = Object.freeze({
|
||||
...scope,
|
||||
isCurrent: () => {
|
||||
if (!armed) return true;
|
||||
checksAfterArming += 1;
|
||||
// The retry loop entry still observes a current scope; the pre-dispatch
|
||||
// final invariant is the first observation of the fence.
|
||||
return checksAfterArming <= 1;
|
||||
},
|
||||
});
|
||||
const fetcher = vi.fn(async () =>
|
||||
Response.json({ type: "about:blank", title: "slow down", status: 429 }, {
|
||||
status: 429,
|
||||
}),
|
||||
);
|
||||
const executor = createContractHttpExecutor({
|
||||
baseUrl: "https://api.example/",
|
||||
maxRetryAttempts: 1,
|
||||
attachCredentials: () => ({ kind: "READY", headers: {} }),
|
||||
fetcher,
|
||||
sleep: async () => {
|
||||
armed = true;
|
||||
},
|
||||
random: () => 0,
|
||||
});
|
||||
|
||||
const outcome = await executor.execute(
|
||||
idempotentCommand,
|
||||
{ name: "created" },
|
||||
{
|
||||
routeId: ROUTE_ID,
|
||||
scope: racingScope,
|
||||
intent: mutationIntent({ idempotencyKey: null }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(outcome).toMatchObject({
|
||||
kind: "CONTRACT_VIOLATION",
|
||||
violation: { kind: "SCOPE_FENCED" },
|
||||
effect: "MAYBE_APPLIED",
|
||||
});
|
||||
});
|
||||
|
||||
it("settles a credential hang at the total operation deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
let settled = false;
|
||||
@@ -479,7 +531,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
});
|
||||
|
||||
const result = executor
|
||||
.execute(operation({ deadlineMs: 5 }), { limit: 20 }, { scope })
|
||||
.execute(operation({ deadlineMs: 5 }), { limit: 20 }, { routeId: ROUTE_ID, scope })
|
||||
.then((outcome) => {
|
||||
settled = true;
|
||||
return outcome;
|
||||
@@ -519,7 +571,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher,
|
||||
monotonicNow: () => 0,
|
||||
@@ -532,7 +583,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
const result = executor.execute(
|
||||
operation({ deadlineMs: 5 }),
|
||||
{ limit: 20 },
|
||||
{ scope },
|
||||
{ routeId: ROUTE_ID, scope },
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await flushMicrotasks();
|
||||
@@ -548,7 +599,14 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
expect(observe).toHaveBeenCalledTimes(iterationCount);
|
||||
for (const [observation] of observe.mock.calls) {
|
||||
expect(observation).toEqual(
|
||||
expect.objectContaining({ attempts: 1, certainty: "TIMEOUT" }),
|
||||
expect.objectContaining({
|
||||
attemptCount: 1,
|
||||
errorKind: "TIMEOUT",
|
||||
terminalReason: "TIMEOUT",
|
||||
cancellationOwner: "DEADLINE",
|
||||
routeId: ROUTE_ID,
|
||||
operationId: "TEST_LIST_ENTITIES",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -566,7 +624,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher: vi.fn(async () =>
|
||||
Response.json(
|
||||
@@ -582,7 +639,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
});
|
||||
|
||||
const result = executor
|
||||
.execute(installed, { limit: 20 }, { scope, signal: caller.signal })
|
||||
.execute(installed, { limit: 20 }, { routeId: ROUTE_ID, scope, signal: caller.signal })
|
||||
.then((outcome) => {
|
||||
settled = true;
|
||||
return outcome;
|
||||
@@ -608,7 +665,6 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
attachCredentials: () => ({
|
||||
kind: "READY",
|
||||
headers: {},
|
||||
credentials: "omit",
|
||||
}),
|
||||
fetcher: vi.fn(async () => new Response(body, { status: 200 })),
|
||||
});
|
||||
@@ -617,7 +673,7 @@ describe("descriptor-driven HTTP execution lifetime", () => {
|
||||
executor.execute(
|
||||
operation({ responseBody: "NONE" }),
|
||||
{ limit: 20 },
|
||||
{ scope },
|
||||
{ routeId: ROUTE_ID, scope },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
kind: "TRANSPORT_FAILURE",
|
||||
|
||||
@@ -203,6 +203,7 @@ describe("production image CDN runtime", () => {
|
||||
const resolved = await runtime.presentation.resolve({
|
||||
asset: accepted.value,
|
||||
preset,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(resolved.ok).toBe(true);
|
||||
if (!resolved.ok) return;
|
||||
@@ -317,10 +318,11 @@ describe("production image CDN runtime", () => {
|
||||
runtime.presentation.resolve({
|
||||
asset: accepted.value,
|
||||
preset,
|
||||
signal: new AbortController().signal,
|
||||
width: 9_999,
|
||||
query: "format=svg",
|
||||
src: "data:text/html,active",
|
||||
} as Parameters<typeof runtime.presentation.resolve>[0]),
|
||||
} as unknown as Parameters<typeof runtime.presentation.resolve>[0]),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_INPUT" },
|
||||
@@ -332,6 +334,7 @@ describe("production image CDN runtime", () => {
|
||||
"card-landscape",
|
||||
"render-public-product-image",
|
||||
),
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
@@ -341,6 +344,7 @@ describe("production image CDN runtime", () => {
|
||||
runtime.presentation.resolve({
|
||||
asset: {} as typeof accepted.value,
|
||||
preset,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
@@ -385,6 +389,7 @@ describe("production image CDN runtime", () => {
|
||||
const resolved = await runtime.presentation.resolve({
|
||||
asset: accepted.value,
|
||||
preset: presetReference,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(resolved.ok).toBe(true);
|
||||
if (!resolved.ok) return;
|
||||
@@ -782,6 +787,7 @@ describe("production image CDN runtime", () => {
|
||||
runtime.presentation.resolve({
|
||||
asset: accepted.value,
|
||||
preset,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
@@ -840,6 +846,7 @@ describe("production image CDN runtime", () => {
|
||||
runtime.presentation.resolve({
|
||||
asset: accepted.value,
|
||||
preset: lazy,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
@@ -999,7 +1006,13 @@ describe("production image CDN runtime", () => {
|
||||
expect(abortDeadline.clearTimeout).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("bounds concurrent capability verification and releases the slot after abort", async () => {
|
||||
/**
|
||||
* TR-RR-07. The concurrency cap exists to bound *physical* verification work.
|
||||
* Releasing the slot when the wrapper's abort resolved let an abandoned
|
||||
* verifier keep running while a new one was admitted, so repeated aborts
|
||||
* produced more concurrent work than the configured cap allows.
|
||||
*/
|
||||
it("holds the verification slot until the raw verifier settles", async () => {
|
||||
const preset = imageCdnPresetReference(
|
||||
"verification-concurrency",
|
||||
"bound-image-verification-concurrency",
|
||||
@@ -1011,10 +1024,13 @@ describe("production image CDN runtime", () => {
|
||||
},
|
||||
});
|
||||
let verificationAttempt = 0;
|
||||
let releaseFirst: ((value: boolean) => void) | undefined;
|
||||
const verify = vi.fn(() => {
|
||||
verificationAttempt += 1;
|
||||
return verificationAttempt === 1
|
||||
? new Promise<boolean>(() => undefined)
|
||||
? new Promise<boolean>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
})
|
||||
: Promise.resolve(true);
|
||||
});
|
||||
const runtime = createImageCdnRuntime({
|
||||
@@ -1047,9 +1063,24 @@ describe("production image CDN runtime", () => {
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
|
||||
// The caller's wait ended, but the raw verifier has not. Admitting a second
|
||||
// one here would put two physical verifications under a cap of one.
|
||||
await expect(
|
||||
runtime.assets.acceptBackendIssued(issued),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
expect(verify).toHaveBeenCalledOnce();
|
||||
|
||||
// Once the raw verifier settles the slot is free again.
|
||||
releaseFirst?.(true);
|
||||
await vi.waitFor(async () => {
|
||||
await expect(
|
||||
runtime.assets.acceptBackendIssued(issued),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
expect(verify).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -1110,6 +1141,7 @@ describe("production image CDN runtime", () => {
|
||||
runtime.presentation.resolve({
|
||||
asset: accepted.value,
|
||||
preset,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
const acceptedPrivate =
|
||||
@@ -1125,6 +1157,7 @@ describe("production image CDN runtime", () => {
|
||||
runtime.presentation.resolve({
|
||||
asset: accepted.value,
|
||||
preset,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
@@ -1479,6 +1512,10 @@ describe("browser image probe", () => {
|
||||
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",
|
||||
@@ -1645,7 +1682,9 @@ describe("browser image probe", () => {
|
||||
responseAt(imageUrl, png, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"cache-control": "private, no-store",
|
||||
// TR-RR-09. A private response carries `no-store` and nothing else
|
||||
// that describes cacheability.
|
||||
"cache-control": "no-store",
|
||||
"content-type": "image/png",
|
||||
},
|
||||
}),
|
||||
@@ -1735,6 +1774,67 @@ describe("browser image probe", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
@@ -1836,6 +1936,115 @@ describe("browser image probe", () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("P-256 image capability verifier", () => {
|
||||
|
||||
@@ -596,6 +596,37 @@ describe("IndexedDB bounded codec maintenance", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("stops codec migration commit at the cooperative deadline", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedLegacy(memory, "row-a", "first");
|
||||
seedLegacy(memory, "row-b", "second");
|
||||
const policy = defaultPolicy();
|
||||
// STO-06. The clock only advances past the deadline once the commit
|
||||
// transaction is already open, so the stop must happen inside the commit
|
||||
// chain rather than before transform.
|
||||
let calls = 0;
|
||||
const maintenance = createMaintenance(memory, policy, {
|
||||
now: () => {
|
||||
calls += 1;
|
||||
// Scan, prepare and the first commit record stay inside the budget.
|
||||
return calls <= 6 ? 0 : 5_000;
|
||||
},
|
||||
});
|
||||
|
||||
const result = await maintenance.migrateCodecBatch({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 1_000,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
// The batch is incomplete and says so; it never claims a full pass.
|
||||
expect(result.value.state).toBe("MORE");
|
||||
expect(result.value.budgetExhausted).toBe(true);
|
||||
expect(result.value.checkpointedRows).toBeLessThan(2);
|
||||
});
|
||||
|
||||
it("fails closed when a historical payload cannot be transformed", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/cursor-pagination-runtime.ts";
|
||||
import {
|
||||
defineMutationIntent,
|
||||
isValidIdempotencyKey,
|
||||
} from "../../src/contracts/mutation-intent.ts";
|
||||
import type {
|
||||
CursorPage,
|
||||
CursorPaginationProfile,
|
||||
} from "../../src/contracts/cursor-pagination.ts";
|
||||
import type { Result } from "../../src/application/result.ts";
|
||||
|
||||
const PROFILE: CursorPaginationProfile = Object.freeze({
|
||||
profileId: "TEST_PAGINATION_V1",
|
||||
maxPages: 3,
|
||||
maxTotalItems: 30,
|
||||
maxEstimatedBytes: 32_768,
|
||||
maxCursorBytes: 512,
|
||||
allowSparsePage: false,
|
||||
});
|
||||
|
||||
function page(
|
||||
items: readonly number[],
|
||||
nextCursor: string | null,
|
||||
): CursorPage<number> {
|
||||
return Object.freeze({
|
||||
items: Object.freeze([...items]),
|
||||
nextCursor,
|
||||
hasMore: nextCursor !== null,
|
||||
snapshotToken: "snapshot-1",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* OPT-NET-01. A loader rejection is evidence about the data source. Reporting
|
||||
* it as `PAGINATION_ABORTED` because a signal merely *exists* erases a real
|
||||
* network or contract failure and files it under a user decision nobody made.
|
||||
*/
|
||||
describe("OPT-NET-01 cursor pagination abort classification", () => {
|
||||
it("preserves a loader rejection while the signal is still live", async () => {
|
||||
const controller = new AbortController();
|
||||
const runtime = createCursorPaginationRuntime<number>({
|
||||
definitionId: "TEST_PAGINATION",
|
||||
profile: PROFILE,
|
||||
loadPage: async () => {
|
||||
throw new TypeError("upstream exploded");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.loadAll({ signal: controller.signal }),
|
||||
).rejects.toThrow("upstream exploded");
|
||||
});
|
||||
|
||||
it("keeps the same rejection when no signal is supplied", async () => {
|
||||
const runtime = createCursorPaginationRuntime<number>({
|
||||
definitionId: "TEST_PAGINATION",
|
||||
profile: PROFILE,
|
||||
loadPage: async () => {
|
||||
throw new TypeError("upstream exploded");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(runtime.loadAll({})).rejects.toThrow("upstream exploded");
|
||||
});
|
||||
|
||||
it("classifies a rejection during a real abort as PAGINATION_ABORTED", async () => {
|
||||
const controller = new AbortController();
|
||||
const runtime = createCursorPaginationRuntime<number>({
|
||||
definitionId: "TEST_PAGINATION",
|
||||
profile: PROFILE,
|
||||
loadPage: async () => {
|
||||
controller.abort();
|
||||
throw new TypeError("cancelled upstream");
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runtime.loadAll({ signal: controller.signal });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.ok ? null : result.error.code).toBe("PAGINATION_ABORTED");
|
||||
});
|
||||
|
||||
it("does not admit a page that resolves after the abort", async () => {
|
||||
const controller = new AbortController();
|
||||
const loadPage = vi.fn(
|
||||
async (): Promise<Result<CursorPage<number>>> => {
|
||||
controller.abort();
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
return { ok: true, value: page([1, 2], null) };
|
||||
},
|
||||
);
|
||||
const runtime = createCursorPaginationRuntime<number>({
|
||||
definitionId: "TEST_PAGINATION",
|
||||
profile: PROFILE,
|
||||
loadPage,
|
||||
});
|
||||
|
||||
const result = await runtime.loadAll({ signal: controller.signal });
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.ok ? null : result.error.code).toBe("PAGINATION_ABORTED");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* OPT-NET-02. One idempotency-key authority. Two validators drift, and the
|
||||
* looser one becomes the way a control character reaches a request header.
|
||||
*/
|
||||
describe("OPT-NET-02 shared idempotency key validation", () => {
|
||||
const rejected = [
|
||||
"",
|
||||
" ",
|
||||
"key\nwith-newline",
|
||||
"key\u0000null",
|
||||
"key\u007fdelete",
|
||||
"key\u009fc1",
|
||||
"a".repeat(257),
|
||||
];
|
||||
|
||||
it("rejects the same values at intent definition and at admission", () => {
|
||||
for (const value of rejected) {
|
||||
expect(isValidIdempotencyKey(value)).toBe(false);
|
||||
expect(() =>
|
||||
defineMutationIntent({
|
||||
intentId: "intent-1",
|
||||
operationId: "OP",
|
||||
canonicalInputIdentity: "identity",
|
||||
idempotencyKey: value,
|
||||
createdAtMonotonicMs: 1,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the bounded printable and Unicode values both sides allow", () => {
|
||||
for (const value of ["key-1", "a".repeat(256), "키-값", "ключ"]) {
|
||||
expect(isValidIdempotencyKey(value)).toBe(true);
|
||||
expect(
|
||||
defineMutationIntent({
|
||||
intentId: "intent-1",
|
||||
operationId: "OP",
|
||||
canonicalInputIdentity: "identity",
|
||||
idempotencyKey: value,
|
||||
createdAtMonotonicMs: 1,
|
||||
}).idempotencyKey,
|
||||
).toBe(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -290,7 +290,10 @@ function createWorker(
|
||||
return browserDataSuccess(undefined);
|
||||
},
|
||||
async cleanupTransaction() {
|
||||
return browserDataSuccess(undefined);
|
||||
return browserDataSuccess({ kind: "CLEANED" as const });
|
||||
},
|
||||
async abortPreparedPut() {
|
||||
return browserDataSuccess({ kind: "CLEANED" as const });
|
||||
},
|
||||
async finalizePut() {
|
||||
return browserDataSuccess(undefined);
|
||||
@@ -349,6 +352,123 @@ describe("OPFS byte-store coordinator", () => {
|
||||
expect(JSON.stringify(observations)).not.toContain("object_12345678");
|
||||
});
|
||||
|
||||
/**
|
||||
* NS-04. Awaiting `journal.complete` without checking it reported a settled
|
||||
* transaction that nobody settled: the caller saw plain success and success
|
||||
* telemetry while the durable row stayed `COMMITTED`, so the reconcile
|
||||
* backlog grew invisibly.
|
||||
*/
|
||||
it("does not report a settled write when the journal cannot complete it", async () => {
|
||||
const journal = createJournal();
|
||||
journal.complete = async () =>
|
||||
browserDataFailure("UNAVAILABLE", "INDEXEDDB_WRITE", {
|
||||
retryable: true,
|
||||
recovery: "RECONCILE",
|
||||
});
|
||||
const observations: { operation: string; outcome: string }[] = [];
|
||||
const adapter = createOpfsByteStoreAdapter({
|
||||
journal,
|
||||
worker: createWorker(),
|
||||
scope,
|
||||
storagePolicy,
|
||||
policy: {
|
||||
...resolveOpfsRuntimePolicy(),
|
||||
chunkSizeBytes: 64 * 1024,
|
||||
maxObjectBytes: 64 * 1024,
|
||||
maxChunkCount: 1,
|
||||
},
|
||||
createTransactionId: () => "transaction_12345678",
|
||||
now: () => 100,
|
||||
observer: (event) =>
|
||||
observations.push(event as { operation: string; outcome: string }),
|
||||
});
|
||||
|
||||
const result = await adapter.objects.put({
|
||||
objectId: "object_12345678",
|
||||
expectedGeneration: null,
|
||||
mediaType: "application/octet-stream",
|
||||
source: sourceFrom(new Uint8Array([1, 2, 3])),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
// The payload is durable, so recovery is reconciliation, not a rewrite.
|
||||
expect(result.error.recovery).toBe("RECONCILE");
|
||||
expect(result.error.operation).toBe("OBJECT_WRITE");
|
||||
}
|
||||
expect(
|
||||
observations.some(
|
||||
(event) =>
|
||||
event.operation === "OBJECT_WRITE" && event.outcome === "SUCCEEDED",
|
||||
),
|
||||
).toBe(false);
|
||||
// The committed row stays the reconciler's authority.
|
||||
expect(journal.transactions.get("transaction_12345678")?.phase).toBe(
|
||||
"COMMITTED",
|
||||
);
|
||||
expect(journal.objects.get("object_12345678")).toBeDefined();
|
||||
});
|
||||
|
||||
it("records maintenance debt when a committed delete cannot be completed", async () => {
|
||||
const journal = createJournal();
|
||||
const observations: { operation: string; outcome: string }[] = [];
|
||||
const policy = {
|
||||
...resolveOpfsRuntimePolicy(),
|
||||
chunkSizeBytes: 64 * 1024,
|
||||
maxObjectBytes: 64 * 1024,
|
||||
maxChunkCount: 1,
|
||||
};
|
||||
const writer = createOpfsByteStoreAdapter({
|
||||
journal,
|
||||
worker: createWorker(),
|
||||
scope,
|
||||
storagePolicy,
|
||||
policy,
|
||||
createTransactionId: () => "transaction_12345678",
|
||||
now: () => 100,
|
||||
});
|
||||
await writer.objects.put({
|
||||
objectId: "object_12345678",
|
||||
expectedGeneration: null,
|
||||
mediaType: "application/octet-stream",
|
||||
source: sourceFrom(new Uint8Array([1, 2, 3])),
|
||||
});
|
||||
|
||||
// The journal can commit the deletion but cannot settle the transaction.
|
||||
const remover = createOpfsByteStoreAdapter({
|
||||
journal: {
|
||||
...journal,
|
||||
complete: async () =>
|
||||
browserDataFailure("UNAVAILABLE", "INDEXEDDB_WRITE", {
|
||||
retryable: true,
|
||||
recovery: "RECONCILE",
|
||||
}),
|
||||
},
|
||||
worker: createWorker(),
|
||||
scope,
|
||||
storagePolicy,
|
||||
policy,
|
||||
createTransactionId: () => "transaction_87654321",
|
||||
now: () => 200,
|
||||
observer: (event) =>
|
||||
observations.push(event as { operation: string; outcome: string }),
|
||||
});
|
||||
|
||||
// Logical deletion is already committed, so the caller must not be asked to
|
||||
// repeat a non-idempotent delete — but it is not a settled success either.
|
||||
const result = await remover.objects.remove({
|
||||
objectId: "object_12345678",
|
||||
expectedGeneration: 1,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
const deleteOutcomes = observations
|
||||
.filter((event) => event.operation === "OBJECT_DELETE")
|
||||
.map((event) => event.outcome);
|
||||
expect(deleteOutcomes.at(-1)).toBe("DEGRADED");
|
||||
expect(deleteOutcomes).not.toContain("SUCCEEDED");
|
||||
});
|
||||
|
||||
it("deep-snapshots scope and policy at composition against caller mutation", async () => {
|
||||
const journal = createJournal();
|
||||
const mutableScope = { ...scope };
|
||||
@@ -501,7 +621,183 @@ describe("OPFS byte-store coordinator", () => {
|
||||
expect(replacementWorkerMethod).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a committed journal row for reconciliation when cleanup fails", async () => {
|
||||
it("keeps PREPARING journal when compensating cleanup is aborted or unavailable", async () => {
|
||||
for (const failing of [
|
||||
{
|
||||
async abortPreparedPut() {
|
||||
return browserDataFailure("ABORTED", "OBJECT_RECONCILE");
|
||||
},
|
||||
},
|
||||
{
|
||||
async abortPreparedPut() {
|
||||
return browserDataSuccess({ kind: "EFFECT_UNKNOWN" as const });
|
||||
},
|
||||
},
|
||||
]) {
|
||||
const journal = createJournal();
|
||||
const worker = createWorker({
|
||||
async preparePut() {
|
||||
return browserDataFailure("QUOTA_EXCEEDED", "OBJECT_WRITE");
|
||||
},
|
||||
...failing,
|
||||
});
|
||||
const adapter = createOpfsByteStoreAdapter({
|
||||
journal,
|
||||
worker,
|
||||
scope,
|
||||
storagePolicy,
|
||||
createTransactionId: () => "transaction_12345678",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const result = await adapter.objects.put({
|
||||
objectId: "object_12345678",
|
||||
expectedGeneration: null,
|
||||
mediaType: "application/octet-stream",
|
||||
source: sourceFrom(new Uint8Array([1])),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
// The journal row is the only durable evidence that staging bytes may
|
||||
// still exist, so it survives an unconfirmed compensation.
|
||||
expect(
|
||||
journal.transactions.get("transaction_12345678")?.phase,
|
||||
).toBe("PREPARING");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not roll back journal after an unknown worker mutation effect", async () => {
|
||||
const journal = createJournal();
|
||||
const worker = createWorker({
|
||||
async abortPreparedPut() {
|
||||
return browserDataSuccess({ kind: "EFFECT_UNKNOWN" as const });
|
||||
},
|
||||
});
|
||||
const rollback = vi.spyOn(journal, "rollback");
|
||||
vi.spyOn(journal, "markFilesReady").mockResolvedValueOnce(
|
||||
browserDataFailure("UNAVAILABLE", "OBJECT_WRITE"),
|
||||
);
|
||||
const adapter = createOpfsByteStoreAdapter({
|
||||
journal,
|
||||
worker,
|
||||
scope,
|
||||
storagePolicy,
|
||||
createTransactionId: () => "transaction_12345678",
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
const result = await adapter.objects.put({
|
||||
objectId: "object_12345678",
|
||||
expectedGeneration: null,
|
||||
mediaType: "application/octet-stream",
|
||||
source: sourceFrom(new Uint8Array([1])),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(rollback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("holds the OPFS mutation lease until exact physical cleanup completes", async () => {
|
||||
const journal = createJournal();
|
||||
const observed: Array<Readonly<Record<string, unknown>>> = [];
|
||||
const worker = createWorker({
|
||||
async preparePut() {
|
||||
return browserDataFailure("QUOTA_EXCEEDED", "OBJECT_WRITE");
|
||||
},
|
||||
async abortPreparedPut(request) {
|
||||
observed.push({ ...request });
|
||||
return browserDataSuccess({ kind: "CLEANED" as const });
|
||||
},
|
||||
});
|
||||
const compensation = new AbortController();
|
||||
const caller = new AbortController();
|
||||
caller.abort();
|
||||
const adapter = createOpfsByteStoreAdapter({
|
||||
journal,
|
||||
worker,
|
||||
scope,
|
||||
storagePolicy,
|
||||
createTransactionId: () => "transaction_12345678",
|
||||
createPhysicalGenerationId: () => "f".repeat(32) as never,
|
||||
compensationSignal: compensation.signal,
|
||||
now: () => 100,
|
||||
});
|
||||
|
||||
await adapter.objects.put({
|
||||
objectId: "object_12345678",
|
||||
expectedGeneration: null,
|
||||
mediaType: "application/octet-stream",
|
||||
source: sourceFrom(new Uint8Array([1])),
|
||||
});
|
||||
|
||||
expect(observed).toHaveLength(1);
|
||||
expect(observed[0]).toMatchObject({
|
||||
transactionId: "transaction_12345678",
|
||||
physicalGenerationId: "f".repeat(32),
|
||||
});
|
||||
// Compensation never inherits the caller signal.
|
||||
expect(observed[0]?.signal).toBe(compensation.signal);
|
||||
expect(
|
||||
journal.transactions.has("transaction_12345678"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("delayed stale cleanup cannot delete a reused logical generation", async () => {
|
||||
const journal = createJournal();
|
||||
const issued: string[] = [];
|
||||
let nextToken = 0;
|
||||
const worker = createWorker({
|
||||
async abortPreparedPut(request) {
|
||||
issued.push(request.physicalGenerationId);
|
||||
return browserDataSuccess({ kind: "CLEANED" as const });
|
||||
},
|
||||
});
|
||||
let transaction = 0;
|
||||
// T1 abandons its prepared put at the same logical generation 1.
|
||||
vi.spyOn(journal, "markFilesReady").mockResolvedValueOnce(
|
||||
browserDataFailure("UNAVAILABLE", "OBJECT_WRITE"),
|
||||
);
|
||||
const adapter = createOpfsByteStoreAdapter({
|
||||
journal,
|
||||
worker,
|
||||
scope,
|
||||
storagePolicy,
|
||||
createTransactionId: () => `transaction_1234567${(transaction += 1)}`,
|
||||
createPhysicalGenerationId: () =>
|
||||
String(nextToken += 1).padStart(32, "0") as never,
|
||||
now: () => 100,
|
||||
});
|
||||
const first = await adapter.objects.put({
|
||||
objectId: "object_12345678",
|
||||
expectedGeneration: null,
|
||||
mediaType: "application/octet-stream",
|
||||
source: sourceFrom(new Uint8Array([1])),
|
||||
});
|
||||
expect(first.ok).toBe(false);
|
||||
|
||||
// T2 legitimately reuses logical generation 1 with a different token.
|
||||
const second = await adapter.objects.put({
|
||||
objectId: "object_12345678",
|
||||
expectedGeneration: null,
|
||||
mediaType: "application/octet-stream",
|
||||
source: sourceFrom(new Uint8Array([1])),
|
||||
});
|
||||
expect(second.ok).toBe(true);
|
||||
if (second.ok) expect(second.value.generation).toBe(1);
|
||||
|
||||
// The stale compensation targeted only T1's physical token.
|
||||
expect(issued).toEqual([String(1).padStart(32, "0")]);
|
||||
const stored = journal.objects.get("object_12345678");
|
||||
expect(stored?.physicalSchemaVersion).toBe(1);
|
||||
});
|
||||
|
||||
/**
|
||||
* STO-RR-01. Finalization runs after the commit fence, so the payload is
|
||||
* durable and the journal row must survive for reconciliation. What the
|
||||
* caller must not be told is that the write settled: the previous generation
|
||||
* and the staging directory are still there.
|
||||
*/
|
||||
it("reports a failed finalization instead of a plain success", async () => {
|
||||
const journal = createJournal();
|
||||
const worker = createWorker({
|
||||
async finalizePut() {
|
||||
@@ -533,7 +829,8 @@ describe("OPFS byte-store coordinator", () => {
|
||||
source: sourceFrom(new Uint8Array([1])),
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.ok ? null : result.error.operation).toBe("OBJECT_WRITE");
|
||||
expect(
|
||||
journal.transactions.get("transaction_12345678")?.phase,
|
||||
).toBe("COMMITTED");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
OpfsPreparedObject,
|
||||
OpfsPhysicalGenerationId,
|
||||
OpfsStorageScope,
|
||||
} from "../../src/application/ports/browser-file-storage/opfs-ports.ts";
|
||||
import type {
|
||||
@@ -14,13 +15,16 @@ import {
|
||||
createOpfsWorkerGateway,
|
||||
type OpfsWorkerLike,
|
||||
} from "../../src/adapters/storage/opfs/opfs-worker-client.ts";
|
||||
import { OPFS_WORKER_PROTOCOL_VERSION } from "../../src/adapters/storage/opfs/opfs-worker-protocol.ts";
|
||||
import type {
|
||||
OpfsWorkerRequest,
|
||||
OpfsWorkerResponse,
|
||||
} from "../../src/adapters/storage/opfs/opfs-worker-protocol.ts";
|
||||
import {
|
||||
createOpfsWorkerRuntime,
|
||||
startBrowserOpfsDedicatedWorker,
|
||||
type OpfsMutationLeaseManager,
|
||||
type OpfsWorkerMessageHost,
|
||||
} from "../../src/adapters/storage/opfs/opfs-worker-runtime.ts";
|
||||
|
||||
const scopeA: OpfsStorageScope = Object.freeze({
|
||||
@@ -170,18 +174,23 @@ function notFound(): DOMException {
|
||||
return new DOMException("Entry was not found.", "NotFoundError");
|
||||
}
|
||||
|
||||
const PHYSICAL_GENERATION_A = "a".repeat(32) as OpfsPhysicalGenerationId;
|
||||
|
||||
function beginRequest(
|
||||
requestId: string,
|
||||
transactionId: string,
|
||||
scope: OpfsStorageScope,
|
||||
physicalGenerationId: OpfsPhysicalGenerationId = PHYSICAL_GENERATION_A,
|
||||
): OpfsWorkerRequest {
|
||||
return {
|
||||
requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "BEGIN_PUT",
|
||||
transactionId,
|
||||
scope,
|
||||
objectId: "object_12345678",
|
||||
generation: 1,
|
||||
physicalGenerationId,
|
||||
declaredByteLength: 1,
|
||||
mediaType: "application/octet-stream",
|
||||
createdAtEpochMs: 100,
|
||||
@@ -263,6 +272,7 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
await Promise.resolve();
|
||||
const aborted = await runtime.handleRequest({
|
||||
requestId: "request_abort_1234",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "ABORT_PUT",
|
||||
scope: scopeA,
|
||||
transactionId: "transaction_12345678",
|
||||
@@ -314,6 +324,7 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
|
||||
const appending = runtime.handleRequest({
|
||||
requestId: "request_append_5678",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "APPEND_CHUNK",
|
||||
scope: scopeA,
|
||||
transactionId: "transaction_56785678",
|
||||
@@ -323,6 +334,7 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
await Promise.resolve();
|
||||
const aborting = runtime.handleRequest({
|
||||
requestId: "request_abort_5678",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "ABORT_PUT",
|
||||
scope: scopeA,
|
||||
transactionId: "transaction_56785678",
|
||||
@@ -369,6 +381,7 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: `request_append_iso_${index}`,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "APPEND_CHUNK",
|
||||
scope: targetScope,
|
||||
transactionId,
|
||||
@@ -381,6 +394,7 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_abort_iso_a",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "ABORT_PUT",
|
||||
scope: scopeA,
|
||||
transactionId,
|
||||
@@ -388,6 +402,7 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
).toMatchObject({ ok: true });
|
||||
const finished = await runtime.handleRequest({
|
||||
requestId: "request_finish_iso_b",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "FINISH_PUT",
|
||||
scope: scopeB,
|
||||
transactionId,
|
||||
@@ -397,11 +412,14 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_verify_iso_b",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "VERIFY_OBJECT",
|
||||
preparedObject,
|
||||
}),
|
||||
).toEqual({
|
||||
requestId: "request_verify_iso_b",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: expect.any(String),
|
||||
ok: true,
|
||||
value: true,
|
||||
});
|
||||
@@ -430,6 +448,7 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
);
|
||||
await runtime.handleRequest({
|
||||
requestId: `request_gc_append_${index}`,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "APPEND_CHUNK",
|
||||
scope: targetScope,
|
||||
transactionId,
|
||||
@@ -439,6 +458,7 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
const object = preparedValue(
|
||||
await runtime.handleRequest({
|
||||
requestId: `request_gc_finish_${index}`,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "FINISH_PUT",
|
||||
scope: targetScope,
|
||||
transactionId,
|
||||
@@ -447,6 +467,7 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
preparedByScope.set(targetScope.authorityToken, object);
|
||||
await runtime.handleRequest({
|
||||
requestId: `request_gc_finalize_${index}`,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "FINALIZE_PUT",
|
||||
transactionId,
|
||||
preparedObject: object,
|
||||
@@ -459,6 +480,7 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_gc_list_a",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "LIST_ORPHAN_CANDIDATES",
|
||||
scope: scopeA,
|
||||
olderThanEpochMs: cutoff,
|
||||
@@ -468,6 +490,7 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_gc_delete_a",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "DELETE_ORPHAN_CHUNK",
|
||||
scope: scopeA,
|
||||
digestHex,
|
||||
@@ -478,22 +501,28 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_gc_verify_a",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "VERIFY_OBJECT",
|
||||
preparedObject: preparedByScope.get(scopeA.authorityToken)!,
|
||||
}),
|
||||
).toEqual({
|
||||
requestId: "request_gc_verify_a",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: expect.any(String),
|
||||
ok: true,
|
||||
value: false,
|
||||
});
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_gc_verify_b",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "VERIFY_OBJECT",
|
||||
preparedObject: preparedByScope.get(scopeB.authorityToken)!,
|
||||
}),
|
||||
).toEqual({
|
||||
requestId: "request_gc_verify_b",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: expect.any(String),
|
||||
ok: true,
|
||||
value: true,
|
||||
});
|
||||
@@ -511,10 +540,13 @@ describe("OPFS dedicated worker runtime", () => {
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_caps_1234",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "CAPABILITIES",
|
||||
}),
|
||||
).toEqual({
|
||||
requestId: "request_caps_1234",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: expect.any(String),
|
||||
ok: true,
|
||||
value: {
|
||||
available: false,
|
||||
@@ -564,6 +596,8 @@ describe("OPFS worker client lifecycle", () => {
|
||||
listener?.({
|
||||
data: {
|
||||
requestId: "request_collision_1234",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "CAPABILITIES",
|
||||
ok: true,
|
||||
value: {
|
||||
available: true,
|
||||
@@ -628,11 +662,15 @@ describe("OPFS worker client lifecycle", () => {
|
||||
message.kind === "VERIFY_OBJECT"
|
||||
? {
|
||||
requestId: message.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: message.kind,
|
||||
ok: true,
|
||||
value: true,
|
||||
}
|
||||
: {
|
||||
requestId: message.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: message.kind,
|
||||
ok: true,
|
||||
value: new Uint8Array([4, 2]).buffer,
|
||||
};
|
||||
@@ -728,3 +766,591 @@ describe("OPFS worker client lifecycle", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* STO-RR-01. A Web Lock is not reentrant. Any path that re-acquires the origin
|
||||
* mutation lease while already holding it stops making progress forever, and a
|
||||
* lock the runtime waits on cannot be observed by a fake that hands out an
|
||||
* unlimited number of leases.
|
||||
*/
|
||||
function strictNonReentrantLeases(
|
||||
counters: { acquires: number; releases: number },
|
||||
): OpfsMutationLeaseManager {
|
||||
let held = false;
|
||||
return {
|
||||
async acquire() {
|
||||
if (held) {
|
||||
// A second holder waits for the first to release. Nothing here ever
|
||||
// does, which is exactly what a deadlock looks like.
|
||||
return await new Promise<never>(() => {});
|
||||
}
|
||||
held = true;
|
||||
counters.acquires += 1;
|
||||
let released = false;
|
||||
return {
|
||||
release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
held = false;
|
||||
counters.releases += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withTimeout<Value>(
|
||||
operation: Promise<Value>,
|
||||
label: string,
|
||||
ms = 200,
|
||||
): Promise<Value> {
|
||||
return Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => reject(new Error(`${label} did not settle`)), ms);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("STO-RR-01 OPFS finalization under a non-reentrant lock", () => {
|
||||
async function completedPut(
|
||||
runtime: ReturnType<typeof createOpfsWorkerRuntime>,
|
||||
transactionId: string,
|
||||
): Promise<OpfsPreparedObject> {
|
||||
expect(
|
||||
await runtime.handleRequest(
|
||||
beginRequest(`request_begin_${transactionId}`, transactionId, scopeA),
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: `request_append_${transactionId}`,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "APPEND_CHUNK",
|
||||
scope: scopeA,
|
||||
transactionId,
|
||||
sequence: 0,
|
||||
bytes: new Uint8Array([9]).buffer,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
const finished = await runtime.handleRequest({
|
||||
requestId: `request_finish_${transactionId}`,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "FINISH_PUT",
|
||||
scope: scopeA,
|
||||
transactionId,
|
||||
});
|
||||
expect(finished).toMatchObject({ ok: true });
|
||||
return preparedValue(finished);
|
||||
}
|
||||
|
||||
it("finalizes a normal PUT with exactly one lock acquisition", async () => {
|
||||
const root = new MemoryDirectory();
|
||||
const counters = { acquires: 0, releases: 0 };
|
||||
const runtime = createOpfsWorkerRuntime({
|
||||
root: root as unknown as FileSystemDirectoryHandle,
|
||||
crypto: globalThis.crypto,
|
||||
policy: runtimePolicy,
|
||||
leaseManager: strictNonReentrantLeases(counters),
|
||||
dedicatedWorker: true,
|
||||
supportsSynchronousAccessHandles: false,
|
||||
});
|
||||
const transactionId = "transaction_final_0001";
|
||||
const prepared = await completedPut(runtime, transactionId);
|
||||
const before = counters.acquires;
|
||||
|
||||
const finalized = await withTimeout(
|
||||
runtime.handleRequest({
|
||||
requestId: "request_finalize_0001",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "FINALIZE_PUT",
|
||||
transactionId,
|
||||
preparedObject: prepared,
|
||||
}),
|
||||
"FINALIZE_PUT",
|
||||
);
|
||||
|
||||
expect(finalized).toMatchObject({ ok: true });
|
||||
expect(counters.acquires - before).toBe(1);
|
||||
expect(counters.acquires).toBe(counters.releases);
|
||||
expect(
|
||||
root.has([
|
||||
"authorities",
|
||||
scopeA.authorityToken,
|
||||
scopeA.namespaceToken,
|
||||
scopeA.partitionToken,
|
||||
"staging",
|
||||
transactionId,
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the lock free for the next mutation after a finalized PUT", async () => {
|
||||
const root = new MemoryDirectory();
|
||||
const counters = { acquires: 0, releases: 0 };
|
||||
const runtime = createOpfsWorkerRuntime({
|
||||
root: root as unknown as FileSystemDirectoryHandle,
|
||||
crypto: globalThis.crypto,
|
||||
policy: runtimePolicy,
|
||||
leaseManager: strictNonReentrantLeases(counters),
|
||||
dedicatedWorker: true,
|
||||
supportsSynchronousAccessHandles: false,
|
||||
});
|
||||
const first = "transaction_final_0002";
|
||||
const prepared = await completedPut(runtime, first);
|
||||
await withTimeout(
|
||||
runtime.handleRequest({
|
||||
requestId: "request_finalize_0002",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "FINALIZE_PUT",
|
||||
transactionId: first,
|
||||
preparedObject: prepared,
|
||||
}),
|
||||
"first FINALIZE_PUT",
|
||||
);
|
||||
|
||||
const second = "transaction_final_0003";
|
||||
await expect(
|
||||
withTimeout(completedPut(runtime, second), "second PUT"),
|
||||
).resolves.toMatchObject({ descriptor: { objectId: "object_12345678" } });
|
||||
expect(counters.acquires).toBe(counters.releases);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* STO-RR-02. A failure raised while serving a validated request must answer
|
||||
* that request. Defaulting the response kind to `CAPABILITIES` made the client's
|
||||
* own expected-kind check reject it as a protocol breach, so a quota or
|
||||
* integrity failure reached the caller as `UNSUPPORTED`.
|
||||
*/
|
||||
describe("STO-RR-02 worker failure responses echo the request kind", () => {
|
||||
const failingRoot = {
|
||||
async getDirectoryHandle(): Promise<FileSystemDirectoryHandle> {
|
||||
throw new DOMException("Out of room", "QuotaExceededError");
|
||||
},
|
||||
async getFileHandle(): Promise<FileSystemFileHandle> {
|
||||
throw new DOMException("Out of room", "QuotaExceededError");
|
||||
},
|
||||
async removeEntry(): Promise<void> {
|
||||
throw new DOMException("Out of room", "QuotaExceededError");
|
||||
},
|
||||
async *entries(): AsyncIterableIterator<never> {},
|
||||
} as unknown as FileSystemDirectoryHandle;
|
||||
|
||||
it("keeps the validated kind on every failure path", async () => {
|
||||
const counters = { acquires: 0, releases: 0 };
|
||||
const runtime = createOpfsWorkerRuntime({
|
||||
root: failingRoot,
|
||||
crypto: globalThis.crypto,
|
||||
policy: runtimePolicy,
|
||||
leaseManager: strictNonReentrantLeases(counters),
|
||||
dedicatedWorker: true,
|
||||
supportsSynchronousAccessHandles: false,
|
||||
});
|
||||
|
||||
const requests: readonly OpfsWorkerRequest[] = [
|
||||
beginRequest("request_kind_begin", "transaction_kind_0001", scopeA),
|
||||
{
|
||||
requestId: "request_kind_remove",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "REMOVE_OBJECT",
|
||||
scope: scopeA,
|
||||
objectId: "object_12345678",
|
||||
generation: 1,
|
||||
},
|
||||
{
|
||||
requestId: "request_kind_cleanup",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "CLEANUP_TRANSACTION",
|
||||
scope: scopeA,
|
||||
transactionId: "transaction_kind_0001",
|
||||
},
|
||||
{
|
||||
requestId: "request_kind_orphans",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "LIST_ORPHAN_CANDIDATES",
|
||||
scope: scopeA,
|
||||
olderThanEpochMs: 1,
|
||||
maxEntries: 1,
|
||||
},
|
||||
];
|
||||
|
||||
for (const request of requests) {
|
||||
const response = await runtime.handleRequest(request);
|
||||
expect(response).toMatchObject({
|
||||
ok: false,
|
||||
kind: request.kind,
|
||||
requestId: request.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("still reports a protocol-level failure for an unreadable envelope", async () => {
|
||||
const counters = { acquires: 0, releases: 0 };
|
||||
const runtime = createOpfsWorkerRuntime({
|
||||
root: failingRoot,
|
||||
crypto: globalThis.crypto,
|
||||
policy: runtimePolicy,
|
||||
leaseManager: strictNonReentrantLeases(counters),
|
||||
dedicatedWorker: true,
|
||||
supportsSynchronousAccessHandles: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_kind_broken",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "NOT_A_KIND",
|
||||
}),
|
||||
).toMatchObject({ ok: false, kind: "CAPABILITIES" });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* STO-RR-03. The client decoder is the trust boundary for anything a worker
|
||||
* says. A `code` that is merely a string lets an arbitrary value escape the
|
||||
* closed `BrowserDataFailure` taxonomy into application code.
|
||||
*/
|
||||
describe("STO-RR-03 worker responses are decoded against closed sets", () => {
|
||||
function respondingWorker(
|
||||
reply: (request: OpfsWorkerRequest) => unknown,
|
||||
): OpfsWorkerLike {
|
||||
const listeners = new Set<(event: MessageEvent<unknown>) => void>();
|
||||
return {
|
||||
postMessage(message: unknown) {
|
||||
const response = reply(message as OpfsWorkerRequest);
|
||||
queueMicrotask(() => {
|
||||
for (const listener of listeners) {
|
||||
listener({ data: response } as MessageEvent<unknown>);
|
||||
}
|
||||
});
|
||||
},
|
||||
addEventListener(_type: "message", listener: (event: MessageEvent<unknown>) => void) {
|
||||
listeners.add(listener);
|
||||
},
|
||||
removeEventListener(_type: "message", listener: (event: MessageEvent<unknown>) => void) {
|
||||
listeners.delete(listener);
|
||||
},
|
||||
} as unknown as OpfsWorkerLike;
|
||||
}
|
||||
|
||||
const hostileReplies: readonly (readonly [string, (request: OpfsWorkerRequest) => unknown])[] = [
|
||||
[
|
||||
"unknown failure code",
|
||||
(request) => ({
|
||||
requestId: request.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: request.kind,
|
||||
ok: false,
|
||||
failure: { code: "EVIL", retryable: false },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"unknown request kind",
|
||||
(request) => ({
|
||||
requestId: request.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: "NOT_A_KIND",
|
||||
ok: false,
|
||||
failure: { code: "UNAVAILABLE", retryable: false },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"non-boolean retryable",
|
||||
(request) => ({
|
||||
requestId: request.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: request.kind,
|
||||
ok: false,
|
||||
failure: { code: "UNAVAILABLE", retryable: "yes" },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"inherited failure fields",
|
||||
(request) => ({
|
||||
requestId: request.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: request.kind,
|
||||
ok: false,
|
||||
failure: Object.create({ code: "UNAVAILABLE", retryable: false }) as object,
|
||||
}),
|
||||
],
|
||||
[
|
||||
"throwing getter",
|
||||
(request) => {
|
||||
const response: Record<string, unknown> = {
|
||||
requestId: request.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
ok: false,
|
||||
failure: { code: "UNAVAILABLE", retryable: false },
|
||||
};
|
||||
Object.defineProperty(response, "kind", {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
throw new TypeError("hostile getter");
|
||||
},
|
||||
});
|
||||
return response;
|
||||
},
|
||||
],
|
||||
[
|
||||
"extra own field",
|
||||
(request) => ({
|
||||
requestId: request.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: request.kind,
|
||||
ok: false,
|
||||
failure: { code: "UNAVAILABLE", retryable: false, injected: 1 },
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
for (const [label, reply] of hostileReplies) {
|
||||
it(`closes a ${label} as UNSUPPORTED without rejecting`, async () => {
|
||||
const gateway = createOpfsWorkerGateway({
|
||||
worker: respondingWorker(reply),
|
||||
policy: runtimePolicy,
|
||||
createRequestId: () => `request_hostile_${label.replace(/\W/gu, "")}`,
|
||||
});
|
||||
const result = await withTimeout(gateway.capabilities(), label);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.ok ? null : result.error.code).toBe("UNSUPPORTED");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* NS-06. Correlation was read separately and the pending row and its timer
|
||||
* were removed before the reply was decoded. A trap that threw inside the
|
||||
* decoder therefore left the public promise pending forever, and a throwing
|
||||
* `requestId` getter produced a timeout instead of a prompt protocol failure.
|
||||
*/
|
||||
const uncorrelatableReplies: readonly (readonly [
|
||||
string,
|
||||
(request: OpfsWorkerRequest) => unknown,
|
||||
])[] = [
|
||||
[
|
||||
"throwing requestId getter",
|
||||
(request) =>
|
||||
Object.defineProperty(
|
||||
{
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: request.kind,
|
||||
ok: true,
|
||||
value: {},
|
||||
},
|
||||
"requestId",
|
||||
{
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
throw new TypeError("hostile requestId getter");
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
[
|
||||
"throwing ownKeys trap",
|
||||
(request) =>
|
||||
new Proxy(
|
||||
{
|
||||
requestId: request.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: request.kind,
|
||||
ok: true,
|
||||
value: {},
|
||||
},
|
||||
{
|
||||
ownKeys() {
|
||||
throw new TypeError("hostile ownKeys trap");
|
||||
},
|
||||
},
|
||||
),
|
||||
],
|
||||
[
|
||||
"descriptor trap that throws after correlation",
|
||||
(request) => {
|
||||
let reads = 0;
|
||||
return new Proxy(
|
||||
{
|
||||
requestId: request.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: request.kind,
|
||||
ok: true,
|
||||
value: {},
|
||||
},
|
||||
{
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
reads += 1;
|
||||
if (reads > 1) {
|
||||
throw new TypeError("stateful descriptor trap");
|
||||
}
|
||||
return Reflect.getOwnPropertyDescriptor(target, key);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
],
|
||||
[
|
||||
"symbol-keyed field",
|
||||
(request) => ({
|
||||
requestId: request.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: request.kind,
|
||||
ok: true,
|
||||
value: {},
|
||||
[Symbol.for("injected")]: true,
|
||||
}),
|
||||
],
|
||||
[
|
||||
"non-enumerable own field",
|
||||
(request) =>
|
||||
Object.defineProperty(
|
||||
{
|
||||
requestId: request.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: request.kind,
|
||||
ok: true,
|
||||
value: {},
|
||||
},
|
||||
"injected",
|
||||
{ enumerable: false, value: true },
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
for (const [label, reply] of uncorrelatableReplies) {
|
||||
it(`closes a ${label} promptly as UNSUPPORTED`, async () => {
|
||||
const gateway = createOpfsWorkerGateway({
|
||||
worker: respondingWorker(reply),
|
||||
policy: { ...runtimePolicy, rpcTimeoutMs: 60_000 },
|
||||
createRequestId: () => `request_uncorr_${label.replace(/\W/gu, "")}`,
|
||||
});
|
||||
// The RPC timeout is far beyond the test budget, so a pass here means the
|
||||
// reply itself closed the request rather than the timer.
|
||||
const result = await withTimeout(gateway.capabilities(), label);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.ok ? null : result.error.code).toBe("UNSUPPORTED");
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps serving requests after a malformed reply", async () => {
|
||||
let replies = 0;
|
||||
const gateway = createOpfsWorkerGateway({
|
||||
worker: respondingWorker((request) => {
|
||||
replies += 1;
|
||||
if (replies === 1) return { requestId: request.requestId };
|
||||
return {
|
||||
requestId: request.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: request.kind,
|
||||
ok: false,
|
||||
failure: { code: "QUOTA_EXCEEDED", retryable: true },
|
||||
};
|
||||
}),
|
||||
policy: { ...runtimePolicy, rpcTimeoutMs: 60_000 },
|
||||
createRequestId: () => `request_sequence_${replies}`,
|
||||
});
|
||||
|
||||
const first = await withTimeout(gateway.capabilities(), "first");
|
||||
expect(first.ok ? null : first.error.code).toBe("UNSUPPORTED");
|
||||
const second = await withTimeout(gateway.capabilities(), "second");
|
||||
expect(second.ok ? null : second.error.code).toBe("QUOTA_EXCEEDED");
|
||||
});
|
||||
|
||||
it("still admits a well-formed closed failure", async () => {
|
||||
const gateway = createOpfsWorkerGateway({
|
||||
worker: respondingWorker((request) => ({
|
||||
requestId: request.requestId,
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind: request.kind,
|
||||
ok: false,
|
||||
failure: { code: "QUOTA_EXCEEDED", retryable: true },
|
||||
})),
|
||||
policy: runtimePolicy,
|
||||
createRequestId: () => "request_wellformed_1234",
|
||||
});
|
||||
const result = await withTimeout(gateway.capabilities(), "well-formed");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.ok ? null : result.error.code).toBe("QUOTA_EXCEEDED");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* NS-05. When the runtime never came up, the message host answered with a
|
||||
* default `CAPABILITIES` kind. The gateway saw that as an expected-kind
|
||||
* mismatch and replaced the real cause — `BLOCKED`, `QUOTA_EXCEEDED` — with a
|
||||
* generic `UNSUPPORTED` protocol breach, so the outage was misreported.
|
||||
*/
|
||||
describe("NS-05 a bootstrap failure answers the request it belongs to", () => {
|
||||
function hostFor(): Readonly<{
|
||||
host: OpfsWorkerMessageHost;
|
||||
posted: OpfsWorkerResponse[];
|
||||
deliver(message: unknown): void;
|
||||
}> {
|
||||
const listeners: ((event: MessageEvent<unknown>) => void)[] = [];
|
||||
const posted: OpfsWorkerResponse[] = [];
|
||||
return {
|
||||
host: {
|
||||
addEventListener(_type, listener) {
|
||||
listeners.push(listener);
|
||||
},
|
||||
postMessage(message) {
|
||||
posted.push(message);
|
||||
},
|
||||
},
|
||||
posted,
|
||||
deliver(message: unknown) {
|
||||
for (const listener of listeners) {
|
||||
listener({ data: message } as MessageEvent<unknown>);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const requestKinds = [
|
||||
"CAPABILITIES",
|
||||
"BEGIN_PUT",
|
||||
"APPEND_CHUNK",
|
||||
"FINISH_PUT",
|
||||
"ABORT_PUT",
|
||||
"VERIFY_OBJECT",
|
||||
"READ_CHUNK",
|
||||
"REMOVE_OBJECT",
|
||||
"CLEANUP_TRANSACTION",
|
||||
"FINALIZE_PUT",
|
||||
"LIST_ORPHAN_CANDIDATES",
|
||||
"DELETE_ORPHAN_CHUNK",
|
||||
] as const;
|
||||
|
||||
for (const kind of requestKinds) {
|
||||
it(`preserves the ${kind} correlation when bootstrap fails`, async () => {
|
||||
const { host, posted, deliver } = hostFor();
|
||||
const start = startBrowserOpfsDedicatedWorker(host, {
|
||||
storageManager: {
|
||||
getDirectory: () =>
|
||||
Promise.reject(
|
||||
new DOMException("blocked", "SecurityError"),
|
||||
),
|
||||
} as unknown as StorageManager,
|
||||
crypto: globalThis.crypto,
|
||||
});
|
||||
// The bootstrap rejection must not escape the worker entry point either.
|
||||
await expect(start).rejects.toBeInstanceOf(Error);
|
||||
|
||||
deliver({
|
||||
requestId: "request_bootstrap_failure",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(posted).toHaveLength(1);
|
||||
expect(posted[0]).toMatchObject({
|
||||
requestId: "request_bootstrap_failure",
|
||||
protocolVersion: OPFS_WORKER_PROTOCOL_VERSION,
|
||||
kind,
|
||||
ok: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PRESIGNED_TRANSFER_PROTOCOL } from "../../src/application/ports/browser-transfer/presigned-transfer.ts";
|
||||
|
||||
import type {
|
||||
PresignedDownloadCapability,
|
||||
PresignedDownloadByteSource,
|
||||
@@ -43,6 +45,8 @@ function downloadCapabilityPayload(
|
||||
) {
|
||||
const digest = sha256Hex(bytes);
|
||||
return {
|
||||
// BT-PRE-02. Every capability envelope carries the top-level protocol.
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt: "capability-download-1",
|
||||
method: "GET",
|
||||
binding: {
|
||||
@@ -81,6 +85,7 @@ function uploadCapabilityPayload(input: Readonly<{
|
||||
checksum: string;
|
||||
}>, overrides: Readonly<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt: "capability-upload-1",
|
||||
method: "PUT",
|
||||
binding: {
|
||||
@@ -602,13 +607,15 @@ describe("presigned transfer", () => {
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
expect(
|
||||
await harness.executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
await expect(
|
||||
firstStreamResult(
|
||||
await harness.executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
@@ -621,13 +628,15 @@ describe("presigned transfer", () => {
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
expect(
|
||||
await harness.executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
await expect(
|
||||
firstStreamResult(
|
||||
await harness.executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
@@ -640,18 +649,532 @@ describe("presigned transfer", () => {
|
||||
});
|
||||
expect(issued.ok).toBe(true);
|
||||
if (!issued.ok) return;
|
||||
expect(
|
||||
await harness.executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
await expect(
|
||||
firstStreamResult(
|
||||
await harness.executor.downloadSources.open({
|
||||
resourceId: "resource-1",
|
||||
capability: issued.value,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INTEGRITY_FAILED" },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "missing", protocol: undefined },
|
||||
{ label: "V0", protocol: "PRESIGNED_TRANSFER_V0" },
|
||||
{ label: "V2", protocol: "PRESIGNED_TRANSFER_V2" },
|
||||
])(
|
||||
"requires PRESIGNED_TRANSFER_V1 in request and response ($label)",
|
||||
async ({ protocol }) => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const payload = downloadCapabilityPayload(bytes);
|
||||
const body =
|
||||
protocol === undefined
|
||||
? (({ protocol: _dropped, ...rest }) => rest)(
|
||||
payload as Record<string, unknown>,
|
||||
)
|
||||
: { ...payload, protocol };
|
||||
const requests: unknown[] = [];
|
||||
const fetcher = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input) === CONTROL_ENDPOINT) {
|
||||
requests.push(JSON.parse(String(init?.body)));
|
||||
return jsonResponse(body as never);
|
||||
}
|
||||
return downloadResponse(bytes.slice().buffer, payload);
|
||||
}) as unknown as typeof fetch;
|
||||
const { provider, vault } = createHarness({ fetcher });
|
||||
|
||||
const issued = await provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
// BT-PRE-02. The request always declares V1, and a response that does not
|
||||
// is closed before the vault ever registers it.
|
||||
expect(requests[0]).toMatchObject({
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
});
|
||||
expect(issued).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
// The rejected envelope never reached vault registration.
|
||||
expect(vault.resolve).toBeTypeOf("function");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ label: "encoded slash", path: "/files/a%2Fb" },
|
||||
{ label: "encoded backslash", path: "/files/a%5Cb" },
|
||||
{ label: "double-encoded dot segment", path: "/files/%252e%252e" },
|
||||
{ label: "lowercase percent-hex", path: "/files/a%c3%a9" },
|
||||
{ label: "encoded NUL", path: "/files/a%00b" },
|
||||
])("rejects a provider path that can decode again ($label)", async ({ path }) => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const payload = downloadCapabilityPayload(bytes, {
|
||||
path,
|
||||
href: `${DATA_ORIGIN}${path}`,
|
||||
});
|
||||
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 } = createHarness({ fetcher });
|
||||
|
||||
await expect(
|
||||
provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a valid opaque UTF-8 path segment", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
// BT-PRE-05. Canonical uppercase percent-hex for a real UTF-8 segment.
|
||||
const path = `/files/${encodeURIComponent("caf\u00e9")}`;
|
||||
const payload = downloadCapabilityPayload(bytes, {
|
||||
path,
|
||||
href: `${DATA_ORIGIN}${path}?sig=do-not-log-this`,
|
||||
});
|
||||
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 } = createHarness({ fetcher });
|
||||
|
||||
await expect(
|
||||
provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("bounds a fetch that ignores its abort signal and cancels the late body", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const payload = downloadCapabilityPayload(bytes);
|
||||
const cancel = vi.fn(async () => {});
|
||||
let releaseControl: ((response: Response) => void) | undefined;
|
||||
const timers: Array<() => void> = [];
|
||||
const fetcher = vi.fn(
|
||||
async () =>
|
||||
await new Promise<Response>((resolve) => {
|
||||
releaseControl = resolve;
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
const { provider } = createHarness({
|
||||
fetcher,
|
||||
scheduler: {
|
||||
setTimeout: (callback: () => void) => {
|
||||
timers.push(callback);
|
||||
return timers.length;
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const issuing = provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
await Promise.resolve();
|
||||
// BT-PRE-03. The timeout fires while the fetch is still pending and never
|
||||
// settles on its own.
|
||||
timers.forEach((fire) => fire());
|
||||
|
||||
await expect(issuing).resolves.toMatchObject({ ok: false });
|
||||
|
||||
releaseControl?.({ body: { cancel } } as unknown as Response);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
void payload;
|
||||
});
|
||||
|
||||
/**
|
||||
* TR-RR-05. A scheduler that cannot install the deadline leaves the operation
|
||||
* unbounded. Releasing the caller listener and continuing anyway meant a
|
||||
* later abort was invisible, so an install failure is itself terminal: the
|
||||
* request fails closed with a typed Result and its resources are released.
|
||||
*/
|
||||
it("fails closed when the scheduler cannot install the deadline", async () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]);
|
||||
const payload = downloadCapabilityPayload(bytes);
|
||||
const caller = new AbortController();
|
||||
const remove = vi.spyOn(caller.signal, "removeEventListener");
|
||||
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 } = createHarness({
|
||||
fetcher,
|
||||
scheduler: {
|
||||
setTimeout: () => {
|
||||
throw new TypeError("scheduler exploded");
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
// The public result stays a typed Result, not a rejection.
|
||||
await expect(
|
||||
provider.issueDownload({
|
||||
resourceId: "resource-1",
|
||||
signal: caller.signal,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false });
|
||||
expect(remove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "href/origin mismatch", patch: { origin: "https://evil.example" } },
|
||||
{ label: "href/path mismatch", patch: { path: "/files/other" } },
|
||||
{ label: "credentials in href", patch: { href: `https://u:p@objects.example${DOWNLOAD_PATH}` } },
|
||||
{ label: "maxBytes below byteLength", patch: { maxBytes: 0 } },
|
||||
{ label: "malformed digest", patch: { expectedSha256: "not-a-digest" } },
|
||||
{ label: "non-positive expiry", patch: { expiresAtEpochMs: 0 } },
|
||||
// TR-RR-03. The registration is a versioned exact union: plaintext, an
|
||||
// ambient credential header, an unknown protocol version and any extra
|
||||
// own field are all refused at the issuer seam.
|
||||
{ label: "unknown protocol version", patch: { protocol: "PRESIGNED_TRANSFER_V0" } },
|
||||
{ label: "missing protocol version", patch: { protocol: undefined } },
|
||||
{
|
||||
label: "plaintext target",
|
||||
patch: {
|
||||
href: `http://objects.example${DOWNLOAD_PATH}`,
|
||||
origin: "http://objects.example",
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "ambient credential header",
|
||||
patch: {
|
||||
requestHeaders: [{ name: "authorization", value: "Bearer leak" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "cookie response header",
|
||||
patch: {
|
||||
requiredResponseHeaders: [{ name: "set-cookie", value: "a=b" }],
|
||||
},
|
||||
},
|
||||
{ label: "status outside 2xx", patch: { expectedStatus: 302 } },
|
||||
{ label: "extra own field", patch: { injected: true } },
|
||||
])(
|
||||
"rejects a malformed registration at the vault issuer seam ($label)",
|
||||
({ patch }) => {
|
||||
// BT-PRE-04. The vault owns these invariants itself, so a second issuer
|
||||
// cannot register a weaker capability of the same type.
|
||||
const vault = createPresignedCapabilityVault({
|
||||
now: () => NOW,
|
||||
maxActiveCapabilities: 4,
|
||||
});
|
||||
const base = {
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt: "capability-direct-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: [],
|
||||
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,
|
||||
};
|
||||
|
||||
expect(
|
||||
vault.register({ ...base, ...patch } as never),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
// The same registration without the defect is accepted.
|
||||
expect(vault.register(base as never)).toMatchObject({ ok: true });
|
||||
vault.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* TR-01. The vault validated the issuer's own object and then read it again
|
||||
* to copy it. Between those reads a stateful issuer could show an allowed
|
||||
* header set to the forbidden-header check and hand `Authorization` to the
|
||||
* stored binding, so the executor sent a credential no rule had approved.
|
||||
*/
|
||||
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",
|
||||
@@ -1632,4 +2155,328 @@ describe("presigned transfer", () => {
|
||||
});
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* BT-PRE-01. The download lease is lazy, so a response-shape rejection is
|
||||
* observed on first consumption rather than at `open()`.
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
} from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts";
|
||||
import {
|
||||
createDefaultPublicCachePolicy,
|
||||
resolvePublicCachePolicy,
|
||||
type PublicCacheRuntimePolicy,
|
||||
} from "../../src/adapters/cache-storage/public-cache-policy.ts";
|
||||
import {
|
||||
@@ -140,6 +141,159 @@ async function manifestFor(
|
||||
}
|
||||
|
||||
describe("public response Cache Storage adapter", () => {
|
||||
it("rejects a policy that enables variants but strips Vary", () => {
|
||||
const base = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
// STO-03. Enabling variants while dropping Vary from the response
|
||||
// allowlist makes every stored variant collide on the same key.
|
||||
expect(() =>
|
||||
resolvePublicCachePolicy({
|
||||
...base,
|
||||
allowedRequestHeaderNames: ["accept", "accept-language"],
|
||||
allowedVaryHeaderNames: ["accept-language"],
|
||||
allowedResponseHeaderNames: base.allowedResponseHeaderNames.filter(
|
||||
(name) => name !== "vary",
|
||||
),
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
// The same policy with Vary preserved is accepted.
|
||||
expect(() =>
|
||||
resolvePublicCachePolicy({
|
||||
...base,
|
||||
allowedRequestHeaderNames: ["accept", "accept-language"],
|
||||
allowedVaryHeaderNames: ["accept-language"],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("restages an evicted entry even when the release marker remains", async () => {
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const bytes = new Uint8Array([9, 9, 9, 9]);
|
||||
const asset: PublicCacheAsset = {
|
||||
absoluteUrl: "https://assets.example.test/evicted.js",
|
||||
expectedByteLength: bytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(bytes),
|
||||
},
|
||||
};
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
let fetches = 0;
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async () => {
|
||||
fetches += 1;
|
||||
return new Response(bytes, {
|
||||
headers: {
|
||||
"cache-control": "public",
|
||||
"content-type": "application/javascript",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const manifest = await manifestFor("evicted-release", [asset], policy);
|
||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(fetches).toBe(1);
|
||||
|
||||
// The browser evicts the payload but leaves the marker behind.
|
||||
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
|
||||
name.includes("evicted-release"),
|
||||
);
|
||||
if (!cacheName) throw new Error("staged cache missing");
|
||||
const cache = cacheStorage.caches.get(cacheName)!;
|
||||
const payloadIndex = cache.responses.findIndex(
|
||||
(entry) => entry.request.url === asset.absoluteUrl,
|
||||
);
|
||||
expect(payloadIndex).toBeGreaterThanOrEqual(0);
|
||||
cache.responses.splice(payloadIndex, 1);
|
||||
|
||||
// A marker is a claim, not evidence: restaging must repair.
|
||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(fetches).toBe(2);
|
||||
expect(
|
||||
await adapter.admin.activateRelease(
|
||||
manifest.releaseRegistryId,
|
||||
manifest.manifestDigestHex,
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("activates a verified prestaged release without a fetcher", async () => {
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const bytes = new Uint8Array([4, 4, 4, 4]);
|
||||
const asset: PublicCacheAsset = {
|
||||
absoluteUrl: "https://assets.example.test/offline.js",
|
||||
expectedByteLength: bytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(bytes),
|
||||
},
|
||||
};
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const online = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async () =>
|
||||
new Response(bytes, {
|
||||
headers: {
|
||||
"cache-control": "public",
|
||||
"content-type": "application/javascript",
|
||||
},
|
||||
}),
|
||||
});
|
||||
const manifest = await manifestFor("offline-release", [asset], policy);
|
||||
expect(await online.admin.stageRelease(manifest)).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
// STO-05. Activation and cleanup perform no network I/O, so a missing
|
||||
// fetcher must not make an offline rollback UNSUPPORTED.
|
||||
const offline = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
});
|
||||
expect(
|
||||
await offline.admin.activateRelease(
|
||||
manifest.releaseRegistryId,
|
||||
manifest.manifestDigestHex,
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("cleans exact owned caches without a fetcher", async () => {
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const offline = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
});
|
||||
expect(await offline.admin.cleanupOwned()).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("stages, re-verifies and atomically activates an exact release", async () => {
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const bytes = new Uint8Array([1, 2, 3, 4]);
|
||||
@@ -374,6 +528,150 @@ describe("public response Cache Storage adapter", () => {
|
||||
await expect(cacheStorage.keys()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* NS-08. Handing the signal to each `Request` only asked a cooperative fetch
|
||||
* to stop. A stream that ignored it held the mutation lock forever, and work
|
||||
* that finished after the abort still wrote its asset and its marker.
|
||||
*/
|
||||
it("does not wait for a non-cooperative fetch after the caller aborts", async () => {
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const bytes = new Uint8Array([1, 2, 3, 4]);
|
||||
const asset: PublicCacheAsset = {
|
||||
absoluteUrl: "https://assets.example.test/never-settles.js",
|
||||
expectedByteLength: bytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: { algorithm: "SHA-256", digestHex: await digestHex(bytes) },
|
||||
};
|
||||
const manifest = await manifestFor("never-settles", [asset], policy);
|
||||
const fetchStarted = deferred<void>();
|
||||
let locksHeld = 0;
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: {
|
||||
async run(signal, operation) {
|
||||
locksHeld += 1;
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
locksHeld -= 1;
|
||||
}
|
||||
},
|
||||
},
|
||||
policy,
|
||||
fetcher: () => {
|
||||
fetchStarted.resolve(undefined);
|
||||
// Ignores the signal entirely.
|
||||
return new Promise<Response>(() => {});
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
|
||||
const staging = adapter.admin.stageRelease(manifest, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
await fetchStarted.promise;
|
||||
controller.abort();
|
||||
|
||||
await expect(staging).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
expect(locksHeld).toBe(0);
|
||||
await expect(cacheStorage.keys()).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("writes neither asset nor marker when a digest completes after the abort", async () => {
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const bytes = new Uint8Array([9, 9, 9, 9]);
|
||||
const asset: PublicCacheAsset = {
|
||||
absoluteUrl: "https://assets.example.test/late-digest.js",
|
||||
expectedByteLength: bytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: { algorithm: "SHA-256", digestHex: await digestHex(bytes) },
|
||||
};
|
||||
const manifest = await manifestFor("late-digest", [asset], policy);
|
||||
const digestStarted = deferred<void>();
|
||||
const releaseDigest = deferred<void>();
|
||||
let delayFirstDigest = true;
|
||||
const realCrypto = globalThis.crypto;
|
||||
const delayedCrypto = {
|
||||
subtle: {
|
||||
async digest(
|
||||
algorithm: AlgorithmIdentifier,
|
||||
data: BufferSource,
|
||||
): Promise<ArrayBuffer> {
|
||||
if (delayFirstDigest) {
|
||||
delayFirstDigest = false;
|
||||
digestStarted.resolve(undefined);
|
||||
await releaseDigest.promise;
|
||||
}
|
||||
return await realCrypto.subtle.digest(algorithm, data);
|
||||
},
|
||||
},
|
||||
} as unknown as Crypto;
|
||||
let puts = 0;
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: new Proxy(cacheStorage, {
|
||||
get(target, key, receiver) {
|
||||
if (key === "open") {
|
||||
return async (name: string) => {
|
||||
const cache = await target.open(name);
|
||||
return new Proxy(cache, {
|
||||
get(cacheTarget, cacheKey, cacheReceiver) {
|
||||
if (cacheKey === "put") {
|
||||
return async (...args: readonly unknown[]) => {
|
||||
puts += 1;
|
||||
return await (
|
||||
cacheTarget.put as (
|
||||
...values: readonly unknown[]
|
||||
) => Promise<void>
|
||||
)(...args);
|
||||
};
|
||||
}
|
||||
return Reflect.get(cacheTarget, cacheKey, cacheReceiver);
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
return Reflect.get(target, key, receiver);
|
||||
},
|
||||
}) as unknown as CacheStorage,
|
||||
crypto: delayedCrypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async () =>
|
||||
new Response(bytes, {
|
||||
headers: {
|
||||
"cache-control": "public, max-age=60",
|
||||
"content-type": "application/javascript",
|
||||
},
|
||||
}),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
|
||||
const staging = adapter.admin.stageRelease(manifest, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
await digestStarted.promise;
|
||||
controller.abort();
|
||||
releaseDigest.resolve(undefined);
|
||||
|
||||
await expect(staging).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
// Neither the asset nor the activation marker may be written by work the
|
||||
// abort already disowned.
|
||||
expect(puts).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the original activation signal while waiting for the mutation lock", async () => {
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
@@ -1215,3 +1513,199 @@ describe("public response Cache Storage adapter", () => {
|
||||
expect(await cacheStorage.keys()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is
|
||||
* the last thing a repair may destroy. A transient marker read failure is not
|
||||
* evidence of damage, and a repair that has not yet fetched anything has not
|
||||
* yet earned the right to delete what still works.
|
||||
*/
|
||||
describe("public response cache repair is failure-atomic", () => {
|
||||
async function stagedRelease(releaseRegistryId: string) {
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const firstBytes = new Uint8Array([1, 1, 1, 1]);
|
||||
const secondBytes = new Uint8Array([2, 2, 2, 2]);
|
||||
const assets: readonly PublicCacheAsset[] = [
|
||||
{
|
||||
absoluteUrl: "https://assets.example.test/first.js",
|
||||
expectedByteLength: firstBytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(firstBytes),
|
||||
},
|
||||
},
|
||||
{
|
||||
absoluteUrl: "https://assets.example.test/second.js",
|
||||
expectedByteLength: secondBytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(secondBytes),
|
||||
},
|
||||
},
|
||||
];
|
||||
const bodies = new Map<string, Uint8Array>([
|
||||
[assets[0]!.absoluteUrl, firstBytes],
|
||||
[assets[1]!.absoluteUrl, secondBytes],
|
||||
]);
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const fetchLog: string[] = [];
|
||||
let failFrom: string | null = null;
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async (request: Request) => {
|
||||
fetchLog.push(request.url);
|
||||
if (failFrom !== null && request.url === failFrom) {
|
||||
throw new TypeError("network is down");
|
||||
}
|
||||
const body = bodies.get(request.url);
|
||||
if (!body) throw new TypeError(`unknown asset ${request.url}`);
|
||||
return new Response(Uint8Array.from(body), {
|
||||
headers: {
|
||||
"cache-control": "public",
|
||||
"content-type": "application/javascript",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const manifest = await manifestFor(releaseRegistryId, assets, policy);
|
||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(
|
||||
await adapter.admin.activateRelease(
|
||||
manifest.releaseRegistryId,
|
||||
manifest.manifestDigestHex,
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
const cacheName = [...cacheStorage.caches.keys()].find((name) =>
|
||||
name.includes(releaseRegistryId),
|
||||
);
|
||||
if (!cacheName) throw new Error("staged cache missing");
|
||||
|
||||
return {
|
||||
adapter,
|
||||
assets,
|
||||
cacheName,
|
||||
cacheStorage,
|
||||
fetchLog,
|
||||
manifest,
|
||||
setFailure(url: string | null) {
|
||||
failFrom = url;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("does not delete an active candidate when the marker read fails transiently", async () => {
|
||||
const release = await stagedRelease("transient-marker");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
const realMatch = cache.match.bind(cache);
|
||||
let markerReads = 0;
|
||||
const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl));
|
||||
cache.match = async (request: RequestInfo | URL) => {
|
||||
const url =
|
||||
request instanceof Request ? request.url : String(request);
|
||||
if (!assetUrls.has(url)) {
|
||||
markerReads += 1;
|
||||
throw new DOMException("Storage is busy", "InvalidStateError");
|
||||
}
|
||||
return await realMatch(request);
|
||||
};
|
||||
|
||||
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
||||
|
||||
expect(markerReads).toBeGreaterThan(0);
|
||||
expect(restaged.ok).toBe(false);
|
||||
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
||||
cache.match = realMatch;
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[0]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("keeps every healthy asset when one repair fetch fails", async () => {
|
||||
const release = await stagedRelease("partial-repair");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
// Corrupt only the first asset's stored bytes.
|
||||
const corrupted = cache.responses.findIndex(
|
||||
(entry) => entry.request.url === release.assets[0]!.absoluteUrl,
|
||||
);
|
||||
expect(corrupted).toBeGreaterThanOrEqual(0);
|
||||
cache.responses.splice(corrupted, 1);
|
||||
|
||||
release.setFailure(release.assets[0]!.absoluteUrl);
|
||||
const restaged = await release.adapter.admin.stageRelease(release.manifest);
|
||||
expect(restaged.ok).toBe(false);
|
||||
|
||||
// The cache still exists and the healthy asset is still served.
|
||||
expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true);
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[1]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("still removes a candidate this call created when staging fails", async () => {
|
||||
const policy = createDefaultPublicCachePolicy(
|
||||
"https://assets.example.test",
|
||||
);
|
||||
const bytes = new Uint8Array([7, 7, 7, 7]);
|
||||
const asset: PublicCacheAsset = {
|
||||
absoluteUrl: "https://assets.example.test/fresh.js",
|
||||
expectedByteLength: bytes.byteLength,
|
||||
expectedContentType: "application/javascript",
|
||||
integrity: {
|
||||
algorithm: "SHA-256",
|
||||
digestHex: await digestHex(bytes),
|
||||
},
|
||||
};
|
||||
const cacheStorage = new MemoryCacheStorage();
|
||||
const adapter = createPublicResponseCacheAdapter({
|
||||
cacheStorage: cacheStorage as unknown as CacheStorage,
|
||||
crypto: globalThis.crypto,
|
||||
mutationLock: immediateLock,
|
||||
policy,
|
||||
fetcher: async () => {
|
||||
throw new TypeError("network is down");
|
||||
},
|
||||
});
|
||||
const manifest = await manifestFor("fresh-release", [asset], policy);
|
||||
|
||||
expect(await adapter.admin.stageRelease(manifest)).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
expect(await cacheStorage.keys()).toEqual([]);
|
||||
});
|
||||
|
||||
it("repairs an evicted asset in place and keeps the release usable", async () => {
|
||||
const release = await stagedRelease("in-place-repair");
|
||||
const cache = release.cacheStorage.caches.get(release.cacheName)!;
|
||||
const evicted = cache.responses.findIndex(
|
||||
(entry) => entry.request.url === release.assets[1]!.absoluteUrl,
|
||||
);
|
||||
cache.responses.splice(evicted, 1);
|
||||
|
||||
expect(
|
||||
await release.adapter.admin.stageRelease(release.manifest),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[1]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
await release.adapter.responses.matchActiveExact({
|
||||
absoluteUrl: release.assets[0]!.absoluteUrl,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -214,6 +214,47 @@ describe("live/poll authoritative writer handoff", () => {
|
||||
void second;
|
||||
});
|
||||
|
||||
it("tracks a retired active writer after handoff queue overflow", async () => {
|
||||
let release: ((result: RealtimeResult<void>) => void) | undefined;
|
||||
const harness = createHarness({
|
||||
limits: {
|
||||
...limits,
|
||||
maxActiveQueueCount: 2,
|
||||
maxActiveQueueBytes: 10,
|
||||
},
|
||||
apply: vi.fn(async ({ value }) => {
|
||||
if (value === "first") {
|
||||
return await new Promise<RealtimeResult<void>>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
}
|
||||
return realtimeSuccess(undefined);
|
||||
}),
|
||||
});
|
||||
const writer = harness.coordinator.currentWriter()!;
|
||||
const first = writer.write("first", 5);
|
||||
const second = writer.write("second", 5);
|
||||
await flush();
|
||||
await expect(writer.write("overflow", 1)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "QUEUE_OVERFLOW" },
|
||||
});
|
||||
|
||||
// R-03. The fail-close dropped the active reference, but the writer is
|
||||
// still running, so close() must not claim quiescence.
|
||||
const closing = harness.coordinator.close();
|
||||
await flush();
|
||||
harness.clock.advance(100);
|
||||
await expect(closing).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
|
||||
release?.(realtimeSuccess(undefined));
|
||||
void first;
|
||||
void second;
|
||||
});
|
||||
|
||||
it("fences and aborts live, waits for quiescence, then recovers before activating poll", async () => {
|
||||
const liveEffect = deferred<RealtimeResult<void>>();
|
||||
const checkpoint = deferred<RealtimeResult<void>>();
|
||||
@@ -486,12 +527,87 @@ describe("live/poll authoritative writer handoff", () => {
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
await expect(harness.coordinator.close()).resolves.toMatchObject({
|
||||
// RT-RR-03. A second close re-runs rather than replaying a cached verdict.
|
||||
// The writer is still hung, so it still reports a timeout.
|
||||
const second = harness.coordinator.close();
|
||||
await flush();
|
||||
harness.clock.advance(100);
|
||||
await expect(second).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* RT-RR-03. Caching the first timeout forever meant a writer that later
|
||||
* settled could never be proved quiescent: every subsequent close replayed
|
||||
* the stale failure and the retained registry could never be pruned.
|
||||
*/
|
||||
it("converges to success once a retired writer finally settles", async () => {
|
||||
let release: ((value: RealtimeResult<void>) => void) | undefined;
|
||||
const harness = createHarness({
|
||||
apply: vi.fn(
|
||||
async () =>
|
||||
await new Promise<RealtimeResult<void>>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
),
|
||||
});
|
||||
const live = harness.coordinator.currentWriter()!;
|
||||
void live.write("late-settle", 8);
|
||||
await flush();
|
||||
|
||||
const first = harness.coordinator.close();
|
||||
await flush();
|
||||
harness.clock.advance(100);
|
||||
await expect(first).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
|
||||
// The writer finishes after the first close gave up.
|
||||
release?.(realtimeSuccess(undefined));
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const second = harness.coordinator.close();
|
||||
await flush();
|
||||
harness.clock.advance(100);
|
||||
await expect(second).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* RT-RR-04. Checkpoint work is an external authority call like a writer
|
||||
* tail. Racing it against a timeout bounded the public wait but left it out
|
||||
* of the retained registry, so `close()` could report quiescence while the
|
||||
* checkpoint was still running.
|
||||
*/
|
||||
it("does not report quiescence while a checkpoint is still running", async () => {
|
||||
let checkpointSignal: AbortSignal | undefined;
|
||||
const harness = createHarness({
|
||||
recover: vi.fn(async ({ signal }) => {
|
||||
checkpointSignal = signal;
|
||||
return await new Promise<RealtimeResult<void>>(() => {});
|
||||
}),
|
||||
});
|
||||
|
||||
const transition = harness.coordinator.switchToPoll();
|
||||
await flush();
|
||||
expect(checkpointSignal).toBeDefined();
|
||||
|
||||
const closing = harness.coordinator.close();
|
||||
await flush();
|
||||
harness.clock.advance(100);
|
||||
await expect(closing).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
|
||||
harness.clock.advance(100);
|
||||
await flush();
|
||||
await transition;
|
||||
});
|
||||
|
||||
it("rejects invalid initial authority and resource ceilings", () => {
|
||||
expect(() =>
|
||||
createLivePollHandoffCoordinator({
|
||||
|
||||
@@ -29,6 +29,91 @@ import {
|
||||
} from "./fixture.ts";
|
||||
|
||||
describe("transport-independent realtime stream coordinator", () => {
|
||||
it("keeps the stream DRAINING until a non-cooperative effect settles", async () => {
|
||||
const wedged = deferred<RealtimeResult<void>>();
|
||||
const harness = createHarness({
|
||||
apply: async () => wedged.promise,
|
||||
taskLimits: { effectTimeoutMs: 5, drainTimeoutMs: 5 },
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
const applied = await harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-00000001",
|
||||
sequence: "1",
|
||||
resumeCursor: "cursor-00000001",
|
||||
}),
|
||||
);
|
||||
// Bounded for the caller, and explicitly non-retryable.
|
||||
expect(applied).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "APPLY", retryable: false },
|
||||
});
|
||||
expect(harness.coordinator.lifecycle(STREAM_ID)).toBe("DRAINING");
|
||||
|
||||
// DRAINING refuses new admission rather than queueing behind the wedge.
|
||||
await expect(
|
||||
harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-00000002",
|
||||
sequence: "2",
|
||||
resumeCursor: "cursor-00000002",
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({ ok: false, error: { kind: "CLOSED" } });
|
||||
|
||||
// close() cannot claim quiescence while the task is still running.
|
||||
await expect(harness.coordinator.close()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
|
||||
// Only actual settlement ends DRAINING.
|
||||
wedged.resolve(realtimeSuccess(undefined));
|
||||
await expect(harness.coordinator.close()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
expect(harness.coordinator.lifecycle(STREAM_ID)).toBe("CLOSED");
|
||||
});
|
||||
|
||||
it("bounds non-cooperative recovery and rejects its late checkpoint", async () => {
|
||||
const wedged = deferred<RealtimeResult<RealtimeRecoveryCommit>>();
|
||||
let recoveries = 0;
|
||||
const harness = createHarness({
|
||||
recover: async () => {
|
||||
recoveries += 1;
|
||||
return recoveries === 1
|
||||
? realtimeSuccess(snapshotCommit("0"))
|
||||
: wedged.promise;
|
||||
},
|
||||
taskLimits: { recoveryTimeoutMs: 5, drainTimeoutMs: 5 },
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
const recovered = await harness.coordinator.recover(
|
||||
STREAM_ID,
|
||||
"SEQUENCE_GAP",
|
||||
);
|
||||
expect(recovered).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "RECOVER", retryable: false },
|
||||
});
|
||||
expect(harness.coordinator.lifecycle(STREAM_ID)).toBe("DRAINING");
|
||||
|
||||
const beforeLateCommit = harness.coordinator.getResumeState(STREAM_ID);
|
||||
|
||||
// A late checkpoint from the abandoned attempt cannot commit.
|
||||
wedged.resolve(realtimeSuccess(snapshotCommit("9")));
|
||||
for (let flush = 0; flush < 10; flush += 1) await Promise.resolve();
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toEqual(
|
||||
beforeLateCommit,
|
||||
);
|
||||
// Settlement returns the stream to OPEN, marked STALE for an authoritative
|
||||
// recovery rather than silently trusting the abandoned attempt.
|
||||
expect(harness.coordinator.lifecycle(STREAM_ID)).toBe("OPEN");
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe("STALE");
|
||||
});
|
||||
|
||||
it("applies one stream sequentially and commits each cursor after its effect", async () => {
|
||||
const first = deferred<RealtimeResult<void>>();
|
||||
const applied: string[] = [];
|
||||
@@ -858,6 +943,118 @@ describe("transport-independent realtime stream coordinator", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* RT-RR-01 / RT-RR-02. A physical effect exists from the moment the coordinator
|
||||
* calls the authority, not from the moment its public wait expires. Registering
|
||||
* only on timeout let a `close()` that arrived first see an empty retained set
|
||||
* and report quiescence while the raw task was still running against the
|
||||
* authority.
|
||||
*/
|
||||
describe("realtime physical task ownership", () => {
|
||||
it("does not report quiescence while an apply is still running", async () => {
|
||||
const applyGate = deferred<RealtimeResult<void>>();
|
||||
const harness = createHarness({
|
||||
apply: async () => await applyGate.promise,
|
||||
taskLimits: { effectTimeoutMs: 10_000, drainTimeoutMs: 20 },
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
const applying = harness.coordinator.accept(harness.event());
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// close() arrives long before the effect deadline.
|
||||
const closed = await harness.coordinator.close();
|
||||
expect(closed.ok).toBe(false);
|
||||
expect(closed.ok ? null : closed.error.kind).toBe("IDLE_TIMEOUT");
|
||||
expect(harness.coordinator.lifecycle(STREAM_ID)).toBe("DRAINING");
|
||||
|
||||
applyGate.resolve(realtimeSuccess(undefined));
|
||||
await applying;
|
||||
});
|
||||
|
||||
it("reports quiescence once the raw task settles", async () => {
|
||||
const applyGate = deferred<RealtimeResult<void>>();
|
||||
const harness = createHarness({
|
||||
apply: async () => await applyGate.promise,
|
||||
taskLimits: { effectTimeoutMs: 10_000, drainTimeoutMs: 200 },
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
const applying = harness.coordinator.accept(harness.event());
|
||||
await Promise.resolve();
|
||||
const closing = harness.coordinator.close();
|
||||
applyGate.resolve(realtimeSuccess(undefined));
|
||||
await applying;
|
||||
|
||||
expect(await closing).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("does not start a queued event once the stream is DRAINING", async () => {
|
||||
const firstApply = deferred<RealtimeResult<void>>();
|
||||
let applyCalls = 0;
|
||||
const harness = createHarness({
|
||||
apply: async () => {
|
||||
applyCalls += 1;
|
||||
if (applyCalls === 1) return await firstApply.promise;
|
||||
return realtimeSuccess(undefined);
|
||||
},
|
||||
taskLimits: { effectTimeoutMs: 15, drainTimeoutMs: 50 },
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
const first = harness.coordinator.accept(
|
||||
harness.event({ eventId: "event-0001", sequence: "1" }),
|
||||
);
|
||||
// Queued behind the first while it is still inside its deadline.
|
||||
const second = harness.coordinator.accept(
|
||||
harness.event({ eventId: "event-0002", sequence: "2" }),
|
||||
);
|
||||
|
||||
await first;
|
||||
expect(harness.coordinator.lifecycle(STREAM_ID)).toBe("DRAINING");
|
||||
|
||||
// The queued event is dropped at execution time, not applied and not
|
||||
// recovered: admission happened before DRAINING, execution happens inside
|
||||
// it, and the second decision is the one that counts.
|
||||
const secondResult = await second;
|
||||
expect(secondResult).toMatchObject({
|
||||
ok: true,
|
||||
value: { outcome: "DROPPED", reason: "CLOSED" },
|
||||
});
|
||||
expect(applyCalls).toBe(1);
|
||||
|
||||
firstApply.resolve(realtimeSuccess(undefined));
|
||||
});
|
||||
|
||||
it("discards the resume token when an effect is abandoned", async () => {
|
||||
const firstApply = deferred<RealtimeResult<void>>();
|
||||
let applyCalls = 0;
|
||||
const harness = createHarness({
|
||||
apply: async () => {
|
||||
applyCalls += 1;
|
||||
if (applyCalls === 1) return await firstApply.promise;
|
||||
return realtimeSuccess(undefined);
|
||||
},
|
||||
taskLimits: { effectTimeoutMs: 15, drainTimeoutMs: 50 },
|
||||
});
|
||||
await harness.initialize();
|
||||
expect(harness.coordinator.inspect(STREAM_ID).hasResumeState).toBe(true);
|
||||
|
||||
await harness.coordinator.accept(
|
||||
harness.event({ eventId: "event-0001", sequence: "1" }),
|
||||
);
|
||||
|
||||
// The abandoned effect may have applied part of its change, so the token it
|
||||
// was based on is no longer authoritative evidence.
|
||||
const inspection = harness.coordinator.inspect(STREAM_ID);
|
||||
expect(inspection.hasResumeState).toBe(false);
|
||||
expect(inspection.freshness).toBe("UNKNOWN");
|
||||
|
||||
firstApply.resolve(realtimeSuccess(undefined));
|
||||
});
|
||||
});
|
||||
|
||||
type HarnessOptions = Readonly<{
|
||||
registry?: RealtimePolicyRegistry;
|
||||
mappers?: typeof TEST_MAPPERS | Readonly<Record<string, typeof TEST_MAPPER>>;
|
||||
@@ -866,6 +1063,13 @@ type HarnessOptions = Readonly<{
|
||||
request: RealtimeRecoveryRequest,
|
||||
) => Promise<RealtimeResult<RealtimeRecoveryCommit>>;
|
||||
observe?: (observation: RealtimeObservation) => void;
|
||||
taskLimits?: Readonly<{
|
||||
effectTimeoutMs?: number;
|
||||
recoveryTimeoutMs?: number;
|
||||
drainTimeoutMs?: number;
|
||||
}>;
|
||||
scheduleTimeout?: (callback: () => void, delayMs: number) => unknown;
|
||||
clearScheduledTimeout?: (handle: unknown) => void;
|
||||
}>;
|
||||
|
||||
function createHarness(options: HarnessOptions = {}) {
|
||||
@@ -911,6 +1115,13 @@ function createHarness(options: HarnessOptions = {}) {
|
||||
},
|
||||
now: () => 10_000,
|
||||
observe: options.observe,
|
||||
...(options.taskLimits ? { taskLimits: options.taskLimits } : {}),
|
||||
...(options.scheduleTimeout
|
||||
? { scheduleTimeout: options.scheduleTimeout }
|
||||
: {}),
|
||||
...(options.clearScheduledTimeout
|
||||
? { clearScheduledTimeout: options.clearScheduledTimeout }
|
||||
: {}),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -973,3 +1184,168 @@ function deferred<Value>() {
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
/**
|
||||
* RT-01. The registry entry has to exist before the collaborator is called.
|
||||
* Registering after the invocation returned left a window in which an authority
|
||||
* that re-entered `close()` from inside its own callback saw an empty set, so
|
||||
* `close()` reported quiescence while its effect was still running.
|
||||
*/
|
||||
describe("RT-01 reentrant close cannot pass the registration window", () => {
|
||||
it("refuses quiescence when apply re-enters close during its invocation", async () => {
|
||||
const applyGate = deferred<RealtimeResult<void>>();
|
||||
let closeResult: RealtimeResult<void> | undefined;
|
||||
let harness: ReturnType<typeof createHarness> | undefined;
|
||||
harness = createHarness({
|
||||
apply: () => {
|
||||
// Re-entered from inside the invocation itself.
|
||||
void harness!.coordinator.close().then((result) => {
|
||||
closeResult = result;
|
||||
});
|
||||
return applyGate.promise;
|
||||
},
|
||||
taskLimits: { effectTimeoutMs: 10_000, drainTimeoutMs: 20 },
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
const applying = harness.coordinator.accept(harness.event());
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(closeResult?.ok).toBe(false);
|
||||
expect(closeResult?.ok ? null : closeResult?.error.kind).toBe(
|
||||
"IDLE_TIMEOUT",
|
||||
);
|
||||
|
||||
applyGate.resolve(realtimeSuccess(undefined));
|
||||
await applying;
|
||||
// Once the raw task settled, a second close does converge.
|
||||
await expect(harness.coordinator.close()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses quiescence when recovery re-enters close during its invocation", async () => {
|
||||
const recoveryGate = deferred<RealtimeResult<RealtimeRecoveryCommit>>();
|
||||
let closeResult: RealtimeResult<void> | undefined;
|
||||
let harness: ReturnType<typeof createHarness> | undefined;
|
||||
let recoveries = 0;
|
||||
harness = createHarness({
|
||||
recover: () => {
|
||||
recoveries += 1;
|
||||
if (recoveries === 1) {
|
||||
return Promise.resolve(realtimeSuccess(snapshotCommit("0")));
|
||||
}
|
||||
void harness!.coordinator.close().then((result) => {
|
||||
closeResult = result;
|
||||
});
|
||||
return recoveryGate.promise;
|
||||
},
|
||||
taskLimits: { recoveryTimeoutMs: 10_000, drainTimeoutMs: 20 },
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
const recovering = harness.coordinator.recover(STREAM_ID, "SEQUENCE_GAP");
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(closeResult?.ok).toBe(false);
|
||||
expect(closeResult?.ok ? null : closeResult?.error.kind).toBe(
|
||||
"IDLE_TIMEOUT",
|
||||
);
|
||||
|
||||
recoveryGate.resolve(realtimeSuccess(snapshotCommit("0")));
|
||||
await recovering;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* RT-02. A scheduler that cannot install a deadline leaves the wait unbounded.
|
||||
* Letting the exception escape turned a typed realtime result into a native
|
||||
* rejection and, through the caller's own catch, started a recovery that
|
||||
* overlapped the effect still running.
|
||||
*/
|
||||
describe("RT-02 a throwing scheduler stays inside the result contract", () => {
|
||||
/** Installs deadlines normally until the stream is initialized. */
|
||||
function breakableScheduler() {
|
||||
const state = { broken: false };
|
||||
return {
|
||||
state,
|
||||
scheduleTimeout: (callback: () => void, delayMs: number) => {
|
||||
if (state.broken) throw new TypeError("scheduleTimeout exploded");
|
||||
return setTimeout(callback, delayMs);
|
||||
},
|
||||
clearScheduledTimeout: (handle: unknown) => {
|
||||
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("does not start a recovery that overlaps a pending apply", async () => {
|
||||
const applyGate = deferred<RealtimeResult<void>>();
|
||||
const scheduler = breakableScheduler();
|
||||
let recoveriesAfterApply = 0;
|
||||
const harness = createHarness({
|
||||
apply: () => applyGate.promise,
|
||||
recover: async () => {
|
||||
recoveriesAfterApply += 1;
|
||||
return realtimeSuccess(snapshotCommit("0"));
|
||||
},
|
||||
taskLimits: { effectTimeoutMs: 10_000, drainTimeoutMs: 20 },
|
||||
scheduleTimeout: scheduler.scheduleTimeout,
|
||||
clearScheduledTimeout: scheduler.clearScheduledTimeout,
|
||||
});
|
||||
await harness.initialize();
|
||||
recoveriesAfterApply = 0;
|
||||
scheduler.state.broken = true;
|
||||
|
||||
const accepted = await harness.coordinator.accept(harness.event());
|
||||
|
||||
// The apply is still pending, so no recovery may have run beside it.
|
||||
expect(recoveriesAfterApply).toBe(0);
|
||||
expect(accepted.ok).toBe(false);
|
||||
expect(harness.coordinator.lifecycle(STREAM_ID)).toBe("DRAINING");
|
||||
|
||||
applyGate.resolve(realtimeSuccess(undefined));
|
||||
});
|
||||
|
||||
it("returns a typed close failure instead of rejecting natively", async () => {
|
||||
const applyGate = deferred<RealtimeResult<void>>();
|
||||
const scheduler = breakableScheduler();
|
||||
const harness = createHarness({
|
||||
apply: () => applyGate.promise,
|
||||
taskLimits: { effectTimeoutMs: 10_000, drainTimeoutMs: 20 },
|
||||
scheduleTimeout: scheduler.scheduleTimeout,
|
||||
clearScheduledTimeout: scheduler.clearScheduledTimeout,
|
||||
});
|
||||
await harness.initialize();
|
||||
scheduler.state.broken = true;
|
||||
|
||||
const applying = harness.coordinator.accept(harness.event());
|
||||
// Let the apply reach the authority before the fence arrives.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
const closed = await harness.coordinator.close();
|
||||
|
||||
// A drain that could not be bounded is not proof of quiescence.
|
||||
expect(closed.ok).toBe(false);
|
||||
expect(closed.ok ? null : closed.error.kind).toBe("IDLE_TIMEOUT");
|
||||
|
||||
applyGate.resolve(realtimeSuccess(undefined));
|
||||
await applying;
|
||||
});
|
||||
|
||||
it("keeps the classified outcome when clearing a timer throws", async () => {
|
||||
const harness = createHarness({
|
||||
clearScheduledTimeout: () => {
|
||||
throw new TypeError("clearScheduledTimeout exploded");
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(harness.event()),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
await expect(harness.coordinator.close()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
REALTIME_WEBSOCKET_PROTOCOL,
|
||||
@@ -25,6 +25,43 @@ function encode(value: unknown): string {
|
||||
}
|
||||
|
||||
describe("realtime WebSocket protocol", () => {
|
||||
|
||||
it("rejects oversized text before allocating a full UTF-8 copy", () => {
|
||||
const encoderSpy = vi.spyOn(TextEncoder.prototype, "encode");
|
||||
try {
|
||||
const oversize = "a".repeat(64);
|
||||
expect(decodeWebSocketServerFrame(oversize, 8)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FRAME_TOO_LARGE" },
|
||||
});
|
||||
expect(encoderSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
encoderSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("counts multibyte and lone-surrogate bytes like TextEncoder", () => {
|
||||
const samples = [
|
||||
"abc",
|
||||
"\u00e9\u00e9",
|
||||
"\u20ac\u20ac",
|
||||
"\u{1f600}",
|
||||
"a\ud800b",
|
||||
"\udc00",
|
||||
];
|
||||
for (const sample of samples) {
|
||||
const expected = new TextEncoder().encode(sample).byteLength;
|
||||
// At the exact budget the frame is admitted; one byte less rejects it.
|
||||
expect(
|
||||
decodeWebSocketServerFrame(sample, expected).ok ||
|
||||
decodeWebSocketServerFrame(sample, expected),
|
||||
).toBeTruthy();
|
||||
expect(decodeWebSocketServerFrame(sample, expected - 1)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "FRAME_TOO_LARGE" },
|
||||
});
|
||||
}
|
||||
});
|
||||
it("decodes and freezes an exact WELCOME frame", () => {
|
||||
const result = decodeWebSocketServerFrame(
|
||||
encode({
|
||||
|
||||
@@ -183,7 +183,7 @@ describe("IndexedDB resumable upload checkpoint", () => {
|
||||
).toMatchObject({ ok: true });
|
||||
expect(await runtime.admin.deletePartition()).toEqual({
|
||||
ok: true,
|
||||
value: { state: "DELETED" },
|
||||
value: { state: "DELETED", effect: "APPLIED" },
|
||||
});
|
||||
expect(await runtime.store.read("upload_key_01")).toMatchObject({
|
||||
ok: false,
|
||||
@@ -191,21 +191,48 @@ describe("IndexedDB resumable upload checkpoint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds a partition deletion blocked by another browser context", async () => {
|
||||
it("returns PENDING UNKNOWN when deleteDatabase is still blocked", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const factory = deletingFactory(memory, "BLOCKED");
|
||||
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
||||
scope,
|
||||
factory: deletingFactory(memory, "BLOCKED"),
|
||||
factory,
|
||||
blockedTimeoutMs: 1,
|
||||
});
|
||||
// BT-UP-03. The native request is still live, so the deadline is not
|
||||
// evidence that nothing happened.
|
||||
expect(await runtime.admin.deletePartition()).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "PENDING",
|
||||
effect: "UNKNOWN",
|
||||
reason: "BLOCKED_DEADLINE",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the checkpoint store closed until a pending delete is resolved externally", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const factory = deletingFactory(memory, "BLOCKED");
|
||||
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
||||
scope,
|
||||
factory,
|
||||
blockedTimeoutMs: 1,
|
||||
});
|
||||
expect(await runtime.admin.deletePartition()).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "BLOCKED",
|
||||
retryable: true,
|
||||
recovery: "RELOAD_OTHER_CONTEXTS",
|
||||
},
|
||||
ok: true,
|
||||
value: { state: "PENDING" },
|
||||
});
|
||||
|
||||
// A second runtime over the same realm and database would race an unknown
|
||||
// native effect.
|
||||
expect(() =>
|
||||
createIndexedDbResumableUploadCheckpointRuntime({
|
||||
scope,
|
||||
factory,
|
||||
blockedTimeoutMs: 1,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("never reports a false abort after irreversible deleteDatabase dispatch", async () => {
|
||||
@@ -219,7 +246,7 @@ describe("IndexedDB resumable upload checkpoint", () => {
|
||||
controller.abort();
|
||||
expect(await deletion).toEqual({
|
||||
ok: true,
|
||||
value: { state: "DELETED" },
|
||||
value: { state: "DELETED", effect: "APPLIED" },
|
||||
});
|
||||
|
||||
const preAborted = new AbortController();
|
||||
|
||||
@@ -67,6 +67,56 @@ function createTransport(
|
||||
}
|
||||
|
||||
describe("resumable upload fetch transport", () => {
|
||||
it.each([
|
||||
{ label: "delta-seconds", header: "2", now: 1_000, expected: 2_000 },
|
||||
{ label: "HTTP-date ahead", header: "Thu, 01 Jan 1970 00:00:03 GMT", now: 1_000, expected: 2_000 },
|
||||
{ label: "HTTP-date behind (clock rollback)", header: "Thu, 01 Jan 1970 00:00:01 GMT", now: 9_000, expected: 0 },
|
||||
])(
|
||||
"resolves Retry-After against the injected clock ($label)",
|
||||
async ({ header, now, expected }) => {
|
||||
// BT-UP-02. Both branches use the same captured `now`, so boundaries and
|
||||
// clock rollback are deterministic.
|
||||
const transport = createTransport(
|
||||
(async () =>
|
||||
responseAt(ENDPOINTS.GET_STATUS, null, {
|
||||
status: 429,
|
||||
headers: { "retry-after": header },
|
||||
})) as unknown as typeof fetch,
|
||||
{ nowEpochMs: () => now },
|
||||
);
|
||||
|
||||
const result = await transport.execute({
|
||||
operation: "GET_STATUS",
|
||||
body: { sessionId: "session_01" },
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", retryAfterMs: expected },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores an invalid Retry-After date instead of guessing", async () => {
|
||||
const transport = createTransport(
|
||||
(async () =>
|
||||
responseAt(ENDPOINTS.GET_STATUS, null, {
|
||||
status: 429,
|
||||
headers: { "retry-after": "not-a-date" },
|
||||
})) as unknown as typeof fetch,
|
||||
{ nowEpochMs: () => 1_000 },
|
||||
);
|
||||
|
||||
const result = await transport.execute({
|
||||
operation: "GET_STATUS",
|
||||
body: { sessionId: "session_01" },
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(result).toMatchObject({ ok: false });
|
||||
if (result.ok) return;
|
||||
expect(result.error.retryAfterMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses a closed operation map and fixed production fetch policy", async () => {
|
||||
let receivedUrl = "";
|
||||
let receivedInit: RequestInit | undefined;
|
||||
@@ -444,4 +494,158 @@ describe("resumable upload Web Lock", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* X-AUDIT-02. The public port promises a `UploadProviderResult`. A scheduler
|
||||
* that cannot install the attempt deadline must close the attempt inside that
|
||||
* contract instead of rejecting it, and must not leave the caller listener
|
||||
* attached to the parent signal.
|
||||
*/
|
||||
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 attempt when the scheduler cannot install the deadline", async () => {
|
||||
const fetcher = vi.fn(async () => new Response(null, { status: 200 }));
|
||||
const { controller, added, removed } = trackedSignal();
|
||||
const transport = createTransport(fetcher as unknown as typeof fetch, {
|
||||
scheduler: {
|
||||
setTimeout: () => {
|
||||
throw new TypeError("upload scheduler install exploded");
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await transport.execute({
|
||||
operation: "GET_STATUS",
|
||||
body: { sessionId: "session_01" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("UNAVAILABLE");
|
||||
expect(result.error.retryable).toBe(true);
|
||||
}
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
expect(added.filter((type) => type === "abort")).toHaveLength(1);
|
||||
expect(removed.filter((type) => type === "abort")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the classified outcome when clearing the deadline throws", async () => {
|
||||
const fetcher = vi.fn(
|
||||
async () =>
|
||||
jsonResponseAt(ENDPOINTS.GET_STATUS, { sessionId: "session_01" }),
|
||||
);
|
||||
const { controller, added, removed } = trackedSignal();
|
||||
const transport = createTransport(fetcher as unknown as typeof fetch, {
|
||||
scheduler: {
|
||||
setTimeout: (callback: () => void, delayMs: number) =>
|
||||
setTimeout(callback, delayMs),
|
||||
clearTimeout: () => {
|
||||
throw new TypeError("upload scheduler clear exploded");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await transport.execute({
|
||||
operation: "GET_STATUS",
|
||||
body: { sessionId: "session_01" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(added.filter((type) => type === "abort")).toHaveLength(1);
|
||||
expect(removed.filter((type) => type === "abort")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("uses the scheduler methods captured at construction", async () => {
|
||||
const fetcher = vi.fn(
|
||||
async () =>
|
||||
jsonResponseAt(ENDPOINTS.GET_STATUS, { sessionId: "session_01" }),
|
||||
);
|
||||
const scheduler = {
|
||||
setTimeout: (callback: () => void, delayMs: number) =>
|
||||
setTimeout(callback, delayMs),
|
||||
clearTimeout: (handle: unknown) => {
|
||||
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
};
|
||||
const transport = createTransport(fetcher as unknown as typeof fetch, {
|
||||
scheduler,
|
||||
});
|
||||
scheduler.setTimeout = () => {
|
||||
throw new TypeError("mutated upload setTimeout");
|
||||
};
|
||||
scheduler.clearTimeout = () => {
|
||||
throw new TypeError("mutated upload clearTimeout");
|
||||
};
|
||||
|
||||
await expect(
|
||||
transport.execute({
|
||||
operation: "GET_STATUS",
|
||||
body: { sessionId: "session_01" },
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
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(
|
||||
(callback: () => void, delayMs: number) =>
|
||||
setTimeout(callback, delayMs) as unknown,
|
||||
);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const transport = createTransport(fetcher as unknown as typeof fetch, {
|
||||
scheduler: {
|
||||
setTimeout: setTimeout_,
|
||||
clearTimeout: (handle: unknown) => {
|
||||
clearTimeout(handle as ReturnType<typeof setTimeout>);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await transport.execute({
|
||||
operation: "GET_STATUS",
|
||||
body: { sessionId: "session_01" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error.code).toBe("ABORTED");
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
expect(setTimeout_).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PRESIGNED_TRANSFER_PROTOCOL } from "../../src/application/ports/browser-transfer/presigned-transfer.ts";
|
||||
|
||||
import type {
|
||||
PresignedUploadPartCapabilityProvider,
|
||||
} from "../../src/application/ports/browser-transfer/presigned-transfer.ts";
|
||||
@@ -156,6 +158,182 @@ function rangeSource(bytes: Uint8Array) {
|
||||
}
|
||||
|
||||
describe("resumable upload HTTP control plane", () => {
|
||||
/**
|
||||
* TR-RR-08. `Object.keys` sees only enumerable own string keys, so a symbol
|
||||
* or non-enumerable extra passed unseen and a later property read invoked
|
||||
* whatever accessor the sender installed — escaping the Result contract as a
|
||||
* rejection of a public method.
|
||||
*/
|
||||
it("closes every hostile control-plane object as typed CORRUPT_DATA", async () => {
|
||||
const fingerprint: UploadFileFingerprint = Object.freeze({
|
||||
algorithm: "SHA-256-PARTS-V1",
|
||||
digestHex: "a".repeat(64),
|
||||
byteLength: 4,
|
||||
partSizeBytes: 4,
|
||||
partCount: 1,
|
||||
});
|
||||
const validSession = () => ({
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId: "session_01",
|
||||
requestBindingSha256: "b".repeat(64),
|
||||
fingerprint,
|
||||
partSizeBytes: 4,
|
||||
partCount: 1,
|
||||
maxConcurrency: 1,
|
||||
expiresAtEpochMs: NOW + 10_000,
|
||||
});
|
||||
|
||||
const hostile: readonly (readonly [string, () => unknown])[] = [
|
||||
[
|
||||
"throwing getter",
|
||||
() => {
|
||||
const value = validSession() as Record<string, unknown>;
|
||||
Object.defineProperty(value, "sessionId", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
throw new TypeError("hostile getter");
|
||||
},
|
||||
});
|
||||
return value;
|
||||
},
|
||||
],
|
||||
[
|
||||
"symbol key",
|
||||
() => ({ ...validSession(), [Symbol("injected")]: "leak" }),
|
||||
],
|
||||
[
|
||||
"non-enumerable extra",
|
||||
() => {
|
||||
const value = validSession() as Record<string, unknown>;
|
||||
Object.defineProperty(value, "signedUrl", {
|
||||
configurable: true,
|
||||
enumerable: false,
|
||||
value: "https://objects.example/secret?signature=leak",
|
||||
});
|
||||
return value;
|
||||
},
|
||||
],
|
||||
[
|
||||
"ownKeys trap",
|
||||
() =>
|
||||
new Proxy(validSession() as Record<string, unknown>, {
|
||||
ownKeys() {
|
||||
throw new TypeError("hostile ownKeys");
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"getOwnPropertyDescriptor trap",
|
||||
() =>
|
||||
new Proxy(validSession() as Record<string, unknown>, {
|
||||
getOwnPropertyDescriptor() {
|
||||
throw new TypeError("hostile descriptor");
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a nested fingerprint with an extra field",
|
||||
() => ({
|
||||
...validSession(),
|
||||
fingerprint: { ...fingerprint, injected: true },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a nested fingerprint behind an accessor",
|
||||
() => {
|
||||
const value = validSession() as Record<string, unknown>;
|
||||
Object.defineProperty(value, "fingerprint", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => fingerprint,
|
||||
});
|
||||
return value;
|
||||
},
|
||||
],
|
||||
[
|
||||
"a custom prototype",
|
||||
() =>
|
||||
Object.assign(Object.create({ injected: true }), validSession()),
|
||||
],
|
||||
];
|
||||
|
||||
for (const [label, build] of hostile) {
|
||||
const control = createResumableUploadHttpControlPlane({
|
||||
transport: {
|
||||
async execute() {
|
||||
return browserDataSuccess(build());
|
||||
},
|
||||
},
|
||||
partCapabilities: { issueUploadPart: vi.fn() },
|
||||
});
|
||||
const result = await control.createSession({
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
uploadKey: "upload_key_strict",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
requestBindingSha256: "b".repeat(64),
|
||||
fingerprint,
|
||||
requestedPartSizeBytes: 4,
|
||||
requestedMaxConcurrency: 1,
|
||||
idempotencyKey: "upload-create-idempotency-01",
|
||||
signal: activeSignal,
|
||||
});
|
||||
expect(result, label).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "CORRUPT_DATA", operation: "UPLOAD_SESSION" },
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("signature=leak");
|
||||
}
|
||||
|
||||
/**
|
||||
* TR-05. The decoder checked the sender's object and then read it again to
|
||||
* build the result, so a stateful answer could show a safe `sessionId` to
|
||||
* the regex and hand an unvalidated one to the receipt. Reading once means
|
||||
* the value that was validated is the value that is returned.
|
||||
*/
|
||||
let sessionIdReads = 0;
|
||||
const statefulControl = createResumableUploadHttpControlPlane({
|
||||
transport: {
|
||||
async execute() {
|
||||
return browserDataSuccess(
|
||||
new Proxy(validSession() as Record<string, unknown>, {
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
if (key === "sessionId") {
|
||||
sessionIdReads += 1;
|
||||
return {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: sessionIdReads > 1 ? "../../unsafe" : "session_01",
|
||||
};
|
||||
}
|
||||
return Reflect.getOwnPropertyDescriptor(target, key);
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
partCapabilities: { issueUploadPart: vi.fn() },
|
||||
});
|
||||
const stateful = await statefulControl.createSession({
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
uploadKey: "upload_key_strict",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
requestBindingSha256: "b".repeat(64),
|
||||
fingerprint,
|
||||
requestedPartSizeBytes: 4,
|
||||
requestedMaxConcurrency: 1,
|
||||
idempotencyKey: "upload-create-idempotency-02",
|
||||
signal: activeSignal,
|
||||
});
|
||||
expect(sessionIdReads).toBe(1);
|
||||
expect(JSON.stringify(stateful)).not.toContain("../../unsafe");
|
||||
if (stateful.ok) {
|
||||
expect(stateful.value.sessionId).toBe("session_01");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown response fields so URLs cannot cross the DTO boundary", async () => {
|
||||
const fingerprint: UploadFileFingerprint = Object.freeze({
|
||||
algorithm: "SHA-256-PARTS-V1",
|
||||
@@ -272,6 +450,8 @@ describe("resumable upload HTTP control plane", () => {
|
||||
}),
|
||||
);
|
||||
return jsonResponseAt(CAPABILITY_ENDPOINT, {
|
||||
// BT-PRE-02. The capability envelope declares its wire protocol.
|
||||
protocol: PRESIGNED_TRANSFER_PROTOCOL,
|
||||
capabilityReceipt: `capability-upload-${capabilitySequence}`,
|
||||
method: "PUT",
|
||||
binding: request.binding,
|
||||
|
||||
@@ -347,6 +347,65 @@ function executorFor(
|
||||
}
|
||||
|
||||
describe("production resumable upload runtime", () => {
|
||||
it("disposes through one drain that proves quiescence", async () => {
|
||||
// BT-UP-06. close() closes admission; dispose() awaits real settlement.
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const harness = createControlHarness();
|
||||
let releaseUpload: (() => void) | undefined;
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness, {
|
||||
delay: async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseUpload = resolve;
|
||||
});
|
||||
},
|
||||
}),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: runtimePolicy(),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const uploading = runtime.upload({
|
||||
uploadKey: "upload_key_01",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
signal: activeSignal,
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseUpload).toBeDefined());
|
||||
expect(runtime.lifecycle()).toBe("OPEN");
|
||||
|
||||
const first = runtime.dispose();
|
||||
const second = runtime.dispose();
|
||||
// Duplicate dispose is single-flight.
|
||||
expect(first).toBe(second);
|
||||
expect(runtime.lifecycle()).toBe("CLOSING");
|
||||
|
||||
// New admission is refused while draining.
|
||||
await expect(
|
||||
runtime.upload({
|
||||
uploadKey: "upload_key_02",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1])),
|
||||
signal: activeSignal,
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: false, error: { code: "UNAVAILABLE" } });
|
||||
// The checkpoint store cannot close before the operation settles.
|
||||
expect(checkpoints.closed).toBe(false);
|
||||
|
||||
releaseUpload?.();
|
||||
await first;
|
||||
await uploading;
|
||||
expect(runtime.lifecycle()).toBe("CLOSED");
|
||||
expect(checkpoints.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("has a valid production default policy", () => {
|
||||
expect(() => resolveResumableUploadRuntimePolicy()).not.toThrow();
|
||||
expect(() =>
|
||||
@@ -1221,3 +1280,177 @@ describe("production resumable upload runtime", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
/**
|
||||
* TR-RR-06. A non-cooperative mutation lock or provider must not make teardown
|
||||
* unbounded: `dispose()` bounds its drain and reports honestly when the runtime
|
||||
* is still CLOSING, and an abort is admitted physical work it cannot step over.
|
||||
*/
|
||||
describe("TR-RR-06 bounded resumable teardown", () => {
|
||||
it("reports an unproved drain instead of waiting forever", async () => {
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const harness = createControlHarness();
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness, { delay: async () => {} }),
|
||||
checkpoints,
|
||||
// A lock that never grants: dispose must still be bounded.
|
||||
mutationLock: Object.freeze({
|
||||
async run<Value>(): Promise<Value> {
|
||||
return await new Promise<never>(() => {});
|
||||
},
|
||||
}),
|
||||
crypto,
|
||||
policy: runtimePolicy({ cleanupDeadlineMs: 20 }),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
void runtime.upload({
|
||||
uploadKey: "upload_key_hung",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
signal: activeSignal,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
const disposed = await runtime.dispose();
|
||||
expect(disposed.ok).toBe(false);
|
||||
// Still CLOSING: physical work the caller must not treat as finished.
|
||||
expect(runtime.lifecycle()).toBe("CLOSING");
|
||||
expect(checkpoints.closed).toBe(false);
|
||||
});
|
||||
|
||||
it("closes once every admitted operation settles", async () => {
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const harness = createControlHarness();
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness, { delay: async () => {} }),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: runtimePolicy({ cleanupDeadlineMs: 200 }),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
const disposed = await runtime.dispose();
|
||||
expect(disposed).toMatchObject({ ok: true });
|
||||
expect(runtime.lifecycle()).toBe("CLOSED");
|
||||
expect(checkpoints.closed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TR-04. `dispose()` proved quiescence from the wrapper registry only. A
|
||||
* provider that ignored its attempt deadline let the wrapper settle first and
|
||||
* leave the set empty, so teardown reported a drained runtime — and closed the
|
||||
* checkpoint store — while the provider was still running.
|
||||
*/
|
||||
describe("TR-04 teardown waits for raw provider work, not only its wrapper", () => {
|
||||
it("refuses to report a drained runtime while a provider is still running", async () => {
|
||||
const harness = createControlHarness();
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const closeStore = vi.spyOn(checkpoints, "close");
|
||||
let releaseProvider: (() => void) | undefined;
|
||||
// Ignores the attempt signal entirely and outlives its own deadline.
|
||||
harness.control.createSession = () =>
|
||||
new Promise((resolve) => {
|
||||
releaseProvider = () =>
|
||||
resolve(
|
||||
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: runtimePolicy({
|
||||
providerAttemptTimeoutMs: 5,
|
||||
cleanupDeadlineMs: 25,
|
||||
maxRetries: 0,
|
||||
}),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const uploading = runtime.upload({
|
||||
uploadKey: "upload_key_raw",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
signal: activeSignal,
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
|
||||
// The wrapper has already given up on the attempt.
|
||||
await uploading;
|
||||
|
||||
const disposed = await runtime.dispose();
|
||||
expect(disposed.ok).toBe(false);
|
||||
if (!disposed.ok) {
|
||||
expect(disposed.error.code).toBe("UNAVAILABLE");
|
||||
expect(disposed.error.recovery).toBe("RESUME");
|
||||
}
|
||||
// The store stays open while something could still write a checkpoint.
|
||||
expect(closeStore).not.toHaveBeenCalled();
|
||||
|
||||
releaseProvider?.();
|
||||
});
|
||||
|
||||
it("reports a drained runtime once the raw provider settles", async () => {
|
||||
const harness = createControlHarness();
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
let releaseProvider: (() => void) | undefined;
|
||||
harness.control.createSession = () =>
|
||||
new Promise((resolve) => {
|
||||
releaseProvider = () =>
|
||||
resolve(
|
||||
browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", {
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane: harness.control,
|
||||
partExecutor: executorFor(harness),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: runtimePolicy({
|
||||
providerAttemptTimeoutMs: 5,
|
||||
cleanupDeadlineMs: 1_000,
|
||||
maxRetries: 0,
|
||||
}),
|
||||
now: () => 1_000,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const uploading = runtime.upload({
|
||||
uploadKey: "upload_key_raw_2",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])),
|
||||
signal: activeSignal,
|
||||
});
|
||||
await vi.waitFor(() => expect(releaseProvider).toBeDefined());
|
||||
await uploading;
|
||||
|
||||
const disposing = runtime.dispose();
|
||||
releaseProvider?.();
|
||||
await expect(disposing).resolves.toMatchObject({ ok: true });
|
||||
expect(runtime.lifecycle()).toBe("CLOSED");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -138,6 +138,38 @@ describe("runtime adapter composition", () => {
|
||||
adapters.infrastructure.dispose();
|
||||
});
|
||||
|
||||
it("runtime infrastructure disposal disposes telemetry first", async () => {
|
||||
const fetcher = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime: {
|
||||
...runtime,
|
||||
config: {
|
||||
...runtime.config,
|
||||
TELEMETRY_ENABLED: true,
|
||||
TELEMETRY_ENDPOINT: "https://telemetry.test/events",
|
||||
},
|
||||
} as Runtime,
|
||||
release,
|
||||
host: {},
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
adapters.outputPorts.telemetry.emit("api.request.failed", {
|
||||
error_kind: "SERVER_FAILURE",
|
||||
http_status_group: "5xx",
|
||||
attempt_count_bucket: "1",
|
||||
route_id: "TEST_ROUTE",
|
||||
});
|
||||
expect(adapters.outputPorts.telemetry.pendingCount()).toBe(1);
|
||||
|
||||
adapters.infrastructure.dispose();
|
||||
|
||||
expect(adapters.outputPorts.telemetry.pendingCount()).toBe(0);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replaces the QueryClient and coordinator for each session generation", async () => {
|
||||
const adapters = await createRuntimeAdapters({ runtime, release, host: {} });
|
||||
const previousClient = adapters.infrastructure.queryClient;
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { resolveServiceWorkerBuildInput } from "../../scripts/lib/service-worker-build-input.ts";
|
||||
import {
|
||||
canonicalStaticManifestBytes,
|
||||
type StaticAssetRow,
|
||||
} from "../../src/contracts/service-worker-static-manifest.ts";
|
||||
|
||||
const digest = (character: string) => `sha256:${character.repeat(64)}`;
|
||||
|
||||
/** SW-05. The gate recomputes this from the shared canonical bytes. */
|
||||
function setDigestFor(rows: readonly StaticAssetRow[]): string {
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(canonicalStaticManifestBytes(rows))
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
const ASSET_ROWS: readonly StaticAssetRow[] = Object.freeze([
|
||||
Object.freeze({
|
||||
url: "/assets/app.0123456789abcdef.js",
|
||||
sha256: digest("c"),
|
||||
bytes: 128,
|
||||
contentType: "text/javascript",
|
||||
}),
|
||||
]);
|
||||
|
||||
describe("service worker build input", () => {
|
||||
const selection = {
|
||||
mode: "ACTIVE" as const,
|
||||
@@ -14,8 +36,8 @@ describe("service worker build input", () => {
|
||||
schemaVersion: 1 as const,
|
||||
buildId: "build-1",
|
||||
releaseId: "release-1",
|
||||
setDigest: digest("a"),
|
||||
assets: [],
|
||||
setDigest: setDigestFor(ASSET_ROWS),
|
||||
assets: [...ASSET_ROWS],
|
||||
};
|
||||
|
||||
it("rejects a direct worker build when ACTIVE selection or generated inputs are absent", () => {
|
||||
@@ -84,4 +106,90 @@ describe("service worker build input", () => {
|
||||
releaseManifestUrl: "/release-manifest.json",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects asset row or canonical set-digest tampering at build input", () => {
|
||||
const base = {
|
||||
selection,
|
||||
contractSet: { setDigest: digest("b") },
|
||||
runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" },
|
||||
buildId: "build-1",
|
||||
releaseId: "release-1",
|
||||
};
|
||||
const tampered: readonly Readonly<{
|
||||
label: string;
|
||||
assets: unknown;
|
||||
}>[] = [
|
||||
{
|
||||
label: "stale set digest",
|
||||
assets: { ...assets, setDigest: digest("a") },
|
||||
},
|
||||
{
|
||||
label: "tampered byte length",
|
||||
assets: {
|
||||
...assets,
|
||||
assets: [{ ...ASSET_ROWS[0]!, bytes: 129 }],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "cross-origin url",
|
||||
assets: {
|
||||
...assets,
|
||||
assets: [
|
||||
{ ...ASSET_ROWS[0]!, url: "https://evil.example/a.js" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "dot segment",
|
||||
assets: {
|
||||
...assets,
|
||||
assets: [{ ...ASSET_ROWS[0]!, url: "/assets/../a.js" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "extension and content type mismatch",
|
||||
assets: {
|
||||
...assets,
|
||||
assets: [{ ...ASSET_ROWS[0]!, contentType: "text/css" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "unknown row field",
|
||||
assets: {
|
||||
...assets,
|
||||
assets: [{ ...ASSET_ROWS[0]!, extra: "smuggled" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "duplicate url",
|
||||
assets: {
|
||||
...assets,
|
||||
assets: [ASSET_ROWS[0]!, ASSET_ROWS[0]!],
|
||||
},
|
||||
},
|
||||
];
|
||||
for (const entry of tampered) {
|
||||
expect(
|
||||
() =>
|
||||
resolveServiceWorkerBuildInput({
|
||||
...base,
|
||||
assets: entry.assets as never,
|
||||
}),
|
||||
entry.label,
|
||||
).toThrow(TypeError);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts generator-shaped output unchanged", () => {
|
||||
expect(() =>
|
||||
resolveServiceWorkerBuildInput({
|
||||
selection,
|
||||
assets,
|
||||
contractSet: { setDigest: digest("b") },
|
||||
runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" },
|
||||
buildId: "build-1",
|
||||
releaseId: "release-1",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +49,8 @@ function pageContainer(options: { waiting?: boolean; controlled?: boolean } = {}
|
||||
} as unknown as ServiceWorkerContainer;
|
||||
return {
|
||||
container,
|
||||
waiting,
|
||||
controlled,
|
||||
registration,
|
||||
waitingMessages,
|
||||
controllerMessages,
|
||||
@@ -118,6 +120,249 @@ function workerScope() {
|
||||
return { scope, clients, deleted };
|
||||
}
|
||||
|
||||
describe("service worker static cache authority", () => {
|
||||
const setDigest = `sha256:${"c".repeat(64)}` as const;
|
||||
const currentCacheName = `${STATIC_CACHE_PREFIX}${setDigest.slice(
|
||||
"sha256:".length,
|
||||
"sha256:".length + 16,
|
||||
)}`;
|
||||
|
||||
function staticRuntime(
|
||||
caches: Readonly<{
|
||||
open: (name: string) => Promise<unknown>;
|
||||
keys: () => Promise<readonly string[]>;
|
||||
delete: (name: string) => Promise<boolean>;
|
||||
}>,
|
||||
) {
|
||||
return createServiceWorkerRuntime(
|
||||
{
|
||||
caches,
|
||||
clients: { matchAll: vi.fn(async () => []) },
|
||||
registrationScope: `${ORIGIN}/app/`,
|
||||
skipWaiting: vi.fn(async () => {}),
|
||||
fetcher: vi.fn(),
|
||||
digest: vi.fn(),
|
||||
} as never,
|
||||
{
|
||||
identity: { ...identity, staticAssetSetDigest: setDigest },
|
||||
handlers: ["PWA_STATIC_ASSETS"],
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
buildId: identity.buildId,
|
||||
releaseId: identity.releaseId,
|
||||
setDigest,
|
||||
// SW-URL-01. Generator output is root-relative.
|
||||
assets: [
|
||||
{
|
||||
url: "/assets/app.0123456789abcdef.js",
|
||||
sha256: `sha256:${"d".repeat(64)}`,
|
||||
bytes: 10,
|
||||
contentType: "text/javascript",
|
||||
},
|
||||
],
|
||||
},
|
||||
runtimeConfigUrl: "/runtime-config.json",
|
||||
releaseManifestUrl: "/release-manifest.json",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
it("classifies a generated root-relative asset against an absolute Request URL", async () => {
|
||||
const currentResponse = new Response("current", { status: 200 });
|
||||
const opened: string[] = [];
|
||||
const runtime = staticRuntime({
|
||||
open: vi.fn(async (name: string) => {
|
||||
opened.push(name);
|
||||
return {
|
||||
match: async () => currentResponse.clone(),
|
||||
delete: async () => true,
|
||||
};
|
||||
}),
|
||||
keys: vi.fn(async () => [currentCacheName]),
|
||||
delete: vi.fn(async () => true),
|
||||
});
|
||||
|
||||
const served = await runtime.onFetch({
|
||||
method: "GET",
|
||||
url: `${ORIGIN}/assets/app.0123456789abcdef.js`,
|
||||
});
|
||||
expect(served).not.toBeNull();
|
||||
expect(opened).toEqual([currentCacheName]);
|
||||
});
|
||||
|
||||
it("matches static responses only in the current release cache", async () => {
|
||||
const previousCacheName = `${STATIC_CACHE_PREFIX}${"e".repeat(16)}`;
|
||||
const previousMatch = vi.fn(
|
||||
async () => new Response("previous", { status: 200 }),
|
||||
);
|
||||
const deleteEntry = async (): Promise<boolean> => true;
|
||||
const emptyMatch = async (): Promise<Response | undefined> => undefined;
|
||||
const previousCache = { match: previousMatch, delete: deleteEntry };
|
||||
const emptyCache = { match: emptyMatch, delete: deleteEntry };
|
||||
const runtime = staticRuntime({
|
||||
open: vi.fn(async (name: string) =>
|
||||
name === previousCacheName ? previousCache : emptyCache,
|
||||
),
|
||||
keys: vi.fn(async () => [currentCacheName, previousCacheName]),
|
||||
delete: vi.fn(async () => true),
|
||||
});
|
||||
|
||||
// Only the previous cache holds the entry, so the request falls through to
|
||||
// the network rather than serving a stale release.
|
||||
await expect(
|
||||
runtime.onFetch({
|
||||
method: "GET",
|
||||
url: `${ORIGIN}/assets/app.0123456789abcdef.js`,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(previousMatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes an invalid hit only from the current release cache", async () => {
|
||||
const deletes: string[] = [];
|
||||
const runtime = staticRuntime({
|
||||
open: vi.fn(async (name: string) => {
|
||||
const recordDelete = async (url: string): Promise<boolean> => {
|
||||
deletes.push(`${name}:${url}`);
|
||||
return true;
|
||||
};
|
||||
return {
|
||||
match: async () => new Response("bad", { status: 500 }),
|
||||
delete: recordDelete,
|
||||
};
|
||||
}),
|
||||
keys: vi.fn(async () => [currentCacheName]),
|
||||
delete: vi.fn(async () => true),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime.onFetch({
|
||||
method: "GET",
|
||||
url: `${ORIGIN}/assets/app.0123456789abcdef.js`,
|
||||
}),
|
||||
).resolves.toBeNull();
|
||||
expect(deletes).toEqual([
|
||||
`${currentCacheName}:${ORIGIN}/assets/app.0123456789abcdef.js`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("service worker exact ownership and truthful removal", () => {
|
||||
it("deletes only exact owned static cache names", async () => {
|
||||
const fixture = workerScope();
|
||||
fixture.scope.caches.keys = vi.fn(async () => [
|
||||
`${STATIC_CACHE_PREFIX}${"a".repeat(16)}`,
|
||||
`${STATIC_CACHE_PREFIX}${"b".repeat(16)}`,
|
||||
// SW-02. Same prefix, not owned.
|
||||
`${STATIC_CACHE_PREFIX}not-owned`,
|
||||
`${STATIC_CACHE_PREFIX}${"c".repeat(17)}`,
|
||||
`${STATIC_CACHE_PREFIX}${"A".repeat(16)}`,
|
||||
"foreign-cache",
|
||||
]);
|
||||
const runtime = createServiceWorkerRuntime(fixture.scope as never, {
|
||||
identity,
|
||||
handlers: [],
|
||||
manifest: null,
|
||||
runtimeConfigUrl: "/runtime-config.json",
|
||||
releaseManifestUrl: "/release-manifest.json",
|
||||
});
|
||||
const request = createServiceWorkerMessage({
|
||||
kind: "CACHE_RESET_REQUEST",
|
||||
sourceBuildId: "page-build",
|
||||
targetBuildId: identity.buildId,
|
||||
nonce: "nonce-reset-0001",
|
||||
});
|
||||
await runtime.onCacheResetRequest(request, fixture.clients[0] as never);
|
||||
|
||||
expect(fixture.deleted).toEqual([
|
||||
`${STATIC_CACHE_PREFIX}${"a".repeat(16)}`,
|
||||
`${STATIC_CACHE_PREFIX}${"b".repeat(16)}`,
|
||||
]);
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
label: "unregister false",
|
||||
unregister: async () => false,
|
||||
expected: { kind: "FAILED" },
|
||||
},
|
||||
{
|
||||
label: "unregister rejects",
|
||||
unregister: async () => {
|
||||
throw new TypeError("unregister exploded");
|
||||
},
|
||||
expected: { kind: "FAILED" },
|
||||
},
|
||||
{
|
||||
label: "unregister true",
|
||||
unregister: async () => true,
|
||||
expected: { kind: "DISABLED" },
|
||||
},
|
||||
])(
|
||||
"does not hide $label behind DISABLED",
|
||||
async ({ unregister, expected }) => {
|
||||
const registration = {
|
||||
scope: `${ORIGIN}/`,
|
||||
installing: null,
|
||||
waiting: null,
|
||||
active: { scriptURL: SCRIPT_URL },
|
||||
unregister: vi.fn(unregister),
|
||||
update: vi.fn(async () => {}),
|
||||
} as unknown as ServiceWorkerRegistration;
|
||||
const controller = createServiceWorkerPageController({
|
||||
container: {
|
||||
controller: null,
|
||||
register: vi.fn(),
|
||||
getRegistration: vi.fn(async () => registration),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as never,
|
||||
routerBasePath: "/",
|
||||
origin: ORIGIN,
|
||||
selection: {
|
||||
mode: "REMOVE_REGISTRATION",
|
||||
scriptPath: "service-worker.js",
|
||||
handlers: [],
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(controller.start()).resolves.toMatchObject(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("reports an ownership mismatch as INCOMPATIBLE rather than DISABLED", async () => {
|
||||
const foreign = {
|
||||
scope: `${ORIGIN}/`,
|
||||
installing: null,
|
||||
waiting: null,
|
||||
active: { scriptURL: `${ORIGIN}/someone-else.js` },
|
||||
unregister: vi.fn(async () => true),
|
||||
update: vi.fn(async () => {}),
|
||||
} as unknown as ServiceWorkerRegistration;
|
||||
const controller = createServiceWorkerPageController({
|
||||
container: {
|
||||
controller: null,
|
||||
register: vi.fn(),
|
||||
getRegistration: vi.fn(async () => foreign),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as never,
|
||||
routerBasePath: "/",
|
||||
origin: ORIGIN,
|
||||
selection: {
|
||||
mode: "REMOVE_REGISTRATION",
|
||||
scriptPath: "service-worker.js",
|
||||
handlers: [],
|
||||
},
|
||||
} as never);
|
||||
|
||||
await expect(controller.start()).resolves.toMatchObject({
|
||||
kind: "INCOMPATIBLE",
|
||||
});
|
||||
expect(foreign.unregister).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("service worker page protocol", () => {
|
||||
it("does not attach late listeners when stopped during registration", async () => {
|
||||
const browser = pageContainer();
|
||||
@@ -141,7 +386,12 @@ describe("service worker page protocol", () => {
|
||||
const browser = pageContainer({ waiting: true });
|
||||
const controller = pageController(browser.container);
|
||||
await controller.start();
|
||||
const source = { postMessage: vi.fn() };
|
||||
// SW-06. Replies are correlated by source identity, so the fake request
|
||||
// comes from the registration's waiting worker.
|
||||
const source = browser.waiting as unknown as {
|
||||
postMessage(message: unknown): void;
|
||||
};
|
||||
const sourceMessages = vi.spyOn(source, "postMessage");
|
||||
|
||||
browser.dispatch(
|
||||
createServiceWorkerMessage({
|
||||
@@ -153,7 +403,7 @@ describe("service worker page protocol", () => {
|
||||
source,
|
||||
);
|
||||
|
||||
expect(source.postMessage).toHaveBeenCalledWith(
|
||||
expect(sourceMessages).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "CLIENT_DRAINED",
|
||||
sourceBuildId: "page-build",
|
||||
@@ -201,7 +451,7 @@ describe("service worker page protocol", () => {
|
||||
nonce: request.nonce,
|
||||
}),
|
||||
cachesDeleted: 2,
|
||||
});
|
||||
}, browser.controlled ?? undefined);
|
||||
|
||||
await expect(result).resolves.toEqual({ kind: "RESET", cachesDeleted: 2 });
|
||||
await controller.stop();
|
||||
@@ -457,7 +707,7 @@ describe("service worker static asset install", () => {
|
||||
|
||||
it("aborts and rolls back a candidate cache at the overall install deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deleteCache = vi.fn(async () => true);
|
||||
const deleteCache = vi.fn(async (_name: string) => true);
|
||||
const fetcher = vi.fn(
|
||||
(_input: RequestInfo | URL, init?: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
@@ -482,7 +732,47 @@ describe("service worker static asset install", () => {
|
||||
code: "INSTALL_DEADLINE_EXCEEDED",
|
||||
});
|
||||
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true);
|
||||
expect(deleteCache).toHaveBeenCalledTimes(1);
|
||||
// SW-09. The public result closes at the deadline with one exact delete,
|
||||
// and a second exact delete is registered once the abandoned install work
|
||||
// actually settles. Both target the same owned candidate cache.
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(deleteCache).toHaveBeenCalledTimes(2);
|
||||
expect(new Set(deleteCache.mock.calls.map((call) => call[0])).size).toBe(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("observes and cleans non-cooperative late install work", async () => {
|
||||
vi.useFakeTimers();
|
||||
const deleteCache = vi.fn(async (_name: string) => true);
|
||||
const cancel = vi.fn(async () => {});
|
||||
let releaseFetch: ((response: Response) => void) | undefined;
|
||||
// A fetch that ignores the abort signal entirely.
|
||||
const fetcher = vi.fn(
|
||||
() =>
|
||||
new Promise<Response>((resolve) => {
|
||||
releaseFetch = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const result = installStaticAssets(manifest, {
|
||||
caches: {
|
||||
open: vi.fn(async () => ({ put: vi.fn() }) as unknown as Cache),
|
||||
delete: deleteCache,
|
||||
},
|
||||
fetcher: fetcher as typeof fetch,
|
||||
digest: vi.fn(),
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(SERVICE_WORKER_BOUNDS.installDeadlineMs);
|
||||
await expect(result).resolves.toEqual({
|
||||
kind: "REJECTED",
|
||||
code: "INSTALL_DEADLINE_EXCEEDED",
|
||||
});
|
||||
|
||||
// The late response arrives after the public bound; its body is cancelled
|
||||
// and no unhandled rejection escapes.
|
||||
releaseFetch?.({ body: { cancel } } as unknown as Response);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(cancel).toHaveBeenCalledOnce();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -564,4 +854,203 @@ describe("service worker static asset install", () => {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts replies only from the captured waiting or controller source", async () => {
|
||||
const browser = pageContainer({ waiting: true });
|
||||
const controller = pageController(browser.container);
|
||||
await controller.start();
|
||||
const foreign = { postMessage: vi.fn() };
|
||||
|
||||
// SW-06. A same-origin but unrecognised source must not close admission.
|
||||
browser.dispatch(
|
||||
createServiceWorkerMessage({
|
||||
kind: "CLIENT_DRAIN_REQUEST",
|
||||
sourceBuildId: "worker-build",
|
||||
targetBuildId: "page-build",
|
||||
nonce: "drain-foreign",
|
||||
}),
|
||||
foreign,
|
||||
);
|
||||
expect(foreign.postMessage).not.toHaveBeenCalled();
|
||||
await controller.stop();
|
||||
});
|
||||
|
||||
it("coalesces concurrent activation and reset commands", async () => {
|
||||
const browser = pageContainer({ waiting: true });
|
||||
const controller = pageController(browser.container);
|
||||
await controller.start();
|
||||
|
||||
// SW-06. Ten concurrent callers issue exactly one request.
|
||||
const activations = Array.from({ length: 10 }, () =>
|
||||
controller.requestActivation(),
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(browser.waitingMessages).toHaveLength(1);
|
||||
expect(new Set(activations).size).toBe(1);
|
||||
|
||||
await controller.stop();
|
||||
await Promise.allSettled(activations);
|
||||
});
|
||||
|
||||
it("ends an activation whose reply source was swapped", async () => {
|
||||
const browser = pageContainer({ waiting: true });
|
||||
const controller = pageController(browser.container);
|
||||
await controller.start();
|
||||
const activation = controller.requestActivation();
|
||||
await Promise.resolve();
|
||||
|
||||
const request = browser.waitingMessages.at(-1) as { nonce?: string };
|
||||
// A different worker answers: terminate immediately rather than waiting for
|
||||
// the drain timeout.
|
||||
browser.dispatch(
|
||||
createServiceWorkerMessage({
|
||||
kind: "ACTIVATED_RELOAD_REQUIRED",
|
||||
sourceBuildId: "worker-build",
|
||||
targetBuildId: "page-build",
|
||||
nonce: request.nonce,
|
||||
}),
|
||||
{ postMessage: vi.fn() },
|
||||
);
|
||||
|
||||
await expect(activation).resolves.toEqual({ kind: "PROTOCOL_MISMATCH" });
|
||||
await controller.stop();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* SW-01. The marker read must be bounded in bytes, not only in logic. Adding a
|
||||
* whole chunk and *then* comparing the running total meant a corrupt body could
|
||||
* hand activation a 1 MiB chunk against a 257-byte ceiling, and the reader lock
|
||||
* was never released.
|
||||
*/
|
||||
describe("SW-01 the activation marker read is bounded in bytes", () => {
|
||||
function scopeWithMarkerBody(
|
||||
body: ReadableStream<Uint8Array> | null,
|
||||
headers: Readonly<Record<string, string>> = {},
|
||||
) {
|
||||
const markerUrl = "__service-worker-activation-v1__";
|
||||
const response = body
|
||||
? new Response(body, { status: 200, headers })
|
||||
: new Response("", { status: 200, headers });
|
||||
const cache = {
|
||||
match: vi.fn(async (request: RequestInfo | URL) =>
|
||||
String(request).includes(markerUrl) ? response : undefined,
|
||||
),
|
||||
put: vi.fn(async () => {}),
|
||||
delete: vi.fn(async () => true),
|
||||
} as unknown as Cache;
|
||||
return {
|
||||
response,
|
||||
scope: {
|
||||
caches: {
|
||||
open: vi.fn(async () => cache),
|
||||
// The current static cache must exist for its marker to be read.
|
||||
keys: vi.fn(async () => [
|
||||
staticCacheName(identity.staticAssetSetDigest),
|
||||
]),
|
||||
delete: vi.fn(async () => true),
|
||||
match: vi.fn(),
|
||||
},
|
||||
clients: { matchAll: vi.fn(async () => []) },
|
||||
skipWaiting: vi.fn(async () => {}),
|
||||
fetcher: vi.fn(),
|
||||
digest: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const runtimeFor = (scope: unknown) =>
|
||||
createServiceWorkerRuntime(scope as never, {
|
||||
identity,
|
||||
handlers: ["PWA_STATIC_ASSETS"],
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
buildId: identity.buildId,
|
||||
releaseId: identity.releaseId,
|
||||
setDigest: identity.staticAssetSetDigest as `sha256:${string}`,
|
||||
assets: [],
|
||||
},
|
||||
runtimeConfigUrl: "/runtime-config.json",
|
||||
releaseManifestUrl: "/release-manifest.json",
|
||||
});
|
||||
|
||||
it("never retains a single chunk larger than the marker ceiling", async () => {
|
||||
let delivered = 0;
|
||||
let cancels = 0;
|
||||
const oversized = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
delivered += 1;
|
||||
controller.enqueue(new Uint8Array(1_048_576));
|
||||
},
|
||||
cancel() {
|
||||
cancels += 1;
|
||||
},
|
||||
});
|
||||
const fixture = scopeWithMarkerBody(oversized);
|
||||
|
||||
// Activation still completes; the marker is simply not admitted.
|
||||
await expect(runtimeFor(fixture.scope).onActivate()).resolves.toBeTypeOf(
|
||||
"number",
|
||||
);
|
||||
// Every oversized chunk that arrived was refused before being retained,
|
||||
// and the reader that saw it was cancelled. The marker is probed once per
|
||||
// candidate cache, so the counts track each other rather than a constant.
|
||||
expect(cancels).toBeGreaterThanOrEqual(1);
|
||||
expect(delivered).toBeLessThanOrEqual(2);
|
||||
expect(fixture.response.body?.locked).toBe(false);
|
||||
});
|
||||
|
||||
it("bounds a stream that never produces a chunk", async () => {
|
||||
let cancels = 0;
|
||||
const stalled = new ReadableStream<Uint8Array>({
|
||||
pull() {
|
||||
return new Promise<void>(() => {});
|
||||
},
|
||||
cancel() {
|
||||
cancels += 1;
|
||||
},
|
||||
});
|
||||
const fixture = scopeWithMarkerBody(stalled);
|
||||
|
||||
await expect(
|
||||
runtimeFor(fixture.scope).onActivate(),
|
||||
).resolves.toBeTypeOf("number");
|
||||
expect(cancels).toBeGreaterThanOrEqual(1);
|
||||
}, 10_000);
|
||||
|
||||
it("cancels the body it refuses for a declared oversize", async () => {
|
||||
let cancels = 0;
|
||||
const declared = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
controller.enqueue(new Uint8Array(8));
|
||||
},
|
||||
cancel() {
|
||||
cancels += 1;
|
||||
},
|
||||
});
|
||||
const fixture = scopeWithMarkerBody(declared, {
|
||||
"content-length": "1048576",
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtimeFor(fixture.scope).onActivate(),
|
||||
).resolves.toBeTypeOf("number");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(cancels).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("refuses a marker body that is not valid UTF-8", async () => {
|
||||
const invalid = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([0xff, 0xfe, 0xfd]));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const fixture = scopeWithMarkerBody(invalid);
|
||||
|
||||
await expect(
|
||||
runtimeFor(fixture.scope).onActivate(),
|
||||
).resolves.toBeTypeOf("number");
|
||||
expect(fixture.response.body?.locked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
CACHEABLE_ASSET_CONTENT_TYPES,
|
||||
decodeStaticAssetManifest,
|
||||
isCanonicalStaticAssetUrl,
|
||||
} from "../../src/contracts/service-worker-static-manifest.ts";
|
||||
|
||||
/**
|
||||
* SW-RR-03. The build generator and the shared runtime decoder must agree on
|
||||
* exactly which asset kinds exist. A generator that emits `.mjs` or `.png` while
|
||||
* the decoder refuses them turns a correct build into a runtime contract
|
||||
* failure, and the reverse admits a kind no build produces.
|
||||
*/
|
||||
describe("SW-RR-03 one authoritative cacheable asset table", () => {
|
||||
it("covers every extension the generator emits", async () => {
|
||||
const generator = await import(
|
||||
"../../scripts/generate-service-worker-assets.ts"
|
||||
);
|
||||
expect(generator).toBeDefined();
|
||||
for (const extension of [
|
||||
".js",
|
||||
".mjs",
|
||||
".css",
|
||||
".woff2",
|
||||
".svg",
|
||||
".png",
|
||||
".webp",
|
||||
]) {
|
||||
expect(CACHEABLE_ASSET_CONTENT_TYPES[extension]).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("decodes a manifest row for every table entry", () => {
|
||||
const assets = Object.entries(CACHEABLE_ASSET_CONTENT_TYPES).map(
|
||||
([extension, contentType], index) => ({
|
||||
url: `/assets/name-abcdefgh${index}${extension}`,
|
||||
sha256: `sha256:${"a".repeat(64)}`,
|
||||
bytes: 16,
|
||||
contentType,
|
||||
}),
|
||||
);
|
||||
const decoded = decodeStaticAssetManifest({
|
||||
schemaVersion: 1,
|
||||
buildId: "build-1",
|
||||
releaseId: "release-1",
|
||||
assets,
|
||||
setDigest: `sha256:${"b".repeat(64)}`,
|
||||
});
|
||||
expect(decoded).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("refuses a row whose extension is not in the table", () => {
|
||||
const decoded = decodeStaticAssetManifest({
|
||||
schemaVersion: 1,
|
||||
buildId: "build-1",
|
||||
releaseId: "release-1",
|
||||
setDigest: `sha256:${"b".repeat(64)}`,
|
||||
assets: [
|
||||
{
|
||||
url: "/assets/control-abcdefgh.json",
|
||||
sha256: `sha256:${"a".repeat(64)}`,
|
||||
bytes: 16,
|
||||
contentType: "application/json",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(decoded.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* SW-02. Sharing only the extension table left the generator and the decoder
|
||||
* with different path grammars: the generator emitted a URL for a directory
|
||||
* containing an `@` or a space, and the decoder then refused the manifest it
|
||||
* had just produced, failing the release build at install time.
|
||||
*/
|
||||
describe("SW-02 the generator and the decoder share one path grammar", () => {
|
||||
async function distWith(
|
||||
files: Readonly<Record<string, string>>,
|
||||
): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "sw-assets-"));
|
||||
for (const [relative, content] of Object.entries(files)) {
|
||||
const absolute = path.join(root, relative);
|
||||
await mkdir(path.dirname(absolute), { recursive: true });
|
||||
await writeFile(absolute, content);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
it("emits a manifest the runtime decoder accepts for every table entry", async () => {
|
||||
const { collectStaticAssets } = await import(
|
||||
"../../scripts/generate-service-worker-assets.ts"
|
||||
);
|
||||
const files: Record<string, string> = {};
|
||||
for (const [index, extension] of Object.keys(
|
||||
CACHEABLE_ASSET_CONTENT_TYPES,
|
||||
).entries()) {
|
||||
files[`assets/name-abcdefg${index}${extension}`] = `content-${index}`;
|
||||
}
|
||||
files["assets/nested/deep/name-abcdefgz.js"] = "nested";
|
||||
const root = await distWith(files);
|
||||
|
||||
const manifest = await collectStaticAssets(root, "build-1", "release-1");
|
||||
|
||||
expect(manifest.assets.length).toBe(
|
||||
Object.keys(CACHEABLE_ASSET_CONTENT_TYPES).length + 1,
|
||||
);
|
||||
expect(decodeStaticAssetManifest(manifest)).toMatchObject({ ok: true });
|
||||
for (const asset of manifest.assets) {
|
||||
expect(isCanonicalStaticAssetUrl(asset.url)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "an at sign", name: "bad@name-abcdefgh.js" },
|
||||
{ label: "a space", name: "bad name-abcdefgh.js" },
|
||||
{ label: "a percent escape", name: "bad%20name-abcdefgh.js" },
|
||||
{ label: "a hash", name: "bad#name-abcdefgh.js" },
|
||||
])(
|
||||
"stops the build rather than emitting a path the decoder refuses ($label)",
|
||||
async ({ name }) => {
|
||||
const { collectStaticAssets } = await import(
|
||||
"../../scripts/generate-service-worker-assets.ts"
|
||||
);
|
||||
const root = await distWith({
|
||||
"assets/good-abcdefgh.js": "ok",
|
||||
[`assets/${name}`]: "bad",
|
||||
});
|
||||
|
||||
await expect(
|
||||
collectStaticAssets(root, "build-1", "release-1"),
|
||||
).rejects.toThrow(/not canonical/u);
|
||||
},
|
||||
);
|
||||
|
||||
it("agrees with the decoder on the whole path policy table", () => {
|
||||
const cases: readonly (readonly [string, boolean])[] = [
|
||||
["/assets/name-abcdefgh.js", true],
|
||||
["/assets/nested/name-abcdefgh.js", true],
|
||||
["/assets/bad@name-abcdefgh.js", false],
|
||||
["/assets/bad name-abcdefgh.js", false],
|
||||
["/assets/bad%20name-abcdefgh.js", false],
|
||||
["/assets/bad#name-abcdefgh.js", false],
|
||||
["/assets/../name-abcdefgh.js", false],
|
||||
["/assets/./name-abcdefgh.js", false],
|
||||
["assets/name-abcdefgh.js", false],
|
||||
["/assets/\\name-abcdefgh.js", false],
|
||||
["/assets/naïve-abcdefgh.js", false],
|
||||
];
|
||||
for (const [url, canonical] of cases) {
|
||||
expect(isCanonicalStaticAssetUrl(url), url).toBe(canonical);
|
||||
const decoded = decodeStaticAssetManifest({
|
||||
schemaVersion: 1,
|
||||
buildId: "build-1",
|
||||
releaseId: "release-1",
|
||||
setDigest: `sha256:${"a".repeat(64)}`,
|
||||
assets: [
|
||||
{
|
||||
url,
|
||||
sha256: `sha256:${"a".repeat(64)}`,
|
||||
bytes: 4,
|
||||
contentType: "text/javascript",
|
||||
},
|
||||
],
|
||||
});
|
||||
// The decoder still owns the digest check, so a canonical path may fail
|
||||
// for other reasons; a non-canonical one must always fail.
|
||||
if (!canonical) expect(decoded.ok, url).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createDiagnosticsAdapter } from "../../src/adapters/diagnostics/bounded-diagnostics.ts";
|
||||
import {
|
||||
createTelemetryAdapter,
|
||||
safeTraceparent,
|
||||
@@ -236,4 +237,122 @@ describe("best-effort telemetry adapter", () => {
|
||||
expect(adapter.pendingCount()).toBe(0);
|
||||
expect(remove).toHaveBeenCalledWith("pagehide", expect.any(Function));
|
||||
});
|
||||
|
||||
it("drops queued events and invalidates scheduled callbacks on dispose", async () => {
|
||||
const callbacks: Array<() => void> = [];
|
||||
const fetcher = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
const adapter = createTelemetryAdapter({
|
||||
enabled: true,
|
||||
endpoint: "https://telemetry.test/events",
|
||||
schedule: (callback) => callbacks.push(callback),
|
||||
fetcher,
|
||||
});
|
||||
adapter.emit("api.request.failed", validAttributes);
|
||||
expect(adapter.pendingCount()).toBe(1);
|
||||
|
||||
adapter.dispose();
|
||||
expect(adapter.pendingCount()).toBe(0);
|
||||
|
||||
for (const callback of callbacks) callback();
|
||||
await Promise.resolve();
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
// Disposal is a silent shutdown, not a recursive drop event.
|
||||
expect(adapter.dropReasons()).toEqual({});
|
||||
});
|
||||
|
||||
it("ignores emit after dispose", async () => {
|
||||
const fetcher = vi.fn(async () => new Response(null, { status: 204 }));
|
||||
const adapter = createTelemetryAdapter({
|
||||
enabled: true,
|
||||
endpoint: "https://telemetry.test/events",
|
||||
schedule: () => {},
|
||||
fetcher,
|
||||
});
|
||||
adapter.dispose();
|
||||
adapter.emit("api.request.failed", validAttributes);
|
||||
await adapter.flush();
|
||||
|
||||
expect(adapter.pendingCount()).toBe(0);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aborts an in-flight sink and prevents post-dispose rescheduling", async () => {
|
||||
const callbacks: Array<() => void> = [];
|
||||
let observedSignal: AbortSignal | undefined;
|
||||
let releaseSink: (() => void) | undefined;
|
||||
const fetcher = vi.fn(async (_input: unknown, init?: RequestInit) => {
|
||||
observedSignal = init?.signal ?? undefined;
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseSink = resolve;
|
||||
});
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
const adapter = createTelemetryAdapter({
|
||||
enabled: true,
|
||||
endpoint: "https://telemetry.test/events",
|
||||
schedule: (callback) => callbacks.push(callback),
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
});
|
||||
adapter.emit("api.request.failed", validAttributes);
|
||||
callbacks.splice(0).forEach((callback) => callback());
|
||||
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce());
|
||||
|
||||
adapter.dispose();
|
||||
expect(observedSignal?.aborted).toBe(true);
|
||||
|
||||
// The sink ignored the abort and settles late.
|
||||
releaseSink?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(callbacks).toHaveLength(0);
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("joins an already active flush", async () => {
|
||||
let releaseSink: (() => void) | undefined;
|
||||
const fetcher = vi.fn(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseSink = resolve;
|
||||
});
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
const adapter = createTelemetryAdapter({
|
||||
enabled: true,
|
||||
endpoint: "https://telemetry.test/events",
|
||||
schedule: () => {},
|
||||
fetcher: fetcher as unknown as typeof fetch,
|
||||
});
|
||||
adapter.emit("api.request.failed", validAttributes);
|
||||
|
||||
const first = adapter.flush();
|
||||
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce());
|
||||
let secondSettled = false;
|
||||
const second = adapter.flush().then(() => {
|
||||
secondSettled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(secondSettled).toBe(false);
|
||||
|
||||
releaseSink?.();
|
||||
await first;
|
||||
await second;
|
||||
expect(secondSettled).toBe(true);
|
||||
adapter.dispose();
|
||||
});
|
||||
|
||||
it.each([Number.NaN, Number.POSITIVE_INFINITY, 0, -1, 1.5])(
|
||||
"rejects invalid telemetry and diagnostics capacity %s",
|
||||
(value) => {
|
||||
expect(() =>
|
||||
createTelemetryAdapter({
|
||||
enabled: true,
|
||||
endpoint: "https://telemetry.test/events",
|
||||
maxQueue: value,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(() => createDiagnosticsAdapter({ maxEntries: value })).toThrow(
|
||||
TypeError,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -42,6 +42,37 @@ function manualScheduler() {
|
||||
}
|
||||
|
||||
describe("Web Push durable control fence", () => {
|
||||
it.each([0, 2, 3, 9_999])(
|
||||
"rejects a CAS receipt that is not the exact next revision (%i)",
|
||||
async (revision) => {
|
||||
// WP-01. Only the exact next revision is evidence that this command
|
||||
// actually wrote the control it claims to have written.
|
||||
const dependencies = createFakePushControlStoreDependencies();
|
||||
const repository = dependencies.repository;
|
||||
const compareAndSwap = repository.compareAndSwap.bind(repository);
|
||||
repository.compareAndSwap = async (input) => {
|
||||
const written = await compareAndSwap(input);
|
||||
return written.ok
|
||||
? {
|
||||
ok: true as const,
|
||||
value: { ...written.value, revision },
|
||||
}
|
||||
: written;
|
||||
};
|
||||
const store = createPushAssociationFenceStore(dependencies);
|
||||
|
||||
await expect(
|
||||
store.prepare({
|
||||
authority: firstAuthority,
|
||||
updatedAt: "2026-07-28T00:00:00.000Z",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "CONTROL_CORRUPT" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("CASes UNASSOCIATED to ACTIVE and prevents tombstone resurrection", async () => {
|
||||
const store = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
|
||||
@@ -445,6 +445,8 @@ describe("Web Push worker runtime", () => {
|
||||
event: "web_push_subscription_rotated",
|
||||
outcome: "DEGRADED",
|
||||
reason: "DEADLINE_EXCEEDED",
|
||||
countBucket: expect.any(String),
|
||||
truncated: expect.any(Boolean),
|
||||
});
|
||||
runtime.dispose();
|
||||
});
|
||||
@@ -489,6 +491,8 @@ describe("Web Push worker runtime", () => {
|
||||
event: "web_push_subscription_rotated",
|
||||
outcome: "DEGRADED",
|
||||
reason: "ABORTED",
|
||||
countBucket: expect.any(String),
|
||||
truncated: expect.any(Boolean),
|
||||
});
|
||||
|
||||
const throwingStore = await activeFence();
|
||||
@@ -540,6 +544,8 @@ describe("Web Push worker runtime", () => {
|
||||
event: "web_push_subscription_rotated",
|
||||
outcome: "DEGRADED",
|
||||
reason: "ABORTED",
|
||||
countBucket: expect.any(String),
|
||||
truncated: expect.any(Boolean),
|
||||
});
|
||||
throwingRuntime.dispose();
|
||||
});
|
||||
@@ -615,3 +621,210 @@ describe("Web Push worker runtime", () => {
|
||||
expect(listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* WP-01. One click is one terminal record. The adapter emitted the terminal
|
||||
* event from inside `process` *and* again from the `waitUntil` wrapper, so an
|
||||
* ordinary click was counted twice. A late rejection also downgraded the
|
||||
* certainty from `MAYBE_APPLIED` to `NOT_APPLIED`, telling operators the click
|
||||
* had definitely not been applied when nobody knew that, and the late
|
||||
* observation ran outside `waitUntil`, so a worker shutdown lost the evidence.
|
||||
*/
|
||||
describe("WP-01 the click handler has one observation authority", () => {
|
||||
type Observation = Readonly<{
|
||||
event: string;
|
||||
outcome: string;
|
||||
reason?: string;
|
||||
nativeEffect?: string;
|
||||
}>;
|
||||
|
||||
async function clickAdapterWith(
|
||||
clients: Readonly<{
|
||||
matchControlledWindowClients(): Promise<readonly unknown[]>;
|
||||
openWindow(target: string): Promise<unknown>;
|
||||
}>,
|
||||
scheduler?: TimeoutScheduler,
|
||||
) {
|
||||
const store = await activeFence();
|
||||
const observations: Observation[] = [];
|
||||
const adapter = createNotificationClickAdapter({
|
||||
fenceStore: store,
|
||||
registry,
|
||||
origin: "https://app.example.test",
|
||||
now: () => now,
|
||||
clients: clients as never,
|
||||
observer: {
|
||||
record(observation) {
|
||||
observations.push(observation as Observation);
|
||||
},
|
||||
},
|
||||
...(scheduler ? { scheduler } : {}),
|
||||
});
|
||||
return { adapter, observations };
|
||||
}
|
||||
|
||||
const dispatched = (observations: readonly Observation[]) =>
|
||||
observations.filter(
|
||||
(observation) => observation.event === "web_push_click_dispatched",
|
||||
);
|
||||
|
||||
it("records exactly one terminal event for an ordinary focus", async () => {
|
||||
const focus = vi.fn(async () => {});
|
||||
const { adapter, observations } = await clickAdapterWith({
|
||||
async matchControlledWindowClients() {
|
||||
return [
|
||||
{
|
||||
url: "https://app.example.test/current",
|
||||
focus,
|
||||
postMessage() {},
|
||||
},
|
||||
];
|
||||
},
|
||||
openWindow: vi.fn(async () => null),
|
||||
});
|
||||
let waited: Promise<void> | null = null;
|
||||
const result = await adapter.handle({
|
||||
notification: { data: clickData(), close() {} },
|
||||
waitUntil(task) {
|
||||
waited = task;
|
||||
},
|
||||
});
|
||||
await waited;
|
||||
|
||||
expect(result).toEqual({ ok: true, value: undefined });
|
||||
expect(dispatched(observations)).toEqual([
|
||||
{
|
||||
event: "web_push_click_dispatched",
|
||||
outcome: "SUCCEEDED",
|
||||
nativeEffect: "CONFIRMED",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("confirms NOT_APPLIED only for an explicit null window", async () => {
|
||||
const { adapter, observations } = await clickAdapterWith({
|
||||
async matchControlledWindowClients() {
|
||||
return [];
|
||||
},
|
||||
openWindow: vi.fn(async () => null),
|
||||
});
|
||||
let waited: Promise<void> | null = null;
|
||||
const result = await adapter.handle({
|
||||
notification: { data: clickData(), close() {} },
|
||||
waitUntil(task) {
|
||||
waited = task;
|
||||
},
|
||||
});
|
||||
await waited;
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(dispatched(observations)).toEqual([
|
||||
expect.objectContaining({
|
||||
outcome: "FAILED",
|
||||
nativeEffect: "NOT_APPLIED",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps MAYBE_APPLIED when the native effect rejects after the deadline", async () => {
|
||||
const clock = manualScheduler();
|
||||
let rejectFocus: ((reason: unknown) => void) | undefined;
|
||||
const { adapter, observations } = await clickAdapterWith(
|
||||
{
|
||||
async matchControlledWindowClients() {
|
||||
return [
|
||||
{
|
||||
url: "https://app.example.test/current",
|
||||
focus: () =>
|
||||
new Promise<void>((_resolve, reject) => {
|
||||
rejectFocus = reject;
|
||||
}),
|
||||
postMessage() {},
|
||||
},
|
||||
];
|
||||
},
|
||||
openWindow: vi.fn(async () => null),
|
||||
},
|
||||
clock.scheduler,
|
||||
);
|
||||
let waited: Promise<void> | null = null;
|
||||
const handling = adapter.handle({
|
||||
notification: { data: clickData(), close() {} },
|
||||
waitUntil(task) {
|
||||
waited = task;
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(rejectFocus).toBeDefined());
|
||||
clock.expireAll();
|
||||
const result = await handling;
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
// The effect lands only now, after the terminal result.
|
||||
rejectFocus?.(new Error("focus failed late"));
|
||||
await waited;
|
||||
|
||||
const records = dispatched(observations);
|
||||
expect(records).toHaveLength(2);
|
||||
// The evidence record never claims the click was definitely not applied.
|
||||
expect(records.at(-1)).toEqual({
|
||||
event: "web_push_click_dispatched",
|
||||
outcome: "DEGRADED",
|
||||
reason: "ABORTED",
|
||||
nativeEffect: "MAYBE_APPLIED",
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the late effect evidence inside waitUntil", async () => {
|
||||
const clock = manualScheduler();
|
||||
let resolveFocus: (() => void) | undefined;
|
||||
const { adapter, observations } = await clickAdapterWith(
|
||||
{
|
||||
async matchControlledWindowClients() {
|
||||
return [
|
||||
{
|
||||
url: "https://app.example.test/current",
|
||||
focus: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveFocus = resolve;
|
||||
}),
|
||||
postMessage() {},
|
||||
},
|
||||
];
|
||||
},
|
||||
openWindow: vi.fn(async () => null),
|
||||
},
|
||||
clock.scheduler,
|
||||
);
|
||||
let waited: Promise<void> | null = null;
|
||||
const handling = adapter.handle({
|
||||
notification: { data: clickData(), close() {} },
|
||||
waitUntil(task) {
|
||||
waited = task;
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(resolveFocus).toBeDefined());
|
||||
clock.expireAll();
|
||||
await handling;
|
||||
|
||||
let waitUntilSettled = false;
|
||||
const pendingWait = waited as Promise<void> | null;
|
||||
void pendingWait?.then(() => {
|
||||
waitUntilSettled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
// The handler's lifetime is still open because the effect has not landed.
|
||||
expect(waitUntilSettled).toBe(false);
|
||||
|
||||
resolveFocus?.();
|
||||
await waited;
|
||||
expect(
|
||||
dispatched(observations).at(-1),
|
||||
).toEqual({
|
||||
event: "web_push_click_dispatched",
|
||||
outcome: "DEGRADED",
|
||||
reason: "ABORTED",
|
||||
nativeEffect: "CONFIRMED",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user