feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
composeApiOperations,
|
||||
defineRestOperation,
|
||||
} from "../../src/contracts/api-operations.ts";
|
||||
|
||||
function operation(operationId = "GET_RESOURCE") {
|
||||
return defineRestOperation({
|
||||
contractVersion: 2,
|
||||
protocol: "REST",
|
||||
semantics: "QUERY",
|
||||
method: "GET",
|
||||
path: "/resources/{resourceId}",
|
||||
operationId,
|
||||
auth: "external-session",
|
||||
timeoutMs: 5_000,
|
||||
idempotency: "safe",
|
||||
retry: "runtime",
|
||||
replayPolicy: "SAFE",
|
||||
idempotencyKeyPolicy: "NONE",
|
||||
requestSource: "none",
|
||||
requestSchema: "NoRequest",
|
||||
responseSchema: "ResourcePayload",
|
||||
mapperId: "ResourceMapper",
|
||||
successStatuses: [200],
|
||||
responseMediaTypes: ["application/json"],
|
||||
maxResponseBytes: 32_768,
|
||||
providerId: "PRIMARY_API",
|
||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||
csrfProfileId: "NO_CSRF_BEARER",
|
||||
pathSchema: "ResourceParams",
|
||||
pathParameterNames: ["resourceId"],
|
||||
maxEncodedSearchBytes: 0,
|
||||
owner: "test",
|
||||
});
|
||||
}
|
||||
|
||||
describe("REST operation v2 registry", () => {
|
||||
it("rejects duplicate contributions before an object spread can overwrite them", () => {
|
||||
const selected = operation();
|
||||
expect(() =>
|
||||
composeApiOperations([
|
||||
{ GET_RESOURCE: selected },
|
||||
{ GET_RESOURCE: selected },
|
||||
]),
|
||||
).toThrow("Duplicate API operation");
|
||||
});
|
||||
|
||||
it("rejects an unsafe query/replay policy combination", () => {
|
||||
expect(() =>
|
||||
defineRestOperation({
|
||||
...operation(),
|
||||
method: "POST",
|
||||
}),
|
||||
).toThrow("Incoherent REST replay contract");
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,16 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createApplication,
|
||||
type ApplicationOutputPorts,
|
||||
} from "../../src/application/create-application.js";
|
||||
import { createTestApplication } from "../helpers/create-test-application.js";
|
||||
} from "../../src/application/create-application.ts";
|
||||
import { createTestApplication } from "../helpers/create-test-application.ts";
|
||||
|
||||
declare module "../../src/application/ports/in/application-api.ts" {
|
||||
interface ApplicationFeatureInputs {
|
||||
"test-feature": Readonly<{
|
||||
run(): "ok";
|
||||
}>;
|
||||
}
|
||||
}
|
||||
|
||||
describe("application input/output boundary", () => {
|
||||
it("exposes intent-oriented input APIs without leaking output ports", async () => {
|
||||
@@ -22,9 +30,11 @@ describe("application input/output boundary", () => {
|
||||
expect(application).not.toHaveProperty("telemetry");
|
||||
expect(application).not.toHaveProperty("releaseInfo");
|
||||
expect(application.features.has("not-installed")).toBe(false);
|
||||
expect(() => application.features.get("not-installed")).toThrow(
|
||||
"Application feature is not installed",
|
||||
);
|
||||
expect(() =>
|
||||
Reflect.apply(application.features.get, application.features, [
|
||||
"not-installed",
|
||||
]),
|
||||
).toThrow("Application feature is not installed");
|
||||
await expect(application.runtime.getReleaseSummary()).resolves.toEqual({
|
||||
buildId: "test-build",
|
||||
releaseId: "test-release",
|
||||
@@ -33,17 +43,34 @@ describe("application input/output boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses fake output ports for preference, session, and safe diagnostics flows", () => {
|
||||
it("returns a typed input for an installed generic feature", () => {
|
||||
const featureInput = Object.freeze({
|
||||
run: () => "ok" as const,
|
||||
});
|
||||
const application = createTestApplication({
|
||||
featureInputs: { "test-feature": featureInput },
|
||||
});
|
||||
|
||||
expect(application.features.has("test-feature")).toBe(true);
|
||||
expect(application.features.get("test-feature")).toBe(featureInput);
|
||||
expect(application.features.get("test-feature").run()).toBe("ok");
|
||||
});
|
||||
|
||||
it("uses fake output ports for preference, session, and safe diagnostics flows", async () => {
|
||||
const write = vi.fn(() => ({ ok: true as const }));
|
||||
const emit = vi.fn();
|
||||
const record = vi.fn();
|
||||
const subscribe = vi.fn(() => () => {});
|
||||
const beginSignIn = vi.fn(async () => {});
|
||||
const signOut = vi.fn(async () => {});
|
||||
const recover = vi.fn(async () => "restored" as const);
|
||||
const ports = {
|
||||
session: {
|
||||
getState: () => "authenticated" as const,
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
recover: async () => "restored" as const,
|
||||
subscribe,
|
||||
beginSignIn,
|
||||
signOut,
|
||||
recover,
|
||||
},
|
||||
preferences: {
|
||||
read: () => ({ ok: true as const, value: "dark" }),
|
||||
@@ -75,6 +102,14 @@ describe("application input/output boundary", () => {
|
||||
const application = createApplication(ports);
|
||||
|
||||
expect(application.session.getSnapshot()).toBe("authenticated");
|
||||
const listener = vi.fn();
|
||||
expect(application.session.subscribe(listener)).toBeTypeOf("function");
|
||||
expect(subscribe).toHaveBeenCalledWith(listener);
|
||||
await application.session.beginSignIn("/return");
|
||||
expect(beginSignIn).toHaveBeenCalledWith("/return");
|
||||
await application.session.signOut();
|
||||
expect(signOut).toHaveBeenCalledOnce();
|
||||
await expect(application.session.recover()).resolves.toBe("restored");
|
||||
expect(application.preferences.getColorScheme()).toBe("dark");
|
||||
expect(application.preferences.setColorScheme("light")).toEqual({ ok: true });
|
||||
expect(write).toHaveBeenCalledWith("COLOR_SCHEME", "light");
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
createAnonymousSessionAdapter,
|
||||
createDemoSessionAdapter,
|
||||
createExternalAuthSessionAdapter,
|
||||
} from "../../src/adapters/auth/external-session-adapter.js";
|
||||
} from "../../src/adapters/auth/external-session-adapter.ts";
|
||||
|
||||
describe("external AuthSessionPort adapter", () => {
|
||||
it("attaches opaque credentials without exposing a token-shaped session", async () => {
|
||||
@@ -13,17 +13,19 @@ describe("external AuthSessionPort adapter", () => {
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
attachCredential: async (request) => {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set("X-Session-Attached", "true");
|
||||
return new Request(request, { headers });
|
||||
},
|
||||
attachCredential: async () => ({
|
||||
headers: { Authorization: "Bearer opaque" },
|
||||
}),
|
||||
recoverSession: async () => "restored",
|
||||
notifyUnauthenticated: vi.fn(),
|
||||
});
|
||||
|
||||
const request = await adapter.attach(new Request("https://api.test/resource"));
|
||||
expect(request.headers.get("X-Session-Attached")).toBe("true");
|
||||
const patch = await adapter.credentialPatch({
|
||||
origin: "https://api.test",
|
||||
method: "GET",
|
||||
operationId: "GET_RESOURCE",
|
||||
});
|
||||
expect(patch.headers.authorization).toBe("Bearer opaque");
|
||||
expect(adapter.getState()).toBe("authenticated");
|
||||
expect(adapter).not.toHaveProperty("accessToken");
|
||||
expect(adapter).not.toHaveProperty("refreshToken");
|
||||
@@ -35,7 +37,7 @@ describe("external AuthSessionPort adapter", () => {
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
attachCredential: async (request) => request,
|
||||
attachCredential: async () => ({ headers: {} }),
|
||||
// @ts-expect-error Deliberately violates the external-owner contract.
|
||||
recoverSession: async () => "unexpected",
|
||||
notifyUnauthenticated: vi.fn(),
|
||||
@@ -44,6 +46,28 @@ describe("external AuthSessionPort adapter", () => {
|
||||
await expect(adapter.recover()).rejects.toThrow("invalid recovery state");
|
||||
});
|
||||
|
||||
it("rejects credential patches that can alter transport-owned headers", async () => {
|
||||
const adapter = createExternalAuthSessionAdapter({
|
||||
readState: () => "authenticated",
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
attachCredential: async () => ({
|
||||
headers: { Host: "attacker.test" },
|
||||
}),
|
||||
recoverSession: async () => "restored",
|
||||
notifyUnauthenticated: vi.fn(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
adapter.credentialPatch({
|
||||
origin: "https://api.test",
|
||||
method: "GET",
|
||||
operationId: "GET_RESOURCE",
|
||||
}),
|
||||
).rejects.toThrow("forbidden credential patch");
|
||||
});
|
||||
|
||||
it("provides a safe anonymous adapter", async () => {
|
||||
const adapter = createAnonymousSessionAdapter();
|
||||
expect(adapter.getState()).toBe("unauthenticated");
|
||||
@@ -1,11 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mapOperationPayload } from "../../src/adapters/http/resource-mapper.js";
|
||||
|
||||
describe("platform DTO mapper boundary", () => {
|
||||
it("fails closed when no feature mapper was injected", () => {
|
||||
expect(() => mapOperationPayload("UNKNOWN", {})).toThrow(
|
||||
"No boundary mapper registered",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { mapOperationPayload } from "../../src/adapters/http/resource-mapper.ts";
|
||||
import {
|
||||
composeBoundaryMapperRegistry,
|
||||
mappingSuccess,
|
||||
} from "../../src/contracts/boundary-mapper.ts";
|
||||
|
||||
describe("platform DTO mapper boundary", () => {
|
||||
it("fails closed when no feature mapper was injected", () => {
|
||||
expect(mapOperationPayload("UNKNOWN", {})).toEqual({
|
||||
ok: false,
|
||||
code: "MAPPING_INVARIANT_REJECTED",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects duplicate mapper contributions before installation", () => {
|
||||
const mapper = {
|
||||
mapperId: "ResourceMapper",
|
||||
mapperVersion: 1,
|
||||
inputSchemaId: "ResourcePayload",
|
||||
outputContractId: "Resource",
|
||||
owner: "test",
|
||||
maxOutputItems: 1,
|
||||
map: (input: unknown) => mappingSuccess(input),
|
||||
};
|
||||
expect(() =>
|
||||
composeBoundaryMapperRegistry([
|
||||
{ ResourceMapper: mapper },
|
||||
{ ResourceMapper: mapper },
|
||||
]),
|
||||
).toThrow("duplicate boundary mapper");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,758 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
FileVerificationReceipt,
|
||||
LocalFileRef,
|
||||
} from "../../src/application/ports/browser-file-storage/file.ts";
|
||||
import { BrowserFileVault } from "../../src/adapters/browser-files/browser-file-vault.ts";
|
||||
import {
|
||||
BrowserTransientPreview,
|
||||
ObjectUrlLeaseRegistry,
|
||||
} from "../../src/adapters/browser-files/object-url-lease.ts";
|
||||
import {
|
||||
sanitizeSuggestedFileName,
|
||||
type RegisteredFileInspectionPolicy,
|
||||
type RegisteredFileSelectionPolicy,
|
||||
} from "../../src/adapters/browser-files/file-policy.ts";
|
||||
import {
|
||||
BrowserFilePolicyRegistry,
|
||||
browserFilePolicyReference,
|
||||
} from "../../src/adapters/browser-files/browser-file-policy-registry.ts";
|
||||
|
||||
const pngSelectionPolicy: RegisteredFileSelectionPolicy = Object.freeze({
|
||||
policyId: "avatar-v1",
|
||||
purpose: "avatar",
|
||||
classification: "PERSONAL",
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxFileBytes: 1_024,
|
||||
maxTotalBytes: 1_024,
|
||||
allowEmpty: false,
|
||||
accept: Object.freeze([
|
||||
Object.freeze({
|
||||
mediaType: "image/png",
|
||||
extensions: Object.freeze([".png"]),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const pngInspectionPolicy: RegisteredFileInspectionPolicy = Object.freeze({
|
||||
policyId: "avatar-v1",
|
||||
maxInspectionBytes: 16,
|
||||
acceptedSignatures: Object.freeze([
|
||||
Object.freeze({
|
||||
mediaType: "image/png",
|
||||
extensions: Object.freeze([".png"]),
|
||||
patterns: Object.freeze([
|
||||
Object.freeze({
|
||||
offset: 0,
|
||||
bytes: Object.freeze([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
const pngPolicy = browserFilePolicyReference(
|
||||
"avatar",
|
||||
"inspect-and-preview-png",
|
||||
);
|
||||
|
||||
function pngPolicies(
|
||||
maxPreviewBytes = 10,
|
||||
): BrowserFilePolicyRegistry {
|
||||
return new BrowserFilePolicyRegistry({
|
||||
profiles: [
|
||||
{
|
||||
reference: pngPolicy,
|
||||
inspection: pngInspectionPolicy,
|
||||
preview: {
|
||||
allowedMediaTypes: ["image/png"],
|
||||
maxPreviewBytes,
|
||||
},
|
||||
},
|
||||
],
|
||||
hardLimits: {
|
||||
maxInspectionBytes: 64 * 1024,
|
||||
maxRetainedFileBytes: 2_048,
|
||||
maxPreviewBytes,
|
||||
maxObjectUrlBytes: 2_048,
|
||||
maxTransferBytes: 2_048,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("browser file content policy", () => {
|
||||
it("neutralizes path, bidi, device-name and executable filename tricks", () => {
|
||||
expect(
|
||||
sanitizeSuggestedFileName("..\\CON\u202Egpj.exe", {
|
||||
safeExtension: ".pdf",
|
||||
}),
|
||||
).toBe("CONgpj.pdf");
|
||||
expect(
|
||||
sanitizeSuggestedFileName("invoice.exe.pdf", {
|
||||
safeExtension: ".pdf",
|
||||
}),
|
||||
).toBe("invoice_exe.pdf");
|
||||
expect(
|
||||
sanitizeSuggestedFileName("CON.txt", {
|
||||
safeExtension: ".txt",
|
||||
}),
|
||||
).toBe("download.txt");
|
||||
expect(
|
||||
sanitizeSuggestedFileName("report.tar.gz", {
|
||||
safeExtension: ".tar.gz",
|
||||
}),
|
||||
).toBe("report.tar.gz");
|
||||
|
||||
const bounded = sanitizeSuggestedFileName("가".repeat(100), {
|
||||
safeExtension: ".txt",
|
||||
maxUtf8Bytes: 24,
|
||||
});
|
||||
expect(new TextEncoder().encode(bounded).byteLength).toBeLessThanOrEqual(
|
||||
24,
|
||||
);
|
||||
expect(
|
||||
sanitizeSuggestedFileName("😀", {
|
||||
safeExtension: ".bin",
|
||||
maxUtf8Bytes: 5,
|
||||
}),
|
||||
).toBe("d.bin");
|
||||
});
|
||||
|
||||
it("keeps native files behind opaque refs and performs bounded reads", async () => {
|
||||
const policies = pngPolicies();
|
||||
const vault = new BrowserFileVault({
|
||||
policies,
|
||||
createReference: () => "file:opaque-1",
|
||||
});
|
||||
const bytes = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2,
|
||||
]);
|
||||
const captured = vault.captureFiles(
|
||||
[
|
||||
new File([bytes], "portrait.png", {
|
||||
type: "image/png",
|
||||
lastModified: 100,
|
||||
}),
|
||||
],
|
||||
pngSelectionPolicy,
|
||||
);
|
||||
expect(captured.ok).toBe(true);
|
||||
if (!captured.ok) return;
|
||||
const candidate = captured.value[0];
|
||||
expect(candidate).toBeDefined();
|
||||
if (!candidate) return;
|
||||
expect(candidate.ref).not.toContain(candidate.displayName);
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
expect(
|
||||
await vault.inspect({
|
||||
ref: candidate.ref,
|
||||
policy: pngPolicy,
|
||||
signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
detectedMediaType: "image/png",
|
||||
normalizedExtension: ".png",
|
||||
signature: "MATCHED",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await vault.readRange({
|
||||
ref: candidate.ref,
|
||||
offset: 8,
|
||||
length: 2,
|
||||
signal,
|
||||
}),
|
||||
).toEqual({ ok: true, value: new Uint8Array([1, 2]) });
|
||||
|
||||
const opened = await vault.openSource({
|
||||
ref: candidate.ref,
|
||||
signal,
|
||||
});
|
||||
expect(opened.ok).toBe(true);
|
||||
if (opened.ok) {
|
||||
const streamed: number[] = [];
|
||||
for await (const chunk of opened.value.stream(signal)) {
|
||||
expect(chunk.ok).toBe(true);
|
||||
if (chunk.ok) streamed.push(...chunk.value);
|
||||
}
|
||||
expect(streamed).toEqual([...bytes]);
|
||||
}
|
||||
|
||||
vault.release(candidate.ref);
|
||||
expect(vault.activeVerificationCount).toBe(0);
|
||||
expect(
|
||||
await vault.readRange({
|
||||
ref: candidate.ref,
|
||||
offset: 0,
|
||||
length: 1,
|
||||
signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "NOT_FOUND", recovery: "RESELECT" },
|
||||
});
|
||||
});
|
||||
|
||||
it("snapshots vault requests before asynchronous file resolution", async () => {
|
||||
const references = ["file:a", "file:b"];
|
||||
const policies = pngPolicies(32);
|
||||
const vault = new BrowserFileVault({
|
||||
policies,
|
||||
createReference: () =>
|
||||
references.shift() ?? "file:unexpected",
|
||||
createVerificationReceipt: () => "verification:a",
|
||||
});
|
||||
const pngHeader = [
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
];
|
||||
const captured = vault.captureFiles(
|
||||
[
|
||||
new File(
|
||||
[new Uint8Array([...pngHeader, 1, 2])],
|
||||
"a.png",
|
||||
{ type: "image/png", lastModified: 1 },
|
||||
),
|
||||
new File(
|
||||
[new Uint8Array([...pngHeader, 9, 8])],
|
||||
"b.png",
|
||||
{ type: "image/png", lastModified: 2 },
|
||||
),
|
||||
],
|
||||
{
|
||||
...pngSelectionPolicy,
|
||||
multiple: true,
|
||||
maxCount: 2,
|
||||
maxTotalBytes: 2_048,
|
||||
},
|
||||
);
|
||||
expect(captured.ok).toBe(true);
|
||||
if (
|
||||
!captured.ok ||
|
||||
!captured.value[0] ||
|
||||
!captured.value[1]
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const [first, second] = captured.value;
|
||||
const active = new AbortController();
|
||||
const replacement = new AbortController();
|
||||
replacement.abort();
|
||||
|
||||
const readRequest = {
|
||||
ref: first.ref,
|
||||
offset: 8,
|
||||
length: 1,
|
||||
signal: active.signal,
|
||||
};
|
||||
const pendingRead = vault.readRange(readRequest);
|
||||
readRequest.ref = second.ref;
|
||||
readRequest.offset = 9;
|
||||
readRequest.signal = replacement.signal;
|
||||
expect(await pendingRead).toEqual({
|
||||
ok: true,
|
||||
value: new Uint8Array([1]),
|
||||
});
|
||||
|
||||
const inspectionRequest = {
|
||||
ref: first.ref,
|
||||
policy: pngPolicy,
|
||||
signal: active.signal,
|
||||
};
|
||||
const pendingInspection = vault.inspect(inspectionRequest);
|
||||
inspectionRequest.ref = second.ref;
|
||||
inspectionRequest.policy = browserFilePolicyReference(
|
||||
"forged",
|
||||
"forged",
|
||||
);
|
||||
inspectionRequest.signal = replacement.signal;
|
||||
expect(await pendingInspection).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
byteLength: 10,
|
||||
verificationReceipt: "verification:a",
|
||||
},
|
||||
});
|
||||
|
||||
const sourceRequest = {
|
||||
ref: first.ref,
|
||||
signal: active.signal,
|
||||
};
|
||||
const pendingSource = vault.openSource(sourceRequest);
|
||||
sourceRequest.ref = second.ref;
|
||||
sourceRequest.signal = replacement.signal;
|
||||
const opened = await pendingSource;
|
||||
expect(opened.ok).toBe(true);
|
||||
if (!opened.ok) return;
|
||||
const streamed: number[] = [];
|
||||
for await (const chunk of opened.value.stream(active.signal)) {
|
||||
expect(chunk.ok).toBe(true);
|
||||
if (chunk.ok) streamed.push(...chunk.value);
|
||||
}
|
||||
expect(streamed).toEqual([...pngHeader, 1, 2]);
|
||||
});
|
||||
|
||||
it("snapshots file handles and loader methods before awaiting them", async () => {
|
||||
const policies = pngPolicies();
|
||||
let reference = 0;
|
||||
const vault = new BrowserFileVault({
|
||||
policies,
|
||||
createReference: () => `file:handle-${reference++}`,
|
||||
});
|
||||
let resolveFirst: ((file: File) => void) | undefined;
|
||||
const firstFile = new File(["a"], "a.txt", {
|
||||
type: "text/plain",
|
||||
lastModified: 1,
|
||||
});
|
||||
const secondFile = new File(["b"], "b.txt", {
|
||||
type: "text/plain",
|
||||
lastModified: 2,
|
||||
});
|
||||
const originalFirst = vi.fn(
|
||||
() =>
|
||||
new Promise<File>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
);
|
||||
const originalSecond = vi.fn(async () => secondFile);
|
||||
const replaced = vi.fn(async () =>
|
||||
new File(["x"], "x.txt"),
|
||||
);
|
||||
const firstHandle = {
|
||||
kind: "file" as const,
|
||||
name: "a.txt",
|
||||
getFile: originalFirst,
|
||||
};
|
||||
const secondHandle = {
|
||||
kind: "file" as const,
|
||||
name: "b.txt",
|
||||
getFile: originalSecond,
|
||||
};
|
||||
const handles = [firstHandle, secondHandle];
|
||||
const pending = vault.captureHandles(
|
||||
handles,
|
||||
{
|
||||
...pngSelectionPolicy,
|
||||
multiple: true,
|
||||
maxCount: 2,
|
||||
accept: Object.freeze([]),
|
||||
},
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
firstHandle.getFile = replaced;
|
||||
secondHandle.getFile = replaced;
|
||||
handles.splice(1, 1, {
|
||||
kind: "file",
|
||||
name: "x.txt",
|
||||
getFile: replaced,
|
||||
});
|
||||
resolveFirst?.(firstFile);
|
||||
|
||||
const result = await pending;
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: [
|
||||
{ displayName: "a.txt" },
|
||||
{ displayName: "b.txt" },
|
||||
],
|
||||
});
|
||||
expect(originalFirst).toHaveBeenCalledOnce();
|
||||
expect(originalSecond).toHaveBeenCalledOnce();
|
||||
expect(replaced).not.toHaveBeenCalled();
|
||||
if (!result.ok || !result.value[1]) return;
|
||||
expect(
|
||||
await vault.resolveFile(
|
||||
result.value[1].ref,
|
||||
new AbortController().signal,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: secondFile,
|
||||
});
|
||||
expect(originalSecond).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("marks MIME/extension/signature disagreement and stale handles", async () => {
|
||||
const refs = ["file:mismatch", "file:stale"];
|
||||
const policies = pngPolicies();
|
||||
const vault = new BrowserFileVault({
|
||||
policies,
|
||||
createReference: () => refs.shift() ?? "file:unexpected",
|
||||
});
|
||||
const permissive = {
|
||||
...pngSelectionPolicy,
|
||||
accept: Object.freeze([]),
|
||||
};
|
||||
const mismatch = vault.captureFiles(
|
||||
[
|
||||
new File(
|
||||
[
|
||||
new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]),
|
||||
],
|
||||
"portrait.jpg",
|
||||
{ type: "image/jpeg", lastModified: 100 },
|
||||
),
|
||||
],
|
||||
permissive,
|
||||
);
|
||||
expect(mismatch.ok).toBe(true);
|
||||
if (!mismatch.ok || !mismatch.value[0]) return;
|
||||
expect(
|
||||
await vault.inspect({
|
||||
ref: mismatch.value[0].ref,
|
||||
policy: pngPolicy,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
detectedMediaType: "image/png",
|
||||
signature: "MISMATCHED",
|
||||
verificationReceipt: null,
|
||||
},
|
||||
});
|
||||
|
||||
let current = new File([new Uint8Array([1])], "state.bin", {
|
||||
lastModified: 1,
|
||||
});
|
||||
const handle = {
|
||||
kind: "file" as const,
|
||||
name: "state.bin",
|
||||
getFile: vi.fn(async () => current),
|
||||
};
|
||||
const handleCapture = await vault.captureHandles(
|
||||
[handle],
|
||||
permissive,
|
||||
new AbortController().signal,
|
||||
);
|
||||
expect(handleCapture.ok).toBe(true);
|
||||
if (!handleCapture.ok || !handleCapture.value[0]) return;
|
||||
current = new File([new Uint8Array([1, 2])], "state.bin", {
|
||||
lastModified: 2,
|
||||
});
|
||||
expect(
|
||||
await vault.resolveFile(
|
||||
handleCapture.value[0].ref,
|
||||
new AbortController().signal,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "STALE_RESULT", recovery: "RESELECT" },
|
||||
});
|
||||
});
|
||||
|
||||
it("owns preview object URL leases and rejects active content", async () => {
|
||||
const revoked: string[] = [];
|
||||
const createObjectURL = vi.fn(() => "blob:test-1");
|
||||
const leases = new ObjectUrlLeaseRegistry({
|
||||
createObjectURL,
|
||||
revokeObjectURL: (url) => revoked.push(url),
|
||||
});
|
||||
const policies = pngPolicies();
|
||||
const vault = new BrowserFileVault({
|
||||
policies,
|
||||
createReference: () => "file:preview",
|
||||
createVerificationReceipt: () => "verification:preview",
|
||||
});
|
||||
const capture = vault.captureFiles(
|
||||
[
|
||||
new File(
|
||||
[
|
||||
new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]),
|
||||
],
|
||||
"preview.png",
|
||||
{
|
||||
type: "image/png",
|
||||
lastModified: 1,
|
||||
},
|
||||
),
|
||||
],
|
||||
pngSelectionPolicy,
|
||||
);
|
||||
expect(capture.ok).toBe(true);
|
||||
if (!capture.ok || !capture.value[0]) return;
|
||||
const inspected = await vault.inspect({
|
||||
ref: capture.value[0].ref,
|
||||
policy: pngPolicy,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(inspected.ok).toBe(true);
|
||||
if (!inspected.ok || !inspected.value.verificationReceipt) return;
|
||||
const verificationReceipt =
|
||||
inspected.value.verificationReceipt;
|
||||
const preview = new BrowserTransientPreview({
|
||||
files: vault,
|
||||
policies,
|
||||
leases,
|
||||
hardMaxPreviewBytes: 10,
|
||||
observer: {
|
||||
record() {
|
||||
throw new Error("ignored");
|
||||
},
|
||||
},
|
||||
});
|
||||
const created = await preview.create({
|
||||
ref: capture.value[0].ref,
|
||||
verificationReceipt,
|
||||
policy: pngPolicy,
|
||||
maxPreviewBytes: 10,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(created.ok).toBe(true);
|
||||
if (created.ok) {
|
||||
created.value.release();
|
||||
created.value.release();
|
||||
}
|
||||
expect(revoked).toEqual(["blob:test-1"]);
|
||||
expect(leases.activeLeaseCount).toBe(0);
|
||||
|
||||
expect(
|
||||
await preview.create({
|
||||
ref: capture.value[0].ref,
|
||||
verificationReceipt,
|
||||
policy: pngPolicy,
|
||||
maxPreviewBytes: 11,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
expect(createObjectURL).toHaveBeenCalledOnce();
|
||||
|
||||
expect(
|
||||
await preview.create({
|
||||
ref: capture.value[0].ref as LocalFileRef,
|
||||
verificationReceipt:
|
||||
"verification:forged" as FileVerificationReceipt,
|
||||
policy: pngPolicy,
|
||||
maxPreviewBytes: 10,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("cannot weaken the built-in active-content preview denylist", async () => {
|
||||
const htmlPolicy = browserFilePolicyReference(
|
||||
"active-content",
|
||||
"preview-html",
|
||||
);
|
||||
const policies = new BrowserFilePolicyRegistry({
|
||||
profiles: [
|
||||
{
|
||||
reference: htmlPolicy,
|
||||
inspection: {
|
||||
policyId: "html-signature-v1",
|
||||
maxInspectionBytes: 6,
|
||||
acceptedSignatures: [
|
||||
{
|
||||
mediaType: "text/html",
|
||||
extensions: [".html"],
|
||||
patterns: [
|
||||
{
|
||||
offset: 0,
|
||||
bytes: [60, 104, 116, 109, 108],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
preview: {
|
||||
allowedMediaTypes: ["text/html"],
|
||||
maxPreviewBytes: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
hardLimits: {
|
||||
maxInspectionBytes: 64 * 1024,
|
||||
maxRetainedFileBytes: 1_024,
|
||||
maxPreviewBytes: 10,
|
||||
maxObjectUrlBytes: 1_024,
|
||||
maxTransferBytes: 1_024,
|
||||
},
|
||||
});
|
||||
const vault = new BrowserFileVault({
|
||||
policies,
|
||||
createReference: () => "file:html",
|
||||
createVerificationReceipt: () => "verification:html",
|
||||
});
|
||||
const captured = vault.captureFiles(
|
||||
[
|
||||
new File([new TextEncoder().encode("<html>")], "page.html", {
|
||||
type: "text/html",
|
||||
lastModified: 1,
|
||||
}),
|
||||
],
|
||||
{
|
||||
...pngSelectionPolicy,
|
||||
accept: Object.freeze([]),
|
||||
},
|
||||
);
|
||||
expect(captured.ok).toBe(true);
|
||||
if (!captured.ok || !captured.value[0]) return;
|
||||
const inspected = await vault.inspect({
|
||||
ref: captured.value[0].ref,
|
||||
policy: htmlPolicy,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(inspected.ok).toBe(true);
|
||||
if (!inspected.ok || !inspected.value.verificationReceipt) return;
|
||||
const createObjectURL = vi.fn(() => "blob:must-not-exist");
|
||||
const preview = new BrowserTransientPreview({
|
||||
files: vault,
|
||||
policies,
|
||||
hardMaxPreviewBytes: 10,
|
||||
hardForbiddenMediaTypes: new Set(["image/jpeg"]),
|
||||
leases: new ObjectUrlLeaseRegistry({
|
||||
createObjectURL,
|
||||
revokeObjectURL() {},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
await preview.create({
|
||||
ref: captured.value[0].ref,
|
||||
verificationReceipt: inspected.value.verificationReceipt,
|
||||
policy: htmlPolicy,
|
||||
maxPreviewBytes: 10,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
expect(createObjectURL).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects count and zero-byte policy violations before retaining refs", () => {
|
||||
const vault = new BrowserFileVault({
|
||||
policies: pngPolicies(),
|
||||
});
|
||||
expect(vault.captureFiles([], pngSelectionPolicy)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
expect(
|
||||
vault.captureFiles(
|
||||
[new File([], "empty.png", { type: "image/png" })],
|
||||
pngSelectionPolicy,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
expect(vault.activeReferenceCount).toBe(0);
|
||||
});
|
||||
|
||||
it("caps retained files and aggregate object URL leases", () => {
|
||||
let referenceSequence = 0;
|
||||
const vault = new BrowserFileVault({
|
||||
policies: pngPolicies(),
|
||||
createReference: () => `file:bounded-${referenceSequence++}`,
|
||||
hardMaxActiveReferences: 1,
|
||||
hardMaxRetainedBytes: 3,
|
||||
});
|
||||
const permissive = {
|
||||
...pngSelectionPolicy,
|
||||
accept: Object.freeze([]),
|
||||
};
|
||||
const first = vault.captureFiles(
|
||||
[new File([new Uint8Array(2)], "one.bin")],
|
||||
permissive,
|
||||
);
|
||||
expect(first.ok).toBe(true);
|
||||
expect(vault.retainedByteLength).toBe(2);
|
||||
expect(
|
||||
vault.captureFiles(
|
||||
[new File([new Uint8Array(2)], "two.bin")],
|
||||
permissive,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
if (first.ok && first.value[0]) vault.release(first.value[0].ref);
|
||||
expect(vault.retainedByteLength).toBe(0);
|
||||
|
||||
let urlSequence = 0;
|
||||
const leases = new ObjectUrlLeaseRegistry(
|
||||
{
|
||||
createObjectURL: () => `blob:bounded-${urlSequence++}`,
|
||||
revokeObjectURL() {},
|
||||
},
|
||||
{
|
||||
hardMaxActiveLeases: 1,
|
||||
hardMaxSingleLeaseBytes: 6,
|
||||
hardMaxAggregateLeaseBytes: 6,
|
||||
},
|
||||
);
|
||||
const lease = leases.create(new Blob([new Uint8Array(6)]));
|
||||
expect(leases.aggregateLeaseByteLength).toBe(6);
|
||||
expect(() =>
|
||||
leases.create(new Blob([new Uint8Array(1)])),
|
||||
).toThrowError(DOMException);
|
||||
lease.release();
|
||||
expect(leases.aggregateLeaseByteLength).toBe(0);
|
||||
});
|
||||
|
||||
it("converts native stream exceptions to closed chunk failures", async () => {
|
||||
const file = new File([new Uint8Array([1])], "broken.bin", {
|
||||
lastModified: 1,
|
||||
});
|
||||
Object.defineProperty(file, "stream", {
|
||||
value: () =>
|
||||
new ReadableStream<Uint8Array>({
|
||||
pull() {
|
||||
throw new DOMException("native detail", "NotReadableError");
|
||||
},
|
||||
}),
|
||||
});
|
||||
const vault = new BrowserFileVault({
|
||||
policies: pngPolicies(),
|
||||
createReference: () => "file:broken",
|
||||
});
|
||||
const captured = vault.captureFiles(
|
||||
[file],
|
||||
{ ...pngSelectionPolicy, accept: Object.freeze([]) },
|
||||
);
|
||||
expect(captured.ok).toBe(true);
|
||||
if (!captured.ok || !captured.value[0]) return;
|
||||
const opened = await vault.openSource({
|
||||
ref: captured.value[0].ref,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(opened.ok).toBe(true);
|
||||
if (!opened.ok) return;
|
||||
|
||||
const results = [];
|
||||
for await (const result of opened.value.stream(
|
||||
new AbortController().signal,
|
||||
)) {
|
||||
results.push(result);
|
||||
}
|
||||
expect(results).toEqual([
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
code: "NOT_READABLE",
|
||||
operation: "FILE_READ",
|
||||
retryable: true,
|
||||
recovery: "REOPEN",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,909 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
BrowserManagedDownloadCapabilityReceipt,
|
||||
BrowserManagedDownloadCapabilityResolver,
|
||||
DownloadDeliveryPort,
|
||||
DownloadSource,
|
||||
DownloadStrategy,
|
||||
FileByteSource,
|
||||
} from "../../src/application/ports/browser-file-storage/file.ts";
|
||||
import {
|
||||
DEFAULT_OBJECT_URL_RELEASE_GRACE_MS,
|
||||
createDownloadDeliveryAdapter,
|
||||
type SaveFileHandle,
|
||||
} from "../../src/adapters/browser-files/download-delivery-adapter.ts";
|
||||
import { ObjectUrlLeaseRegistry } from "../../src/adapters/browser-files/object-url-lease.ts";
|
||||
import {
|
||||
BrowserFilePolicyRegistry,
|
||||
browserFilePolicyReference,
|
||||
} from "../../src/adapters/browser-files/browser-file-policy-registry.ts";
|
||||
|
||||
const HARD_LIMITS = Object.freeze({
|
||||
hardMaxObjectUrlBytes: 64,
|
||||
hardMaxTransferBytes: 128,
|
||||
});
|
||||
const capabilityReceipt =
|
||||
"capability:download-1" as BrowserManagedDownloadCapabilityReceipt;
|
||||
const policyByStrategy = Object.freeze({
|
||||
BROWSER_MANAGED: browserFilePolicyReference(
|
||||
"download",
|
||||
"browser-managed-pdf",
|
||||
),
|
||||
PROMPT_AND_STREAM: browserFilePolicyReference(
|
||||
"download",
|
||||
"prompt-and-stream-pdf",
|
||||
),
|
||||
BOUNDED_OBJECT_URL: browserFilePolicyReference(
|
||||
"download",
|
||||
"bounded-object-url-pdf",
|
||||
),
|
||||
});
|
||||
const policies = new BrowserFilePolicyRegistry({
|
||||
profiles: (
|
||||
Object.entries(policyByStrategy) as Array<
|
||||
[DownloadStrategy, (typeof policyByStrategy)[DownloadStrategy]]
|
||||
>
|
||||
).map(([strategy, reference]) => ({
|
||||
reference,
|
||||
download: {
|
||||
strategy,
|
||||
mediaType: "application/pdf",
|
||||
safeExtension: ".pdf",
|
||||
maxTransferBytes: 32,
|
||||
maxBufferedBytes: 16,
|
||||
integrity: "OPTIONAL" as const,
|
||||
},
|
||||
})),
|
||||
hardLimits: {
|
||||
maxInspectionBytes: 64,
|
||||
maxRetainedFileBytes: 128,
|
||||
maxPreviewBytes: 64,
|
||||
maxObjectUrlBytes: 64,
|
||||
maxTransferBytes: 128,
|
||||
},
|
||||
});
|
||||
|
||||
function capabilityResolver(
|
||||
href: (resourceId: string) => string = () => "/unused",
|
||||
overrides: Readonly<
|
||||
Partial<{
|
||||
capabilityReceipt: BrowserManagedDownloadCapabilityReceipt;
|
||||
resourceId: string;
|
||||
mediaType: string;
|
||||
safeExtension: string;
|
||||
maxBytes: number;
|
||||
expectedSha256: string;
|
||||
expiresAtEpochMs: number;
|
||||
}>
|
||||
> = {},
|
||||
): BrowserManagedDownloadCapabilityResolver {
|
||||
return {
|
||||
resolve(input) {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
capabilityReceipt:
|
||||
overrides.capabilityReceipt ??
|
||||
input.capabilityReceipt,
|
||||
href: href(input.resourceId),
|
||||
resourceId:
|
||||
overrides.resourceId ?? input.resourceId,
|
||||
mediaType:
|
||||
overrides.mediaType ?? "application/pdf",
|
||||
safeExtension: overrides.safeExtension ?? ".pdf",
|
||||
maxBytes: overrides.maxBytes ?? 32,
|
||||
...(overrides.expectedSha256
|
||||
? { expectedSha256: overrides.expectedSha256 }
|
||||
: {}),
|
||||
expiresAtEpochMs:
|
||||
overrides.expiresAtEpochMs ??
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function successfulChunk(bytes: Uint8Array) {
|
||||
return Object.freeze({ ok: true as const, value: bytes });
|
||||
}
|
||||
|
||||
function byteSource(
|
||||
chunks: readonly Uint8Array[],
|
||||
byteLength: number | null = chunks.reduce(
|
||||
(total, chunk) => total + chunk.byteLength,
|
||||
0,
|
||||
),
|
||||
afterChunk?: (index: number) => void,
|
||||
): FileByteSource {
|
||||
return Object.freeze({
|
||||
byteLength,
|
||||
async *stream(
|
||||
signal: AbortSignal,
|
||||
): AsyncIterable<ReturnType<typeof successfulChunk>> {
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
if (signal.aborted) {
|
||||
throw new DOMException("aborted", "AbortError");
|
||||
}
|
||||
yield successfulChunk(chunk);
|
||||
afterChunk?.(index);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function deliveryInput(
|
||||
source: DownloadSource,
|
||||
strategy: DownloadStrategy,
|
||||
signal = new AbortController().signal,
|
||||
) {
|
||||
return {
|
||||
policy: policyByStrategy[strategy],
|
||||
source,
|
||||
suggestedFileName: "../../invoice.exe.pdf",
|
||||
maxTransferBytes: 32,
|
||||
maxBufferedBytes: 16,
|
||||
signal,
|
||||
onProgress: vi.fn(),
|
||||
} satisfies Parameters<DownloadDeliveryPort["deliver"]>[0];
|
||||
}
|
||||
|
||||
function writableHandle(events: string[]): SaveFileHandle {
|
||||
return Object.freeze({
|
||||
async createWritable() {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
events.push(`write:${chunk.byteLength}`);
|
||||
},
|
||||
close() {
|
||||
events.push("close");
|
||||
},
|
||||
abort() {
|
||||
events.push("abort");
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("browser download delivery", () => {
|
||||
it("captures composition dependencies instead of re-reading mutable option objects", async () => {
|
||||
const originalHandoff = vi.fn();
|
||||
const replacedHandoff = vi.fn();
|
||||
const originalResolve = vi.fn(
|
||||
capabilityResolver(
|
||||
() => "/downloads/composition-bound",
|
||||
).resolve,
|
||||
);
|
||||
const replacedResolve = vi.fn(
|
||||
capabilityResolver(
|
||||
() => "https://evil.example/replaced",
|
||||
).resolve,
|
||||
);
|
||||
const host = { handoff: originalHandoff };
|
||||
const browserManagedCapabilities = {
|
||||
resolve: originalResolve,
|
||||
};
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host,
|
||||
browserManagedCapabilities,
|
||||
baseOrigin: "https://app.example",
|
||||
createTransferId: () => "transfer:composition",
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
|
||||
host.handoff = replacedHandoff;
|
||||
browserManagedCapabilities.resolve = replacedResolve;
|
||||
|
||||
expect(
|
||||
await adapter.deliver(
|
||||
deliveryInput(
|
||||
{
|
||||
kind: "BROWSER_MANAGED_RESOURCE",
|
||||
resourceId: "artifact-1",
|
||||
capabilityReceipt,
|
||||
},
|
||||
"BROWSER_MANAGED",
|
||||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "BROWSER_HANDOFF" },
|
||||
});
|
||||
expect(originalResolve).toHaveBeenCalledOnce();
|
||||
expect(replacedResolve).not.toHaveBeenCalled();
|
||||
expect(originalHandoff).toHaveBeenCalledOnce();
|
||||
expect(replacedHandoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sanitizes browser-managed handoff and reports only handoff truth", async () => {
|
||||
const handoff = vi.fn();
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff },
|
||||
browserManagedCapabilities: capabilityResolver(
|
||||
(resourceId) => `/downloads/${resourceId}`,
|
||||
),
|
||||
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",
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: { kind: "BROWSER_HANDOFF", transferId: "transfer:1" },
|
||||
});
|
||||
expect(handoff).toHaveBeenCalledWith(
|
||||
"/downloads/artifact-1",
|
||||
"invoice_exe.pdf",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects cross-origin or query-bearing browser-managed targets", async () => {
|
||||
const handoff = vi.fn();
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff },
|
||||
browserManagedCapabilities: capabilityResolver(
|
||||
() => "https://evil.example/a?token=secret",
|
||||
),
|
||||
baseOrigin: "https://app.example",
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
|
||||
expect(
|
||||
await adapter.deliver(
|
||||
deliveryInput(
|
||||
{
|
||||
kind: "BROWSER_MANAGED_RESOURCE",
|
||||
resourceId: "artifact-1",
|
||||
capabilityReceipt,
|
||||
},
|
||||
"BROWSER_MANAGED",
|
||||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
expect(handoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires an exact, unexpired, server-bound synchronous capability receipt", async () => {
|
||||
const source = {
|
||||
kind: "BROWSER_MANAGED_RESOURCE" as const,
|
||||
resourceId: "artifact-1",
|
||||
capabilityReceipt,
|
||||
};
|
||||
const cases = [
|
||||
{
|
||||
resolver: capabilityResolver(() => "/unused", {
|
||||
capabilityReceipt:
|
||||
"capability:other" as BrowserManagedDownloadCapabilityReceipt,
|
||||
}),
|
||||
expectedCode: "POLICY_REJECTED",
|
||||
},
|
||||
{
|
||||
resolver: capabilityResolver(undefined, {
|
||||
resourceId: "artifact-2",
|
||||
}),
|
||||
expectedCode: "POLICY_REJECTED",
|
||||
},
|
||||
{
|
||||
resolver: capabilityResolver(undefined, {
|
||||
mediaType: "text/plain",
|
||||
}),
|
||||
expectedCode: "POLICY_REJECTED",
|
||||
},
|
||||
{
|
||||
resolver: capabilityResolver(undefined, {
|
||||
expiresAtEpochMs: 99,
|
||||
}),
|
||||
expectedCode: "EXPIRED_RESOURCE",
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const handoff = vi.fn();
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff },
|
||||
browserManagedCapabilities: testCase.resolver,
|
||||
now: () => 100,
|
||||
baseOrigin: "https://app.example",
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
expect(
|
||||
await adapter.deliver(
|
||||
deliveryInput(source, "BROWSER_MANAGED"),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: testCase.expectedCode },
|
||||
});
|
||||
expect(handoff).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
const missingReceipt = {
|
||||
kind: "BROWSER_MANAGED_RESOURCE",
|
||||
resourceId: "artifact-1",
|
||||
} as unknown as DownloadSource;
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff: vi.fn() },
|
||||
browserManagedCapabilities: capabilityResolver(),
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
expect(
|
||||
await adapter.deliver(
|
||||
deliveryInput(missingReceipt, "BROWSER_MANAGED"),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
});
|
||||
|
||||
it("streams with backpressure and succeeds only after close", async () => {
|
||||
const events: string[] = [];
|
||||
const controller = new AbortController();
|
||||
const handle = writableHandle(events);
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff: vi.fn() },
|
||||
browserManagedCapabilities: capabilityResolver(),
|
||||
showSaveFilePicker: async () => handle,
|
||||
createTransferId: () => "transfer:stream",
|
||||
progressMinIntervalMs: 0,
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
const input = deliveryInput(
|
||||
{
|
||||
kind: "GENERATED",
|
||||
bytes: byteSource([
|
||||
new Uint8Array([1, 2]),
|
||||
new Uint8Array([3]),
|
||||
]),
|
||||
},
|
||||
"PROMPT_AND_STREAM",
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
expect(await adapter.deliver(input)).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "SAVED",
|
||||
transferId: "transfer:stream",
|
||||
bytesWritten: 3,
|
||||
integrity: "NOT_PROVIDED",
|
||||
},
|
||||
});
|
||||
expect(events).toEqual(["write:2", "write:1", "close"]);
|
||||
expect(input.onProgress.mock.calls.map(([progress]) => progress.phase)).toEqual(
|
||||
["PREPARING", "TRANSFERRING", "TRANSFERRING", "VERIFYING", "FINALIZING"],
|
||||
);
|
||||
});
|
||||
|
||||
it("snapshots the generated source before awaiting the save picker", async () => {
|
||||
const events: string[] = [];
|
||||
let resolvePicker:
|
||||
| ((handle: SaveFileHandle) => void)
|
||||
| undefined;
|
||||
const originalStream = vi.fn(
|
||||
async function* () {
|
||||
yield successfulChunk(new Uint8Array([1, 2]));
|
||||
},
|
||||
);
|
||||
const replacedStream = vi.fn(
|
||||
async function* () {
|
||||
yield successfulChunk(new Uint8Array([9]));
|
||||
},
|
||||
);
|
||||
const bytes = {
|
||||
byteLength: 2,
|
||||
stream: originalStream,
|
||||
};
|
||||
const source = {
|
||||
kind: "GENERATED" as const,
|
||||
bytes,
|
||||
};
|
||||
const request = deliveryInput(
|
||||
source,
|
||||
"PROMPT_AND_STREAM",
|
||||
);
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff: vi.fn() },
|
||||
browserManagedCapabilities: capabilityResolver(),
|
||||
showSaveFilePicker: () =>
|
||||
new Promise((resolve) => {
|
||||
resolvePicker = resolve;
|
||||
}),
|
||||
createTransferId: () => "transfer:snapshot",
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
|
||||
const pending = adapter.deliver(request);
|
||||
bytes.byteLength = 1;
|
||||
bytes.stream = replacedStream;
|
||||
(
|
||||
request as { source: DownloadSource }
|
||||
).source = {
|
||||
kind: "BROWSER_MANAGED_RESOURCE",
|
||||
resourceId: "replaced",
|
||||
capabilityReceipt,
|
||||
};
|
||||
resolvePicker?.(writableHandle(events));
|
||||
|
||||
expect(await pending).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "SAVED", bytesWritten: 2 },
|
||||
});
|
||||
expect(originalStream).toHaveBeenCalledOnce();
|
||||
expect(replacedStream).not.toHaveBeenCalled();
|
||||
expect(events).toEqual(["write:2", "close"]);
|
||||
});
|
||||
|
||||
it("does not turn a completed close into a late abort failure", async () => {
|
||||
const controller = new AbortController();
|
||||
const handle: SaveFileHandle = {
|
||||
async createWritable() {
|
||||
return new WritableStream<Uint8Array>({
|
||||
close() {
|
||||
controller.abort();
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff: vi.fn() },
|
||||
browserManagedCapabilities: capabilityResolver(),
|
||||
showSaveFilePicker: async () => handle,
|
||||
createTransferId: () => "transfer:committed",
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
|
||||
expect(
|
||||
await adapter.deliver(
|
||||
deliveryInput(
|
||||
{
|
||||
kind: "GENERATED",
|
||||
bytes: byteSource([new Uint8Array([1])]),
|
||||
},
|
||||
"PROMPT_AND_STREAM",
|
||||
controller.signal,
|
||||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "SAVED", transferId: "transfer:committed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts the writable before close on integrity failure", async () => {
|
||||
const events: string[] = [];
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff: vi.fn() },
|
||||
browserManagedCapabilities: capabilityResolver(),
|
||||
showSaveFilePicker: async () => writableHandle(events),
|
||||
createIntegrityVerifier: () => ({
|
||||
update: () => {
|
||||
events.push("hash");
|
||||
},
|
||||
verify: () => false,
|
||||
}),
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
const input = {
|
||||
...deliveryInput(
|
||||
{
|
||||
kind: "GENERATED" as const,
|
||||
bytes: byteSource([new Uint8Array([1, 2])]),
|
||||
expectedSha256: "a".repeat(64),
|
||||
},
|
||||
"PROMPT_AND_STREAM",
|
||||
),
|
||||
};
|
||||
|
||||
expect(await adapter.deliver(input)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INTEGRITY_FAILED" },
|
||||
});
|
||||
expect(events).toEqual(["hash", "write:2", "abort"]);
|
||||
});
|
||||
|
||||
it("distinguishes save-picker dismissal from active cancellation", async () => {
|
||||
const dismissed = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff: vi.fn() },
|
||||
browserManagedCapabilities: capabilityResolver(),
|
||||
showSaveFilePicker: async () => {
|
||||
throw new DOMException("dismissed", "AbortError");
|
||||
},
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
expect(
|
||||
await dismissed.deliver(
|
||||
deliveryInput(
|
||||
{
|
||||
kind: "GENERATED",
|
||||
bytes: byteSource([new Uint8Array([1])]),
|
||||
},
|
||||
"PROMPT_AND_STREAM",
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: { kind: "DISMISSED" },
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
let rejectPicker:
|
||||
| ((reason: DOMException) => void)
|
||||
| undefined;
|
||||
const aborted = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff: vi.fn() },
|
||||
browserManagedCapabilities: capabilityResolver(),
|
||||
showSaveFilePicker: () =>
|
||||
new Promise((_, reject: (reason: DOMException) => void) => {
|
||||
rejectPicker = reject;
|
||||
}),
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
const pending = aborted.deliver(
|
||||
deliveryInput(
|
||||
{
|
||||
kind: "GENERATED",
|
||||
bytes: byteSource([new Uint8Array([1])]),
|
||||
},
|
||||
"PROMPT_AND_STREAM",
|
||||
controller.signal,
|
||||
),
|
||||
);
|
||||
controller.abort();
|
||||
rejectPicker?.(new DOMException("closed", "AbortError"));
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds Blob buffering and revokes the lease after handoff", async () => {
|
||||
const revoked: string[] = [];
|
||||
const leases = new ObjectUrlLeaseRegistry({
|
||||
createObjectURL: () => "blob:download-1",
|
||||
revokeObjectURL: (url) => revoked.push(url),
|
||||
});
|
||||
let scheduled: (() => void) | undefined;
|
||||
let scheduledDelay: number | undefined;
|
||||
const handoff = vi.fn();
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff },
|
||||
browserManagedCapabilities: capabilityResolver(),
|
||||
objectUrls: leases,
|
||||
scheduler: {
|
||||
setTimeout(callback, delayMs) {
|
||||
scheduled = callback;
|
||||
scheduledDelay = delayMs;
|
||||
return 1;
|
||||
},
|
||||
},
|
||||
createTransferId: () => "transfer:blob",
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
|
||||
expect(
|
||||
await adapter.deliver(
|
||||
deliveryInput(
|
||||
{
|
||||
kind: "GENERATED",
|
||||
bytes: byteSource([
|
||||
new Uint8Array([1, 2]),
|
||||
new Uint8Array([3]),
|
||||
]),
|
||||
},
|
||||
"BOUNDED_OBJECT_URL",
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "BROWSER_HANDOFF",
|
||||
transferId: "transfer:blob",
|
||||
},
|
||||
});
|
||||
expect(handoff).toHaveBeenCalledWith(
|
||||
"blob:download-1",
|
||||
"invoice_exe.pdf",
|
||||
);
|
||||
expect(leases.activeLeaseCount).toBe(1);
|
||||
expect(scheduledDelay).toBe(
|
||||
DEFAULT_OBJECT_URL_RELEASE_GRACE_MS,
|
||||
);
|
||||
scheduled?.();
|
||||
expect(leases.activeLeaseCount).toBe(0);
|
||||
expect(revoked).toEqual(["blob:download-1"]);
|
||||
});
|
||||
|
||||
it("captures object URL methods at registry construction", () => {
|
||||
const originalCreate = vi.fn(() => "blob:captured");
|
||||
const originalRevoke = vi.fn();
|
||||
const replacedCreate = vi.fn(() => "blob:replaced");
|
||||
const replacedRevoke = vi.fn();
|
||||
const urlApi = {
|
||||
createObjectURL: originalCreate,
|
||||
revokeObjectURL: originalRevoke,
|
||||
};
|
||||
const leases = new ObjectUrlLeaseRegistry(urlApi);
|
||||
urlApi.createObjectURL = replacedCreate;
|
||||
urlApi.revokeObjectURL = replacedRevoke;
|
||||
|
||||
const lease = leases.create(new Blob(["safe"]));
|
||||
lease.release();
|
||||
|
||||
expect(lease.url).toBe("blob:captured");
|
||||
expect(originalCreate).toHaveBeenCalledOnce();
|
||||
expect(originalRevoke).toHaveBeenCalledWith(
|
||||
"blob:captured",
|
||||
);
|
||||
expect(replacedCreate).not.toHaveBeenCalled();
|
||||
expect(replacedRevoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a stream that crosses the bounded Blob cap", async () => {
|
||||
const handoff = vi.fn();
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff },
|
||||
browserManagedCapabilities: capabilityResolver(),
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
const input = {
|
||||
...deliveryInput(
|
||||
{
|
||||
kind: "GENERATED" as const,
|
||||
bytes: byteSource(
|
||||
[new Uint8Array(10), new Uint8Array(10)],
|
||||
null,
|
||||
),
|
||||
},
|
||||
"BOUNDED_OBJECT_URL",
|
||||
),
|
||||
maxBufferedBytes: 16,
|
||||
};
|
||||
|
||||
expect(await adapter.deliver(input)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
expect(handoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects caller limits above runtime hard caps before side effects", async () => {
|
||||
const handoff = vi.fn();
|
||||
const showSaveFilePicker = vi.fn(async () => writableHandle([]));
|
||||
const stream = vi.fn(
|
||||
async function* (): AsyncIterable<
|
||||
ReturnType<typeof successfulChunk>
|
||||
> {
|
||||
yield successfulChunk(new Uint8Array([1]));
|
||||
},
|
||||
);
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
hardMaxObjectUrlBytes: 8,
|
||||
hardMaxTransferBytes: 12,
|
||||
policies,
|
||||
host: { handoff },
|
||||
browserManagedCapabilities: capabilityResolver(),
|
||||
showSaveFilePicker,
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
const source = {
|
||||
kind: "GENERATED" as const,
|
||||
bytes: { byteLength: null, stream },
|
||||
};
|
||||
|
||||
expect(
|
||||
await adapter.deliver({
|
||||
...deliveryInput(source, "BOUNDED_OBJECT_URL"),
|
||||
maxBufferedBytes: 9,
|
||||
maxTransferBytes: 12,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
expect(
|
||||
await adapter.deliver({
|
||||
...deliveryInput(source, "PROMPT_AND_STREAM"),
|
||||
maxBufferedBytes: 8,
|
||||
maxTransferBytes: 13,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
expect(
|
||||
await adapter.deliver({
|
||||
...deliveryInput(
|
||||
{
|
||||
kind: "GENERATED",
|
||||
bytes: { byteLength: 13, stream },
|
||||
},
|
||||
"PROMPT_AND_STREAM",
|
||||
),
|
||||
maxBufferedBytes: 8,
|
||||
maxTransferBytes: 12,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
expect(stream).not.toHaveBeenCalled();
|
||||
expect(showSaveFilePicker).not.toHaveBeenCalled();
|
||||
expect(handoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aborts streaming when actual bytes cross the transfer hard cap", async () => {
|
||||
const events: string[] = [];
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
hardMaxObjectUrlBytes: 8,
|
||||
hardMaxTransferBytes: 8,
|
||||
policies,
|
||||
host: { handoff: vi.fn() },
|
||||
browserManagedCapabilities: capabilityResolver(),
|
||||
showSaveFilePicker: async () => writableHandle(events),
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
|
||||
expect(
|
||||
await adapter.deliver({
|
||||
...deliveryInput(
|
||||
{
|
||||
kind: "GENERATED",
|
||||
bytes: byteSource(
|
||||
[new Uint8Array(5), new Uint8Array(4)],
|
||||
null,
|
||||
),
|
||||
},
|
||||
"PROMPT_AND_STREAM",
|
||||
),
|
||||
maxBufferedBytes: 8,
|
||||
maxTransferBytes: 8,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
expect(events).toEqual(["write:5", "abort"]);
|
||||
});
|
||||
|
||||
it("treats a declared-length mismatch as integrity failure", async () => {
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff: vi.fn() },
|
||||
browserManagedCapabilities: capabilityResolver(),
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
expect(
|
||||
await adapter.deliver(
|
||||
deliveryInput(
|
||||
{
|
||||
kind: "GENERATED",
|
||||
bytes: byteSource([new Uint8Array([1])], 2),
|
||||
},
|
||||
"BOUNDED_OBJECT_URL",
|
||||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INTEGRITY_FAILED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps typed and defensive raw stream failures to closed download errors", async () => {
|
||||
const adapter = createDownloadDeliveryAdapter({
|
||||
...HARD_LIMITS,
|
||||
policies,
|
||||
host: { handoff: vi.fn() },
|
||||
browserManagedCapabilities: capabilityResolver(),
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
const common = {
|
||||
...deliveryInput(
|
||||
{
|
||||
kind: "GENERATED" as const,
|
||||
bytes: {
|
||||
byteLength: null,
|
||||
async *stream() {
|
||||
yield {
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: "NOT_READABLE" as const,
|
||||
operation: "FILE_READ" as const,
|
||||
retryable: true,
|
||||
recovery: "REOPEN" as const,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
"BOUNDED_OBJECT_URL",
|
||||
),
|
||||
};
|
||||
expect(await adapter.deliver(common)).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "NOT_READABLE",
|
||||
operation: "DOWNLOAD",
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await adapter.deliver({
|
||||
...common,
|
||||
source: {
|
||||
kind: "GENERATED",
|
||||
bytes: {
|
||||
byteLength: null,
|
||||
async *stream(signal: AbortSignal) {
|
||||
if (signal.aborted) {
|
||||
yield {
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: "ABORTED" as const,
|
||||
operation: "FILE_READ" as const,
|
||||
retryable: false,
|
||||
recovery: "NONE" as const,
|
||||
},
|
||||
};
|
||||
return;
|
||||
}
|
||||
throw new DOMException(
|
||||
"native detail",
|
||||
"NotReadableError",
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "NOT_READABLE",
|
||||
operation: "DOWNLOAD",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,496 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_NATIVE_PICKER_FOCUS_GRACE_MS,
|
||||
EnhancedFilePicker,
|
||||
NativeInputFilePicker,
|
||||
} from "../../src/adapters/browser-files/browser-file-picker.ts";
|
||||
import { BrowserFileVault } from "../../src/adapters/browser-files/browser-file-vault.ts";
|
||||
import {
|
||||
BrowserFilePolicyRegistry,
|
||||
browserFilePolicyReference,
|
||||
} from "../../src/adapters/browser-files/browser-file-policy-registry.ts";
|
||||
import type { RegisteredFileSelectionPolicy } from "../../src/adapters/browser-files/file-policy.ts";
|
||||
|
||||
const policyDefinition: RegisteredFileSelectionPolicy = Object.freeze({
|
||||
policyId: "attachment-v1",
|
||||
purpose: "attachment",
|
||||
classification: "PERSONAL",
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxFileBytes: 100,
|
||||
maxTotalBytes: 100,
|
||||
allowEmpty: false,
|
||||
accept: Object.freeze([
|
||||
Object.freeze({
|
||||
mediaType: "text/plain",
|
||||
extensions: Object.freeze([".txt"]),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
const policy = browserFilePolicyReference(
|
||||
"attachments",
|
||||
"select-text-attachment",
|
||||
);
|
||||
|
||||
function createHarness(
|
||||
selection: RegisteredFileSelectionPolicy = policyDefinition,
|
||||
vaultOptions: Readonly<{
|
||||
createReference?: () => string;
|
||||
}> = {},
|
||||
): Readonly<{
|
||||
policies: BrowserFilePolicyRegistry;
|
||||
vault: BrowserFileVault;
|
||||
}> {
|
||||
const policies = new BrowserFilePolicyRegistry({
|
||||
profiles: [{ reference: policy, selection }],
|
||||
hardLimits: {
|
||||
maxInspectionBytes: 64 * 1024,
|
||||
maxRetainedFileBytes: 1_024,
|
||||
maxPreviewBytes: 1_024,
|
||||
maxObjectUrlBytes: 1_024,
|
||||
maxTransferBytes: 1_024,
|
||||
},
|
||||
});
|
||||
return {
|
||||
policies,
|
||||
vault: new BrowserFileVault({ policies, ...vaultOptions }),
|
||||
};
|
||||
}
|
||||
|
||||
function labelledFileInput(): HTMLInputElement {
|
||||
const label = document.createElement("label");
|
||||
label.textContent = "Choose attachment";
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
label.append(input);
|
||||
document.body.append(label);
|
||||
return input;
|
||||
}
|
||||
|
||||
describe("browser file pickers", () => {
|
||||
it("treats native input cancellation as a normal dismissed outcome", async () => {
|
||||
const input = labelledFileInput();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => {
|
||||
queueMicrotask(() => input.dispatchEvent(new Event("cancel")));
|
||||
}),
|
||||
});
|
||||
const harness = createHarness();
|
||||
const picker = new NativeInputFilePicker({
|
||||
input,
|
||||
...harness,
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
|
||||
expect(await picker.select({ policy })).toEqual({
|
||||
ok: true,
|
||||
value: { kind: "DISMISSED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("resets the native input and supports same-file reselection", async () => {
|
||||
const input = labelledFileInput();
|
||||
const selected = new File(["hello"], "notes.txt", {
|
||||
type: "text/plain",
|
||||
lastModified: 1,
|
||||
});
|
||||
Object.defineProperty(input, "files", {
|
||||
configurable: true,
|
||||
value: [selected],
|
||||
});
|
||||
const showPicker = vi.fn(() => {
|
||||
queueMicrotask(() => input.dispatchEvent(new Event("change")));
|
||||
});
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
configurable: true,
|
||||
value: showPicker,
|
||||
});
|
||||
let sequence = 0;
|
||||
const { policies, vault } = createHarness(policyDefinition, {
|
||||
createReference: () => `file:${sequence++}`,
|
||||
});
|
||||
const picker = new NativeInputFilePicker({
|
||||
input,
|
||||
vault,
|
||||
policies,
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
|
||||
const first = await picker.select({ policy });
|
||||
const second = await picker.select({ policy });
|
||||
expect(first).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "SELECTED" },
|
||||
});
|
||||
expect(second).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "SELECTED" },
|
||||
});
|
||||
expect(showPicker).toHaveBeenCalledTimes(2);
|
||||
expect(input.value).toBe("");
|
||||
expect(vault.activeReferenceCount).toBe(2);
|
||||
});
|
||||
|
||||
it("snapshots selection policy before awaiting picker events", async () => {
|
||||
const input = labelledFileInput();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
const mutablePolicy = {
|
||||
policyId: "mutable-v1",
|
||||
purpose: "attachment",
|
||||
classification: "PERSONAL" as const,
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxFileBytes: 100,
|
||||
maxTotalBytes: 100,
|
||||
allowEmpty: false,
|
||||
accept: [
|
||||
{ mediaType: "text/plain", extensions: [".txt"] },
|
||||
],
|
||||
};
|
||||
const harness = createHarness(mutablePolicy, {
|
||||
createReference: () => "file:snapshot",
|
||||
});
|
||||
const picker = new NativeInputFilePicker({
|
||||
input,
|
||||
...harness,
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
const pending = picker.select({ policy });
|
||||
|
||||
mutablePolicy.maxFileBytes = 1;
|
||||
mutablePolicy.maxTotalBytes = 1;
|
||||
mutablePolicy.accept[0]!.extensions[0] = ".png";
|
||||
Object.defineProperty(input, "files", {
|
||||
configurable: true,
|
||||
value: [
|
||||
new File(["hello"], "notes.txt", {
|
||||
type: "text/plain",
|
||||
lastModified: 1,
|
||||
}),
|
||||
],
|
||||
});
|
||||
input.dispatchEvent(new Event("change"));
|
||||
|
||||
expect(await pending).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "SELECTED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("honors AbortSignal while a native dialog is pending", async () => {
|
||||
const input = labelledFileInput();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
const harness = createHarness();
|
||||
const picker = new NativeInputFilePicker({
|
||||
input,
|
||||
...harness,
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const pending = picker.select({ policy, signal: controller.signal });
|
||||
controller.abort();
|
||||
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("gives a late selected FileList a grace window after focus returns", async () => {
|
||||
const input = labelledFileInput();
|
||||
const selected = new File(["late"], "late.txt", {
|
||||
type: "text/plain",
|
||||
lastModified: 1,
|
||||
});
|
||||
let fallback: (() => void) | undefined;
|
||||
let delay: number | undefined;
|
||||
const clearTimeout = vi.fn();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => {
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
}),
|
||||
});
|
||||
const { policies, vault } = createHarness(policyDefinition, {
|
||||
createReference: () => "file:late",
|
||||
});
|
||||
const picker = new NativeInputFilePicker({
|
||||
input,
|
||||
vault,
|
||||
policies,
|
||||
userActivation: { isActive: true },
|
||||
scheduler: {
|
||||
setTimeout(callback, delayMs) {
|
||||
fallback = callback;
|
||||
delay = delayMs;
|
||||
return 1;
|
||||
},
|
||||
clearTimeout,
|
||||
},
|
||||
});
|
||||
const pending = picker.select({ policy });
|
||||
expect(delay).toBe(DEFAULT_NATIVE_PICKER_FOCUS_GRACE_MS);
|
||||
|
||||
Object.defineProperty(input, "files", {
|
||||
configurable: true,
|
||||
value: [selected],
|
||||
});
|
||||
fallback?.();
|
||||
|
||||
expect(await pending).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "SELECTED",
|
||||
files: [{ displayName: "late.txt" }],
|
||||
},
|
||||
});
|
||||
expect(vault.activeReferenceCount).toBe(1);
|
||||
});
|
||||
|
||||
it("lets the native cancel event settle before the focus fallback", async () => {
|
||||
const input = labelledFileInput();
|
||||
let fallback: (() => void) | undefined;
|
||||
const clearTimeout = vi.fn();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
configurable: true,
|
||||
value: vi.fn(() => {
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
input.dispatchEvent(new Event("cancel"));
|
||||
}),
|
||||
});
|
||||
const { policies, vault } = createHarness();
|
||||
const picker = new NativeInputFilePicker({
|
||||
input,
|
||||
vault,
|
||||
policies,
|
||||
userActivation: { isActive: true },
|
||||
scheduler: {
|
||||
setTimeout(callback) {
|
||||
fallback = callback;
|
||||
return 1;
|
||||
},
|
||||
clearTimeout,
|
||||
},
|
||||
});
|
||||
|
||||
expect(await picker.select({ policy })).toEqual({
|
||||
ok: true,
|
||||
value: { kind: "DISMISSED" },
|
||||
});
|
||||
expect(clearTimeout).toHaveBeenCalledWith(1);
|
||||
fallback?.();
|
||||
expect(vault.activeReferenceCount).toBe(0);
|
||||
});
|
||||
|
||||
it("captures native input, window, and scheduler methods at construction", async () => {
|
||||
const input = labelledFileInput();
|
||||
let focus: EventListener | undefined;
|
||||
const originalWindowAdd = vi.fn(
|
||||
(_type: string, listener: EventListener) => {
|
||||
focus = listener;
|
||||
},
|
||||
);
|
||||
const originalWindowRemove = vi.fn();
|
||||
const windowHost = {
|
||||
addEventListener: originalWindowAdd,
|
||||
removeEventListener: originalWindowRemove,
|
||||
};
|
||||
const originalSetTimeout = vi.fn(
|
||||
(callback: () => void) => {
|
||||
queueMicrotask(callback);
|
||||
return 1;
|
||||
},
|
||||
);
|
||||
const originalClearTimeout = vi.fn();
|
||||
const scheduler = {
|
||||
setTimeout: originalSetTimeout,
|
||||
clearTimeout: originalClearTimeout,
|
||||
};
|
||||
const originalShowPicker = vi.fn(() => {
|
||||
focus?.(new Event("focus"));
|
||||
});
|
||||
const replacedShowPicker = vi.fn();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalShowPicker,
|
||||
});
|
||||
const harness = createHarness();
|
||||
const picker = new NativeInputFilePicker({
|
||||
input,
|
||||
...harness,
|
||||
window: windowHost as Pick<
|
||||
Window,
|
||||
"addEventListener" | "removeEventListener"
|
||||
>,
|
||||
scheduler,
|
||||
userActivation: { isActive: true },
|
||||
focusFallbackGraceMs: 0,
|
||||
});
|
||||
|
||||
input.showPicker = replacedShowPicker;
|
||||
windowHost.addEventListener = vi.fn();
|
||||
windowHost.removeEventListener = vi.fn();
|
||||
scheduler.setTimeout = vi.fn();
|
||||
scheduler.clearTimeout = vi.fn();
|
||||
|
||||
expect(await picker.select({ policy })).toEqual({
|
||||
ok: true,
|
||||
value: { kind: "DISMISSED" },
|
||||
});
|
||||
expect(originalShowPicker).toHaveBeenCalledOnce();
|
||||
expect(replacedShowPicker).not.toHaveBeenCalled();
|
||||
expect(originalWindowAdd).toHaveBeenCalledOnce();
|
||||
expect(originalWindowRemove).toHaveBeenCalledOnce();
|
||||
expect(originalSetTimeout).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("requires a connected and labelled file input", async () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
const harness = createHarness();
|
||||
const picker = new NativeInputFilePicker({
|
||||
input,
|
||||
...harness,
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
|
||||
expect(await picker.select({ policy })).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps enhanced dismissal distinct from an active abort", async () => {
|
||||
const dismissedHarness = createHarness();
|
||||
const dismissed = new EnhancedFilePicker({
|
||||
showOpenFilePicker: async () => {
|
||||
throw new DOMException("closed", "AbortError");
|
||||
},
|
||||
...dismissedHarness,
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
expect(await dismissed.select({ policy })).toEqual({
|
||||
ok: true,
|
||||
value: { kind: "DISMISSED" },
|
||||
});
|
||||
|
||||
let rejectPicker:
|
||||
| ((reason: DOMException) => void)
|
||||
| undefined;
|
||||
const controller = new AbortController();
|
||||
const abortedHarness = createHarness();
|
||||
const aborted = new EnhancedFilePicker({
|
||||
showOpenFilePicker: () =>
|
||||
new Promise((_, reject: (reason: DOMException) => void) => {
|
||||
rejectPicker = reject;
|
||||
}),
|
||||
...abortedHarness,
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
const pending = aborted.select({
|
||||
policy,
|
||||
signal: controller.signal,
|
||||
});
|
||||
controller.abort();
|
||||
rejectPicker?.(new DOMException("closed", "AbortError"));
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects excessive enhanced selections before reading handles", async () => {
|
||||
const getFile = vi.fn(async () => new File(["a"], "a.txt"));
|
||||
const multiplePolicy = {
|
||||
...policyDefinition,
|
||||
multiple: true,
|
||||
maxCount: 1,
|
||||
};
|
||||
const harness = createHarness(multiplePolicy);
|
||||
const picker = new EnhancedFilePicker({
|
||||
showOpenFilePicker: async () => [
|
||||
{ kind: "file", name: "a.txt", getFile },
|
||||
{ kind: "file", name: "b.txt", getFile },
|
||||
],
|
||||
...harness,
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
|
||||
expect(await picker.select({ policy })).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
expect(getFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("snapshots an enhanced picker request before awaiting the host", async () => {
|
||||
let resolvePicker:
|
||||
| ((handles: readonly {
|
||||
kind: "file";
|
||||
name: string;
|
||||
getFile(): Promise<File>;
|
||||
}[]) => void)
|
||||
| undefined;
|
||||
const harness = createHarness();
|
||||
const picker = new EnhancedFilePicker({
|
||||
showOpenFilePicker: () =>
|
||||
new Promise((resolve) => {
|
||||
resolvePicker = resolve;
|
||||
}),
|
||||
...harness,
|
||||
userActivation: { isActive: true },
|
||||
});
|
||||
const originalController = new AbortController();
|
||||
const replacementController = new AbortController();
|
||||
const request = {
|
||||
policy,
|
||||
signal: originalController.signal,
|
||||
};
|
||||
const pending = picker.select(request);
|
||||
|
||||
(
|
||||
request as {
|
||||
policy: typeof policy;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
).policy = browserFilePolicyReference(
|
||||
"forged",
|
||||
"forged",
|
||||
);
|
||||
(
|
||||
request as { signal: AbortSignal }
|
||||
).signal = replacementController.signal;
|
||||
replacementController.abort();
|
||||
resolvePicker?.([
|
||||
{
|
||||
kind: "file",
|
||||
name: "safe.txt",
|
||||
async getFile() {
|
||||
return new File(["safe"], "safe.txt", {
|
||||
type: "text/plain",
|
||||
lastModified: 1,
|
||||
});
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(await pending).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "SELECTED",
|
||||
files: [{ displayName: "safe.txt" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
BrowserFilePolicyRegistry,
|
||||
browserFilePolicyReference,
|
||||
} from "../../src/adapters/browser-files/browser-file-policy-registry.ts";
|
||||
import { BrowserFileVault } from "../../src/adapters/browser-files/browser-file-vault.ts";
|
||||
import {
|
||||
BrowserTransientPreview,
|
||||
ObjectUrlLeaseRegistry,
|
||||
} from "../../src/adapters/browser-files/object-url-lease.ts";
|
||||
|
||||
const hardLimits = Object.freeze({
|
||||
maxInspectionBytes: 64,
|
||||
maxRetainedFileBytes: 1_024,
|
||||
maxPreviewBytes: 1_024,
|
||||
maxObjectUrlBytes: 1_024,
|
||||
maxTransferBytes: 1_024,
|
||||
});
|
||||
|
||||
describe("browser file composition policy registry", () => {
|
||||
it("deep-snapshots every policy axis and permits only limit reductions", () => {
|
||||
const reference = browserFilePolicyReference(
|
||||
"documents",
|
||||
"select-preview-download-pdf",
|
||||
);
|
||||
const selection = {
|
||||
policyId: "documents-v1",
|
||||
purpose: "document",
|
||||
classification: "PERSONAL" as const,
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxFileBytes: 100,
|
||||
maxTotalBytes: 100,
|
||||
allowEmpty: false,
|
||||
accept: [
|
||||
{ mediaType: "application/pdf", extensions: [".pdf"] },
|
||||
],
|
||||
};
|
||||
const inspection = {
|
||||
policyId: "documents-v1",
|
||||
maxInspectionBytes: 8,
|
||||
acceptedSignatures: [
|
||||
{
|
||||
mediaType: "application/pdf",
|
||||
extensions: [".pdf"],
|
||||
patterns: [{ offset: 0, bytes: [0x25, 0x50] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const preview = {
|
||||
allowedMediaTypes: ["application/pdf"],
|
||||
maxPreviewBytes: 100,
|
||||
};
|
||||
const download = {
|
||||
strategy: "PROMPT_AND_STREAM" as const,
|
||||
mediaType: "application/pdf",
|
||||
safeExtension: ".pdf",
|
||||
maxTransferBytes: 100,
|
||||
maxBufferedBytes: 50,
|
||||
integrity: "REQUIRED" as const,
|
||||
};
|
||||
const registry = new BrowserFilePolicyRegistry({
|
||||
profiles: [
|
||||
{
|
||||
reference,
|
||||
selection,
|
||||
inspection,
|
||||
preview,
|
||||
download,
|
||||
},
|
||||
],
|
||||
hardLimits,
|
||||
});
|
||||
|
||||
selection.maxFileBytes = 1;
|
||||
selection.accept[0]!.extensions[0] = ".exe";
|
||||
inspection.acceptedSignatures[0]!.patterns[0]!.bytes[0] = 0;
|
||||
preview.allowedMediaTypes[0] = "text/html";
|
||||
(
|
||||
download as { strategy: string }
|
||||
).strategy = "BROWSER_MANAGED";
|
||||
download.safeExtension = ".exe";
|
||||
|
||||
expect(
|
||||
registry.resolveSelection(reference, {
|
||||
maxFileBytes: 80,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
maxFileBytes: 80,
|
||||
accept: [{ extensions: [".pdf"] }],
|
||||
},
|
||||
});
|
||||
expect(
|
||||
registry.resolveSelection(reference, {
|
||||
maxFileBytes: 101,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
expect(registry.resolveInspection(reference)).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
acceptedSignatures: [
|
||||
{ patterns: [{ bytes: [0x25, 0x50] }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
const resolvedPreview = registry.resolvePreview(reference);
|
||||
expect(resolvedPreview.ok).toBe(true);
|
||||
if (resolvedPreview.ok) {
|
||||
expect(
|
||||
resolvedPreview.value.allowedMediaTypes.has(
|
||||
"application/pdf",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolvedPreview.value.allowedMediaTypes.has("text/html"),
|
||||
).toBe(false);
|
||||
(
|
||||
resolvedPreview.value.allowedMediaTypes as Set<string>
|
||||
).clear();
|
||||
expect(
|
||||
registry.resolvePreview(reference),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
allowedMediaTypes: new Set(["application/pdf"]),
|
||||
},
|
||||
});
|
||||
}
|
||||
expect(
|
||||
registry.resolveDownload(reference, {}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
strategy: "PROMPT_AND_STREAM",
|
||||
safeExtension: ".pdf",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
registry.resolveDownload(
|
||||
browserFilePolicyReference("unknown", "unknown"),
|
||||
{},
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
expect(
|
||||
registry.resolveSelection(
|
||||
browserFilePolicyReference(
|
||||
"documents",
|
||||
"select-preview-download-pdf",
|
||||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
expect(
|
||||
registry.resolveSelection(
|
||||
{
|
||||
policyKey: "documents",
|
||||
intention: "select-preview-download-pdf",
|
||||
} as typeof reference,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("binds verification receipts to the exact profile, not a reused policyId", async () => {
|
||||
const profileA = browserFilePolicyReference(
|
||||
"images-a",
|
||||
"preview-avatar",
|
||||
);
|
||||
const profileB = browserFilePolicyReference(
|
||||
"images-b",
|
||||
"preview-banner",
|
||||
);
|
||||
const inspection = {
|
||||
policyId: "shared-png-v1",
|
||||
maxInspectionBytes: 8,
|
||||
acceptedSignatures: [
|
||||
{
|
||||
mediaType: "image/png",
|
||||
extensions: [".png"],
|
||||
patterns: [{ offset: 0, bytes: [0x89, 0x50] }],
|
||||
},
|
||||
],
|
||||
};
|
||||
const policies = new BrowserFilePolicyRegistry({
|
||||
profiles: [profileA, profileB].map((reference) => ({
|
||||
reference,
|
||||
inspection,
|
||||
preview: {
|
||||
allowedMediaTypes: ["image/png"],
|
||||
maxPreviewBytes: 16,
|
||||
},
|
||||
})),
|
||||
hardLimits,
|
||||
});
|
||||
const vault = new BrowserFileVault({
|
||||
policies,
|
||||
createReference: () => "file:profile-bound",
|
||||
createVerificationReceipt: () =>
|
||||
"verification:profile-bound",
|
||||
});
|
||||
const captured = vault.captureFiles(
|
||||
[
|
||||
new File(
|
||||
[new Uint8Array([0x89, 0x50, 1])],
|
||||
"avatar.png",
|
||||
{ type: "image/png", lastModified: 1 },
|
||||
),
|
||||
],
|
||||
{
|
||||
policyId: "capture-v1",
|
||||
purpose: "capture",
|
||||
classification: "PERSONAL",
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxFileBytes: 16,
|
||||
maxTotalBytes: 16,
|
||||
allowEmpty: false,
|
||||
accept: [
|
||||
{ mediaType: "image/png", extensions: [".png"] },
|
||||
],
|
||||
},
|
||||
);
|
||||
expect(captured.ok).toBe(true);
|
||||
if (!captured.ok || !captured.value[0]) return;
|
||||
const inspected = await vault.inspect({
|
||||
ref: captured.value[0].ref,
|
||||
policy: profileA,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
expect(inspected.ok).toBe(true);
|
||||
if (!inspected.ok || !inspected.value.verificationReceipt) {
|
||||
return;
|
||||
}
|
||||
const createObjectURL = vi.fn(() => "blob:profile-bound");
|
||||
const previews = new BrowserTransientPreview({
|
||||
files: vault,
|
||||
policies,
|
||||
hardMaxPreviewBytes: 16,
|
||||
leases: new ObjectUrlLeaseRegistry({
|
||||
createObjectURL,
|
||||
revokeObjectURL() {},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(
|
||||
await previews.create({
|
||||
ref: captured.value[0].ref,
|
||||
verificationReceipt:
|
||||
inspected.value.verificationReceipt,
|
||||
policy: profileB,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
expect(createObjectURL).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await previews.create({
|
||||
ref: captured.value[0].ref,
|
||||
verificationReceipt:
|
||||
inspected.value.verificationReceipt,
|
||||
policy: profileA,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
FileVerificationReceipt,
|
||||
LocalFileRef,
|
||||
} from "../../src/application/ports/browser-file-storage/file.ts";
|
||||
import {
|
||||
browserFilePolicyReference,
|
||||
createBrowserFileRuntime,
|
||||
type BrowserFilePolicyProfile,
|
||||
} from "../../src/adapters/browser-files/index.ts";
|
||||
|
||||
const filePolicy = browserFilePolicyReference(
|
||||
"runtime-file",
|
||||
"select-and-preview",
|
||||
);
|
||||
const downloadPolicy = browserFilePolicyReference(
|
||||
"runtime-download",
|
||||
"bounded-generated-bin",
|
||||
);
|
||||
const policies: readonly BrowserFilePolicyProfile[] = [
|
||||
{
|
||||
reference: filePolicy,
|
||||
selection: {
|
||||
policyId: "attachment-v1",
|
||||
purpose: "attachment",
|
||||
classification: "PERSONAL",
|
||||
multiple: false,
|
||||
maxCount: 1,
|
||||
maxFileBytes: 8,
|
||||
maxTotalBytes: 8,
|
||||
allowEmpty: false,
|
||||
accept: [{ mediaType: "text/plain", extensions: [".txt"] }],
|
||||
},
|
||||
inspection: {
|
||||
policyId: "preview-v1",
|
||||
maxInspectionBytes: 8,
|
||||
acceptedSignatures: [
|
||||
{
|
||||
mediaType: "image/png",
|
||||
extensions: [".png"],
|
||||
patterns: [{ offset: 0, bytes: [0x89] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
preview: {
|
||||
allowedMediaTypes: ["image/png"],
|
||||
maxPreviewBytes: 8,
|
||||
},
|
||||
},
|
||||
{
|
||||
reference: downloadPolicy,
|
||||
download: {
|
||||
strategy: "BOUNDED_OBJECT_URL",
|
||||
mediaType: "application/octet-stream",
|
||||
safeExtension: ".bin",
|
||||
maxTransferBytes: 8,
|
||||
maxBufferedBytes: 8,
|
||||
integrity: "OPTIONAL",
|
||||
},
|
||||
},
|
||||
];
|
||||
const unusedBrowserManagedCapabilities = {
|
||||
resolve() {
|
||||
return {
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: "POLICY_REJECTED" as const,
|
||||
operation: "DOWNLOAD" as const,
|
||||
retryable: false,
|
||||
recovery: "NONE" as const,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function fileInput(): HTMLInputElement {
|
||||
const label = document.createElement("label");
|
||||
label.textContent = "Attachment";
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
label.append(input);
|
||||
document.body.append(label);
|
||||
return input;
|
||||
}
|
||||
|
||||
describe("browser file runtime hard limits and disposal", () => {
|
||||
it("revokes active download leases immediately on runtime dispose", async () => {
|
||||
const revoked: string[] = [];
|
||||
let scheduled: (() => void) | undefined;
|
||||
const runtime = createBrowserFileRuntime({
|
||||
input: fileInput(),
|
||||
policies,
|
||||
limits: {
|
||||
hardMaxPreviewBytes: 8,
|
||||
hardMaxObjectUrlBytes: 8,
|
||||
hardMaxTransferBytes: 16,
|
||||
},
|
||||
objectUrlApi: {
|
||||
createObjectURL: () => "blob:runtime-download",
|
||||
revokeObjectURL: (url) => revoked.push(url),
|
||||
},
|
||||
download: {
|
||||
host: { handoff: vi.fn() },
|
||||
browserManagedCapabilities:
|
||||
unusedBrowserManagedCapabilities,
|
||||
scheduler: {
|
||||
setTimeout(callback) {
|
||||
scheduled = callback;
|
||||
return 1;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const delivery = {
|
||||
policy: downloadPolicy,
|
||||
source: {
|
||||
kind: "GENERATED" as const,
|
||||
bytes: {
|
||||
byteLength: 3,
|
||||
async *stream() {
|
||||
yield {
|
||||
ok: true as const,
|
||||
value: new Uint8Array([1, 2, 3]),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
suggestedFileName: "artifact",
|
||||
maxTransferBytes: 8,
|
||||
maxBufferedBytes: 8,
|
||||
signal: new AbortController().signal,
|
||||
onProgress() {},
|
||||
};
|
||||
expect(await runtime.downloads.deliver(delivery)).toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "BROWSER_HANDOFF" },
|
||||
});
|
||||
expect(revoked).toEqual([]);
|
||||
|
||||
runtime.dispose();
|
||||
expect(revoked).toEqual(["blob:runtime-download"]);
|
||||
expect(await runtime.downloads.deliver(delivery)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE" },
|
||||
});
|
||||
expect(
|
||||
await runtime.content.readRange({
|
||||
ref: "file:disposed" as LocalFileRef,
|
||||
offset: 0,
|
||||
length: 0,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE" },
|
||||
});
|
||||
expect(
|
||||
await runtime.previews.create({
|
||||
ref: "file:disposed" as LocalFileRef,
|
||||
verificationReceipt:
|
||||
"verification:disposed" as FileVerificationReceipt,
|
||||
policy: filePolicy,
|
||||
maxPreviewBytes: 1,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE" },
|
||||
});
|
||||
scheduled?.();
|
||||
runtime.dispose();
|
||||
expect(revoked).toEqual(["blob:runtime-download"]);
|
||||
});
|
||||
|
||||
it("aborts a pending native picker and prevents event resurrection", async () => {
|
||||
const input = fileInput();
|
||||
Object.defineProperty(input, "showPicker", {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
const runtime = createBrowserFileRuntime({
|
||||
input,
|
||||
policies,
|
||||
limits: {
|
||||
hardMaxPreviewBytes: 8,
|
||||
hardMaxObjectUrlBytes: 8,
|
||||
hardMaxTransferBytes: 16,
|
||||
},
|
||||
vault: {
|
||||
createReference: () => "file:must-not-exist",
|
||||
},
|
||||
download: {
|
||||
host: { handoff() {} },
|
||||
browserManagedCapabilities:
|
||||
unusedBrowserManagedCapabilities,
|
||||
},
|
||||
});
|
||||
const pending = runtime.baselinePicker.select({
|
||||
policy: filePolicy,
|
||||
});
|
||||
|
||||
runtime.dispose();
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
Object.defineProperty(input, "files", {
|
||||
configurable: true,
|
||||
value: [
|
||||
new File(["late"], "late.txt", {
|
||||
type: "text/plain",
|
||||
lastModified: 1,
|
||||
}),
|
||||
],
|
||||
});
|
||||
input.dispatchEvent(new Event("change"));
|
||||
expect(
|
||||
await runtime.baselinePicker.select({
|
||||
policy: filePolicy,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts a generated download whose producer ignores cancellation", async () => {
|
||||
let reportStarted: (() => void) | undefined;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
reportStarted = resolve;
|
||||
});
|
||||
let unblockProducer: (() => void) | undefined;
|
||||
const producerBlock = new Promise<void>((resolve) => {
|
||||
unblockProducer = resolve;
|
||||
});
|
||||
const runtime = createBrowserFileRuntime({
|
||||
input: fileInput(),
|
||||
policies,
|
||||
limits: {
|
||||
hardMaxPreviewBytes: 8,
|
||||
hardMaxObjectUrlBytes: 8,
|
||||
hardMaxTransferBytes: 16,
|
||||
},
|
||||
download: {
|
||||
host: { handoff() {} },
|
||||
browserManagedCapabilities:
|
||||
unusedBrowserManagedCapabilities,
|
||||
},
|
||||
});
|
||||
const pending = runtime.downloads.deliver({
|
||||
policy: downloadPolicy,
|
||||
source: {
|
||||
kind: "GENERATED",
|
||||
bytes: {
|
||||
byteLength: 1,
|
||||
async *stream() {
|
||||
reportStarted?.();
|
||||
await producerBlock;
|
||||
yield {
|
||||
ok: true as const,
|
||||
value: new Uint8Array([1]),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
suggestedFileName: "blocked",
|
||||
maxTransferBytes: 8,
|
||||
maxBufferedBytes: 8,
|
||||
signal: new AbortController().signal,
|
||||
onProgress() {},
|
||||
});
|
||||
|
||||
await started;
|
||||
runtime.dispose();
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED", operation: "DOWNLOAD" },
|
||||
});
|
||||
unblockProducer?.();
|
||||
});
|
||||
|
||||
it("rejects inconsistent absolute limits during composition", () => {
|
||||
expect(() =>
|
||||
createBrowserFileRuntime({
|
||||
input: fileInput(),
|
||||
policies,
|
||||
limits: {
|
||||
hardMaxPreviewBytes: 8,
|
||||
hardMaxObjectUrlBytes: 17,
|
||||
hardMaxTransferBytes: 16,
|
||||
},
|
||||
download: {
|
||||
host: { handoff() {} },
|
||||
browserManagedCapabilities:
|
||||
unusedBrowserManagedCapabilities,
|
||||
},
|
||||
}),
|
||||
).toThrowError("Browser file runtime hard limits are invalid.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
composeBrowserRpcOperationRegistry,
|
||||
composeBrowserRpcProviderProfileRegistry,
|
||||
composeBrowserRpcRequestEncoderRegistry,
|
||||
defineBrowserRpcOperation,
|
||||
defineBrowserRpcProviderProfile,
|
||||
validateBrowserRpcContractBindings,
|
||||
type BrowserRpcProviderProfile,
|
||||
} from "../../../src/contracts/browser-rpc.ts";
|
||||
import {
|
||||
DESCRIPTOR_DIGEST,
|
||||
MAPPERS,
|
||||
RUNTIME_DIGEST,
|
||||
SCHEMA_CODECS,
|
||||
STREAM_ENCODER,
|
||||
UNARY_ENCODER,
|
||||
streamOperation,
|
||||
streamProfile,
|
||||
unaryOperation,
|
||||
unaryProfile,
|
||||
} from "./fixture.ts";
|
||||
|
||||
describe("Browser RPC contract registry", () => {
|
||||
it("closes exact operation, provider, schema, mapper and encoder bindings", () => {
|
||||
const operation = unaryOperation();
|
||||
const profile = unaryProfile();
|
||||
const operations = composeBrowserRpcOperationRegistry([
|
||||
{ GET_RPC_RESOURCE: operation },
|
||||
]);
|
||||
const profiles = composeBrowserRpcProviderProfileRegistry([
|
||||
{ CONNECT_REFERENCE_UNARY: profile },
|
||||
]);
|
||||
const encoders = composeBrowserRpcRequestEncoderRegistry([
|
||||
{ RpcResourceRequestEncoder: UNARY_ENCODER },
|
||||
]);
|
||||
|
||||
expect(
|
||||
validateBrowserRpcContractBindings({
|
||||
operations,
|
||||
profiles,
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: encoders,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(Object.isFrozen(operations)).toBe(true);
|
||||
expect(Object.isFrozen(profiles.CONNECT_REFERENCE_UNARY)).toBe(true);
|
||||
expect(
|
||||
Object.isFrozen(
|
||||
profiles.CONNECT_REFERENCE_UNARY?.allowedProcedures,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate rows and descriptor/provider drift", () => {
|
||||
const operation = unaryOperation();
|
||||
expect(() =>
|
||||
composeBrowserRpcOperationRegistry([
|
||||
{ GET_RPC_RESOURCE: operation },
|
||||
{ GET_RPC_RESOURCE: operation },
|
||||
]),
|
||||
).toThrow("duplicate Browser RPC operation");
|
||||
|
||||
expect(() =>
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: { GET_RPC_RESOURCE: operation },
|
||||
profiles: {
|
||||
CONNECT_REFERENCE_UNARY: unaryProfile({
|
||||
descriptorDigest: "c".repeat(64),
|
||||
}),
|
||||
},
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
}),
|
||||
).toThrow("provider binding is invalid");
|
||||
});
|
||||
|
||||
it("permits only a public headerless NO_SIDE_EFFECTS Connect GET", () => {
|
||||
const getProfile = defineBrowserRpcProviderProfile({
|
||||
...unaryProfile(),
|
||||
runtimeProfileId: "CONNECT_PUBLIC_GET",
|
||||
requestMethod: "GET",
|
||||
authProfileId: "ANONYMOUS",
|
||||
csrfProfileId: "NONE",
|
||||
allowedProcedures: [
|
||||
"example.resource.v1.ResourceService/GetResource",
|
||||
],
|
||||
});
|
||||
const validGet = defineBrowserRpcOperation({
|
||||
...unaryOperation(),
|
||||
runtimeProfileId: "CONNECT_PUBLIC_GET",
|
||||
authProfileId: "ANONYMOUS",
|
||||
csrfProfileId: "NONE",
|
||||
idempotencyLevel: "NO_SIDE_EFFECTS",
|
||||
dataClassification: "PUBLIC",
|
||||
});
|
||||
expect(
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: { GET_RPC_RESOURCE: validGet },
|
||||
profiles: { CONNECT_PUBLIC_GET: getProfile },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(() =>
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: {
|
||||
GET_RPC_RESOURCE: defineBrowserRpcOperation({
|
||||
...validGet,
|
||||
dataClassification: "CONFIDENTIAL",
|
||||
}),
|
||||
},
|
||||
profiles: { CONNECT_PUBLIC_GET: getProfile },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
}),
|
||||
).toThrow("GET binding is invalid");
|
||||
});
|
||||
|
||||
it("separates official grpc-web, Connect-Web and Connect tuples", () => {
|
||||
expect(() =>
|
||||
defineBrowserRpcProviderProfile({
|
||||
...streamProfile(),
|
||||
runtimeProfileId: "OFFICIAL_BINARY_STREAM",
|
||||
runtimeId: "official-grpc-web",
|
||||
runtimeVersion: "1.5.0",
|
||||
runtimeDigest: RUNTIME_DIGEST,
|
||||
protocol: "GRPC_WEB",
|
||||
runtimeKind: "OFFICIAL_GRPC_WEB_XHR",
|
||||
clientApiKind: "CALLBACK_STREAM",
|
||||
messageEncoding: "PROTO",
|
||||
framing: "GRPC_WEB_BINARY_ENVELOPE",
|
||||
deadlineDialect: "OFFICIAL_DEADLINE_METADATA",
|
||||
cancelDialect: "CLIENT_READABLE_STREAM_CANCEL",
|
||||
descriptorDigest: DESCRIPTOR_DIGEST,
|
||||
}),
|
||||
).toThrow("provider profile is invalid");
|
||||
|
||||
expect(() =>
|
||||
defineBrowserRpcProviderProfile({
|
||||
...unaryProfile(),
|
||||
framing: "GRPC_WEB_BINARY_ENVELOPE",
|
||||
}),
|
||||
).toThrow("provider profile is invalid");
|
||||
|
||||
expect(
|
||||
defineBrowserRpcProviderProfile({
|
||||
...unaryProfile(),
|
||||
runtimeProfileId: "CONNECT_GRPC_WEB_UNARY",
|
||||
protocol: "GRPC_WEB",
|
||||
framing: "GRPC_WEB_BINARY_ENVELOPE",
|
||||
deadlineDialect: "GRPC_TIMEOUT",
|
||||
}),
|
||||
).toMatchObject({
|
||||
protocol: "GRPC_WEB",
|
||||
runtimeKind: "CONNECT_WEB_FETCH",
|
||||
deadlineDialect: "GRPC_TIMEOUT",
|
||||
});
|
||||
});
|
||||
|
||||
it("revalidates raw registry rows instead of trusting TypeScript assertions", () => {
|
||||
const invalidProfile = {
|
||||
...unaryProfile(),
|
||||
messageEncoding: "XML",
|
||||
} as unknown as BrowserRpcProviderProfile;
|
||||
|
||||
expect(() =>
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: { GET_RPC_RESOURCE: unaryOperation() },
|
||||
profiles: { CONNECT_REFERENCE_UNARY: invalidProfile },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
}),
|
||||
).toThrow("provider profile is invalid");
|
||||
|
||||
expect(() =>
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: { WRONG_REGISTRY_KEY: unaryOperation() },
|
||||
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
}),
|
||||
).toThrow("operation registry is invalid");
|
||||
});
|
||||
|
||||
it("disallows frontend stream retry and unsafe unary replay", () => {
|
||||
expect(() =>
|
||||
defineBrowserRpcProviderProfile({
|
||||
...streamProfile(),
|
||||
retryOwner: "FRONTEND_ADAPTER",
|
||||
maxAttempts: 2,
|
||||
backoffMs: [10],
|
||||
retryableFailures: ["UNAVAILABLE"],
|
||||
}),
|
||||
).toThrow("provider profile is invalid");
|
||||
|
||||
const retryProfile = unaryProfile({
|
||||
retryProfileId: "RPC_RETRY_TWO",
|
||||
retryOwner: "FRONTEND_ADAPTER",
|
||||
maxAttempts: 2,
|
||||
backoffMs: [10],
|
||||
retryableFailures: ["UNAVAILABLE"],
|
||||
});
|
||||
expect(() =>
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: {
|
||||
CREATE_RPC_RESOURCE: defineBrowserRpcOperation({
|
||||
...unaryOperation(),
|
||||
operationId: "CREATE_RPC_RESOURCE",
|
||||
semantics: "COMMAND",
|
||||
replayPolicy: "NON_REPLAYABLE",
|
||||
runtimeProfileId: retryProfile.runtimeProfileId,
|
||||
retryProfileId: retryProfile.retryProfileId,
|
||||
}),
|
||||
},
|
||||
profiles: { CONNECT_REFERENCE_UNARY: retryProfile },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: {
|
||||
...UNARY_ENCODER,
|
||||
operationId: "CREATE_RPC_RESOURCE",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow("retry binding is invalid");
|
||||
});
|
||||
|
||||
it("supports a bounded Connect server-stream contract without composing it", () => {
|
||||
expect(
|
||||
validateBrowserRpcContractBindings({
|
||||
operations: {
|
||||
WATCH_RPC_RESOURCES: streamOperation(),
|
||||
},
|
||||
profiles: {
|
||||
CONNECT_REFERENCE_STREAM: streamProfile(),
|
||||
},
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceStreamRequestEncoder: STREAM_ENCODER,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createBrowserRpcRuntime,
|
||||
createUnavailableBrowserRpcTransport,
|
||||
defineBrowserRpcTransport,
|
||||
type BrowserRpcObservation,
|
||||
type BrowserRpcStreamFrame,
|
||||
type BrowserRpcTransport,
|
||||
} from "../../../src/adapters/browser-rpc/index.ts";
|
||||
import type { Result } from "../../../src/application/result.ts";
|
||||
import type { AppFailure } from "../../../src/contracts/errors.ts";
|
||||
import {
|
||||
MAPPERS,
|
||||
SCHEMA_CODECS,
|
||||
STREAM_ENCODER,
|
||||
UNARY_ENCODER,
|
||||
isResourceView,
|
||||
streamOperation,
|
||||
streamProfile,
|
||||
unaryOperation,
|
||||
unaryProfile,
|
||||
type ResourceView,
|
||||
} from "./fixture.ts";
|
||||
|
||||
describe("Browser RPC provider-neutral runtime", () => {
|
||||
it("validates, encodes, maps and admits only the typed unary result", async () => {
|
||||
const observations: BrowserRpcObservation[] = [];
|
||||
const transport = defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "UNARY",
|
||||
async invokeUnary(call) {
|
||||
expect(call.request).toEqual({ resourceId: "resource-1" });
|
||||
expect(call.timeoutMs).toBeGreaterThan(0);
|
||||
return Object.freeze({
|
||||
ok: true,
|
||||
message: Object.freeze({ id: "resource-1", name: "Resource one" }),
|
||||
encodedBytes: 48,
|
||||
});
|
||||
},
|
||||
});
|
||||
const port = unaryRuntime(transport, {
|
||||
observe(value) {
|
||||
observations.push(value);
|
||||
},
|
||||
}).bindUnary("GET_RPC_RESOURCE", isResourceView);
|
||||
|
||||
await expect(port.execute({ resourceId: "resource-1" })).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { id: "resource-1", label: "Resource one" },
|
||||
});
|
||||
expect(observations).toEqual([
|
||||
{
|
||||
operationId: "GET_RPC_RESOURCE",
|
||||
protocol: "CONNECT_HTTP",
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
rpcKind: "UNARY",
|
||||
outcome: "SUCCESS",
|
||||
attemptCount: 1,
|
||||
messageCount: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails before transport on invalid input or unexpected idempotency metadata", async () => {
|
||||
let calls = 0;
|
||||
const transport = defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "UNARY",
|
||||
async invokeUnary() {
|
||||
calls += 1;
|
||||
return {
|
||||
ok: false,
|
||||
failure: { code: "UNAVAILABLE" },
|
||||
};
|
||||
},
|
||||
});
|
||||
const port = unaryRuntime(transport).bindUnary(
|
||||
"GET_RPC_RESOURCE",
|
||||
isResourceView,
|
||||
);
|
||||
|
||||
const invalid = await port.execute({ resourceId: 42 });
|
||||
expect(invalid).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "VALIDATION_REJECTED",
|
||||
code: "RPC_REQUEST_SCHEMA_INVALID",
|
||||
},
|
||||
});
|
||||
const metadata = await port.execute(
|
||||
{ resourceId: "resource-1" },
|
||||
{ idempotencyKey: "caller-key-is-not-allowed" },
|
||||
);
|
||||
expect(metadata).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "VALIDATION_REJECTED",
|
||||
code: "RPC_IDEMPOTENCY_KEY_INVALID",
|
||||
},
|
||||
});
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the frontend retry owner bounded by replay policy and one total deadline", async () => {
|
||||
let calls = 0;
|
||||
const profile = unaryProfile({
|
||||
retryProfileId: "RPC_RETRY_TWO",
|
||||
retryOwner: "FRONTEND_ADAPTER",
|
||||
maxAttempts: 2,
|
||||
backoffMs: [0],
|
||||
retryableFailures: ["UNAVAILABLE"],
|
||||
maxRetryAfterMs: 100,
|
||||
});
|
||||
const operation = unaryOperation({
|
||||
retryProfileId: "RPC_RETRY_TWO",
|
||||
});
|
||||
const transport = defineBrowserRpcTransport({
|
||||
runtimeProfileId: profile.runtimeProfileId,
|
||||
providerId: profile.providerId,
|
||||
protocol: profile.protocol,
|
||||
rpcKind: profile.rpcKind,
|
||||
async invokeUnary() {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: { code: "UNAVAILABLE" },
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
message: { id: "resource-2", name: "Retried resource" },
|
||||
encodedBytes: 32,
|
||||
};
|
||||
},
|
||||
});
|
||||
const runtime = createBrowserRpcRuntime({
|
||||
operations: { GET_RPC_RESOURCE: operation },
|
||||
profiles: { CONNECT_REFERENCE_UNARY: profile },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
transports: { CONNECT_REFERENCE_UNARY: transport },
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "resource-2" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { id: "resource-2", label: "Retried resource" },
|
||||
});
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
|
||||
it("drops a late unary result after its scope generation changes", async () => {
|
||||
const transport = defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "UNARY",
|
||||
async invokeUnary() {
|
||||
return {
|
||||
ok: true,
|
||||
message: { id: "late", name: "Late resource" },
|
||||
encodedBytes: 24,
|
||||
};
|
||||
},
|
||||
});
|
||||
const runtime = createBrowserRpcRuntime({
|
||||
...baseDependencies(transport),
|
||||
generationFence: {
|
||||
capture: () => 1,
|
||||
isCurrent: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runtime
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "late" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "SCOPE_GENERATION_CHANGED",
|
||||
code: "RPC_SCOPE_GENERATION_CHANGED",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an explicit unavailable adapter without network fallback", async () => {
|
||||
const transport = createUnavailableBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "UNARY",
|
||||
});
|
||||
const result = await unaryRuntime(transport)
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "resource-1" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "SERVER_FAILURE",
|
||||
code: "RPC_UNAVAILABLE",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies valid oversized transport metadata as a response limit", async () => {
|
||||
const transport = defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "UNARY",
|
||||
async invokeUnary() {
|
||||
return {
|
||||
ok: true,
|
||||
message: { id: "large", name: "Large resource" },
|
||||
encodedBytes: 4_097,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
unaryRuntime(transport)
|
||||
.bindUnary("GET_RPC_RESOURCE", isResourceView)
|
||||
.execute({ resourceId: "large" }),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "RESPONSE_BODY_LIMIT",
|
||||
code: "RPC_RESPONSE_MESSAGE_LIMIT",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects transports that expose the wrong call shape", () => {
|
||||
expect(() =>
|
||||
defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "UNARY",
|
||||
async invokeUnary() {
|
||||
return {
|
||||
ok: false,
|
||||
failure: { code: "UNAVAILABLE" },
|
||||
};
|
||||
},
|
||||
async *openServerStream() {
|
||||
yield Object.freeze({ kind: "TERMINAL", ok: true });
|
||||
},
|
||||
}),
|
||||
).toThrow("transport is invalid");
|
||||
});
|
||||
|
||||
it("commits mapped stream messages only before one valid terminal envelope", async () => {
|
||||
const transport = streamTransport(async function* () {
|
||||
yield message("resource-1", "One", 24);
|
||||
yield message("resource-2", "Two", 24);
|
||||
yield Object.freeze({ kind: "TERMINAL", ok: true });
|
||||
});
|
||||
const observations: BrowserRpcObservation[] = [];
|
||||
const stream = streamRuntime(transport, {
|
||||
observe(value) {
|
||||
observations.push(value);
|
||||
},
|
||||
})
|
||||
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||
.open({ resourceId: "scope-1" });
|
||||
|
||||
await expect(collect(stream)).resolves.toEqual([
|
||||
{
|
||||
ok: true,
|
||||
value: { id: "resource-1", label: "One" },
|
||||
},
|
||||
{
|
||||
ok: true,
|
||||
value: { id: "resource-2", label: "Two" },
|
||||
},
|
||||
]);
|
||||
expect(observations).toEqual([
|
||||
{
|
||||
operationId: "WATCH_RPC_RESOURCES",
|
||||
protocol: "CONNECT_HTTP",
|
||||
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
outcome: "SUCCESS",
|
||||
attemptCount: 1,
|
||||
messageCount: 2,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects EOF without terminal and data after terminal", async () => {
|
||||
const missingTerminal = streamTransport(async function* () {
|
||||
yield message("resource-1", "One", 24);
|
||||
});
|
||||
const missingResults = await collect(
|
||||
streamRuntime(missingTerminal)
|
||||
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||
.open({ resourceId: "scope-1" }),
|
||||
);
|
||||
expect(missingResults.at(-1)).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "API_CONTRACT_MISMATCH",
|
||||
code: "RPC_PROTOCOL_MISMATCH",
|
||||
},
|
||||
});
|
||||
|
||||
const afterTerminal = streamTransport(async function* () {
|
||||
yield Object.freeze({ kind: "TERMINAL", ok: true });
|
||||
yield message("resource-2", "Two", 24);
|
||||
});
|
||||
await expect(
|
||||
collect(
|
||||
streamRuntime(afterTerminal)
|
||||
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||
.open({ resourceId: "scope-1" }),
|
||||
),
|
||||
).resolves.toMatchObject([
|
||||
{
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "API_CONTRACT_MISMATCH",
|
||||
code: "RPC_PROTOCOL_MISMATCH",
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("aborts the transport when stream message limits are exceeded", async () => {
|
||||
let cleaned = false;
|
||||
const transport = streamTransport(async function* (signal) {
|
||||
try {
|
||||
yield message("resource-1", "One", 24);
|
||||
yield message("resource-2", "Two", 24);
|
||||
yield Object.freeze({ kind: "TERMINAL", ok: true });
|
||||
} finally {
|
||||
cleaned = signal.aborted;
|
||||
}
|
||||
});
|
||||
const operation = streamOperation({ maxResponseMessages: 1 });
|
||||
const results = await collect(
|
||||
streamRuntime(transport, undefined, operation)
|
||||
.bindServerStream("WATCH_RPC_RESOURCES", isResourceView)
|
||||
.open({ resourceId: "scope-1" }),
|
||||
);
|
||||
|
||||
expect(results.at(-1)).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "RESPONSE_BODY_LIMIT",
|
||||
code: "RPC_STREAM_MESSAGE_LIMIT",
|
||||
},
|
||||
});
|
||||
expect(cleaned).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function unaryRuntime(
|
||||
transport: BrowserRpcTransport,
|
||||
observations?: Readonly<{
|
||||
observe(value: BrowserRpcObservation): void;
|
||||
}>,
|
||||
) {
|
||||
return createBrowserRpcRuntime({
|
||||
...baseDependencies(transport),
|
||||
observations,
|
||||
});
|
||||
}
|
||||
|
||||
function baseDependencies(transport: BrowserRpcTransport) {
|
||||
return {
|
||||
operations: { GET_RPC_RESOURCE: unaryOperation() },
|
||||
profiles: { CONNECT_REFERENCE_UNARY: unaryProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceRequestEncoder: UNARY_ENCODER,
|
||||
},
|
||||
transports: { CONNECT_REFERENCE_UNARY: transport },
|
||||
} as const;
|
||||
}
|
||||
|
||||
function streamRuntime(
|
||||
transport: BrowserRpcTransport,
|
||||
observations?: Readonly<{
|
||||
observe(value: BrowserRpcObservation): void;
|
||||
}>,
|
||||
operation = streamOperation(),
|
||||
) {
|
||||
return createBrowserRpcRuntime({
|
||||
operations: { WATCH_RPC_RESOURCES: operation },
|
||||
profiles: { CONNECT_REFERENCE_STREAM: streamProfile() },
|
||||
schemaCodecs: SCHEMA_CODECS,
|
||||
mappers: MAPPERS,
|
||||
requestEncoders: {
|
||||
RpcResourceStreamRequestEncoder: STREAM_ENCODER,
|
||||
},
|
||||
transports: { CONNECT_REFERENCE_STREAM: transport },
|
||||
observations,
|
||||
});
|
||||
}
|
||||
|
||||
function streamTransport(
|
||||
source: (
|
||||
signal: AbortSignal,
|
||||
) => AsyncIterable<BrowserRpcStreamFrame>,
|
||||
): BrowserRpcTransport {
|
||||
return defineBrowserRpcTransport({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||
providerId: "REFERENCE_RPC",
|
||||
protocol: "CONNECT_HTTP",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
openServerStream(call) {
|
||||
return source(call.signal);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
name: string,
|
||||
encodedBytes: number,
|
||||
): BrowserRpcStreamFrame {
|
||||
return Object.freeze({
|
||||
kind: "MESSAGE",
|
||||
message: Object.freeze({ id, name }),
|
||||
encodedBytes,
|
||||
});
|
||||
}
|
||||
|
||||
async function collect<Value>(
|
||||
iterable: AsyncIterable<Result<Value, AppFailure>>,
|
||||
): Promise<readonly Result<Value, AppFailure>[]> {
|
||||
const values: Result<Value, AppFailure>[] = [];
|
||||
for await (const value of iterable) values.push(value);
|
||||
return values;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import {
|
||||
defineBrowserRpcOperation,
|
||||
defineBrowserRpcProviderProfile,
|
||||
defineBrowserRpcRequestEncoder,
|
||||
type BrowserRpcOperationV3,
|
||||
type BrowserRpcProviderProfile,
|
||||
} from "../../../src/contracts/browser-rpc.ts";
|
||||
import {
|
||||
mappingSuccess,
|
||||
type InstalledBoundaryMapper,
|
||||
} from "../../../src/contracts/boundary-mapper.ts";
|
||||
import type { RuntimeSchemaCodec } from "../../../src/contracts/schema-registry.ts";
|
||||
|
||||
export const DESCRIPTOR_DIGEST = "a".repeat(64);
|
||||
export const RUNTIME_DIGEST = "b".repeat(64);
|
||||
|
||||
export type ResourceView = Readonly<{ id: string; label: string }>;
|
||||
|
||||
export function unaryProfile(
|
||||
overrides: Partial<BrowserRpcProviderProfile> = {},
|
||||
): BrowserRpcProviderProfile {
|
||||
return defineBrowserRpcProviderProfile({
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
fixedBaseUrl: "https://rpc.example.test/base/",
|
||||
runtimeId: "connect-es-web",
|
||||
runtimeVersion: "2.1.0",
|
||||
runtimeDigest: RUNTIME_DIGEST,
|
||||
protocol: "CONNECT_HTTP",
|
||||
runtimeKind: "CONNECT_WEB_FETCH",
|
||||
clientApiKind: "PROMISE_UNARY",
|
||||
rpcKind: "UNARY",
|
||||
messageEncoding: "PROTO",
|
||||
framing: "CONNECT_BARE",
|
||||
requestMethod: "POST",
|
||||
descriptorArtifactId: "buf.example.resource.v1",
|
||||
descriptorDigest: DESCRIPTOR_DIGEST,
|
||||
allowedProcedures: ["example.resource.v1.ResourceService/GetResource"],
|
||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||
csrfProfileId: "NO_CSRF_BEARER",
|
||||
corsProfileId: "SAME_ORIGIN_RPC",
|
||||
errorProfileId: "CONNECT_ERROR_V1",
|
||||
deadlineProfileId: "RPC_TOTAL_5S",
|
||||
retryProfileId: "RPC_RETRY_NONE",
|
||||
retryOwner: "NONE",
|
||||
maxAttempts: 1,
|
||||
backoffMs: [],
|
||||
retryableFailures: [],
|
||||
maxRetryAfterMs: 0,
|
||||
deadlineDialect: "CONNECT_TIMEOUT_MS",
|
||||
cancelDialect: "ABORT_SIGNAL",
|
||||
rawByteCeilingOwner: "EDGE_AND_TRANSPORT",
|
||||
streamMessageCompression: "IDENTITY_ONLY",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
export function unaryOperation(
|
||||
overrides: Partial<BrowserRpcOperationV3> = {},
|
||||
): BrowserRpcOperationV3 {
|
||||
return defineBrowserRpcOperation({
|
||||
contractVersion: 3,
|
||||
operationId: "GET_RPC_RESOURCE",
|
||||
owner: "feature-browser-rpc-test",
|
||||
protocol: "CONNECT_HTTP",
|
||||
semantics: "QUERY",
|
||||
replayPolicy: "SAFE",
|
||||
idempotencyKeyPolicy: "NONE",
|
||||
idempotencyLevel: "IDEMPOTENT",
|
||||
dataClassification: "INTERNAL",
|
||||
runtimeProfileId: "CONNECT_REFERENCE_UNARY",
|
||||
providerId: "REFERENCE_RPC",
|
||||
fullyQualifiedService: "example.resource.v1.ResourceService",
|
||||
method: "GetResource",
|
||||
rpcKind: "UNARY",
|
||||
requestMessageId: "example.resource.v1.GetResourceRequest",
|
||||
responseMessageId: "example.resource.v1.Resource",
|
||||
descriptorArtifactId: "buf.example.resource.v1",
|
||||
descriptorDigest: DESCRIPTOR_DIGEST,
|
||||
requestSchemaId: "RpcResourceRequest",
|
||||
responseSchemaId: "RpcResourceResponse",
|
||||
requestEncoderId: "RpcResourceRequestEncoder",
|
||||
mapperId: "RpcResourceMapper",
|
||||
authProfileId: "REFERENCE_EXTERNAL_BEARER",
|
||||
csrfProfileId: "NO_CSRF_BEARER",
|
||||
errorProfileId: "CONNECT_ERROR_V1",
|
||||
deadlineProfileId: "RPC_TOTAL_5S",
|
||||
retryProfileId: "RPC_RETRY_NONE",
|
||||
serverStateProfileId: "RpcResourceQuery",
|
||||
maxRequestMessageBytes: 1_024,
|
||||
maxResponseMessageBytes: 4_096,
|
||||
maxResponseMessages: 1,
|
||||
maxTotalResponseBytes: 4_096,
|
||||
maxBufferedBytes: 4_096,
|
||||
idleDeadlineMs: null,
|
||||
totalDeadlineMs: 5_000,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
export function streamProfile(
|
||||
overrides: Partial<BrowserRpcProviderProfile> = {},
|
||||
): BrowserRpcProviderProfile {
|
||||
return defineBrowserRpcProviderProfile({
|
||||
...unaryProfile(),
|
||||
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||
clientApiKind: "ASYNC_ITERABLE",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
framing: "CONNECT_ENVELOPE",
|
||||
allowedProcedures: [
|
||||
"example.resource.v1.ResourceService/WatchResources",
|
||||
],
|
||||
retryProfileId: "RPC_STREAM_RETRY_NONE",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
export function streamOperation(
|
||||
overrides: Partial<BrowserRpcOperationV3> = {},
|
||||
): BrowserRpcOperationV3 {
|
||||
return defineBrowserRpcOperation({
|
||||
...unaryOperation(),
|
||||
operationId: "WATCH_RPC_RESOURCES",
|
||||
semantics: "SERVER_STREAM",
|
||||
runtimeProfileId: "CONNECT_REFERENCE_STREAM",
|
||||
method: "WatchResources",
|
||||
rpcKind: "SERVER_STREAM",
|
||||
requestEncoderId: "RpcResourceStreamRequestEncoder",
|
||||
retryProfileId: "RPC_STREAM_RETRY_NONE",
|
||||
serverStateProfileId: null,
|
||||
maxResponseMessages: 4,
|
||||
maxTotalResponseBytes: 16_384,
|
||||
maxBufferedBytes: 8_192,
|
||||
idleDeadlineMs: 1_000,
|
||||
totalDeadlineMs: 10_000,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
export const SCHEMA_CODECS = Object.freeze({
|
||||
RpcResourceRequest: Object.freeze({
|
||||
schemaId: "RpcResourceRequest",
|
||||
parse(value: unknown) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { resourceId?: unknown }).resourceId === "string"
|
||||
) {
|
||||
return Object.freeze({
|
||||
success: true,
|
||||
data: Object.freeze({
|
||||
resourceId: (value as { resourceId: string }).resourceId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
success: false,
|
||||
issues: Object.freeze([
|
||||
Object.freeze({ path: "resourceId", code: "INVALID_STRING" }),
|
||||
]),
|
||||
});
|
||||
},
|
||||
}),
|
||||
RpcResourceResponse: Object.freeze({
|
||||
schemaId: "RpcResourceResponse",
|
||||
parse(value: unknown) {
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { id?: unknown }).id === "string" &&
|
||||
typeof (value as { name?: unknown }).name === "string"
|
||||
) {
|
||||
return Object.freeze({
|
||||
success: true,
|
||||
data: Object.freeze({
|
||||
id: (value as { id: string }).id,
|
||||
name: (value as { name: string }).name,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
success: false,
|
||||
issues: Object.freeze([
|
||||
Object.freeze({ path: "", code: "INVALID_RESOURCE" }),
|
||||
]),
|
||||
});
|
||||
},
|
||||
}),
|
||||
} satisfies Readonly<Record<string, RuntimeSchemaCodec>>);
|
||||
|
||||
export const MAPPERS = Object.freeze({
|
||||
RpcResourceMapper: Object.freeze({
|
||||
mapperId: "RpcResourceMapper",
|
||||
mapperVersion: 1,
|
||||
inputSchemaId: "RpcResourceResponse",
|
||||
outputContractId: "ResourceView",
|
||||
owner: "feature-browser-rpc-test",
|
||||
maxOutputItems: 1,
|
||||
map(input: unknown) {
|
||||
const resource = input as Readonly<{ id: string; name: string }>;
|
||||
return mappingSuccess<ResourceView>(
|
||||
Object.freeze({ id: resource.id, label: resource.name }),
|
||||
);
|
||||
},
|
||||
}),
|
||||
} satisfies Readonly<Record<string, InstalledBoundaryMapper>>);
|
||||
|
||||
export const UNARY_ENCODER = defineBrowserRpcRequestEncoder({
|
||||
encoderId: "RpcResourceRequestEncoder",
|
||||
operationId: "GET_RPC_RESOURCE",
|
||||
encode(value) {
|
||||
const request = value as Readonly<{ resourceId: string }>;
|
||||
return Object.freeze({
|
||||
ok: true,
|
||||
value: Object.freeze({ resourceId: request.resourceId }),
|
||||
encodedBytes: request.resourceId.length,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const STREAM_ENCODER = defineBrowserRpcRequestEncoder({
|
||||
encoderId: "RpcResourceStreamRequestEncoder",
|
||||
operationId: "WATCH_RPC_RESOURCES",
|
||||
encode(value) {
|
||||
const request = value as Readonly<{ resourceId: string }>;
|
||||
return Object.freeze({
|
||||
ok: true,
|
||||
value: Object.freeze({ resourceId: request.resourceId }),
|
||||
encodedBytes: request.resourceId.length,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export function isResourceView(value: unknown): value is ResourceView {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { id?: unknown }).id === "string" &&
|
||||
typeof (value as { label?: unknown }).label === "string"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assertValidStoragePolicy,
|
||||
type BrowserStoragePolicy,
|
||||
} from "../../src/application/ports/browser-file-storage/shared.ts";
|
||||
|
||||
const localFirstPolicy: BrowserStoragePolicy = Object.freeze({
|
||||
owner: "frontend-platform",
|
||||
namespace: "draft-attachments",
|
||||
classification: "PERSONAL",
|
||||
authority: "LOCAL_FIRST",
|
||||
accountScope: "OPAQUE_PARTITION",
|
||||
retention: Object.freeze({ kind: "UNTIL_SYNCED" }),
|
||||
softBudgetBytes: 8 * 1024 * 1024,
|
||||
hardBudgetBytes: 16 * 1024 * 1024,
|
||||
evictionPriority: "USER_AUTHORED",
|
||||
logoutAction: "EXPORT_THEN_PURGE",
|
||||
accountDeletionAction: "PURGE_PARTITION",
|
||||
pressureAction: "RETAIN",
|
||||
unavailableFallback: "EXPORT_REQUIRED",
|
||||
});
|
||||
|
||||
describe("browser storage policy", () => {
|
||||
it("accepts a coherent dataset policy snapshot", () => {
|
||||
expect(() => assertValidStoragePolicy(localFirstPolicy)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts explicitly evictable reconstructable origin data", () => {
|
||||
expect(() =>
|
||||
assertValidStoragePolicy({
|
||||
...localFirstPolicy,
|
||||
classification: "PUBLIC",
|
||||
authority: "RECONSTRUCTABLE",
|
||||
accountScope: "ORIGIN_SHARED",
|
||||
retention: { kind: "TTL", maxAgeMs: 60_000 },
|
||||
evictionPriority: "RECONSTRUCTABLE",
|
||||
logoutAction: "KEEP_ORIGIN_SHARED",
|
||||
accountDeletionAction: "KEEP_ORIGIN_SHARED",
|
||||
pressureAction: "EVICT_RECONSTRUCTABLE",
|
||||
unavailableFallback: "ONLINE_ONLY",
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "unsafe governance owner",
|
||||
patch: { owner: " account@example.com " },
|
||||
},
|
||||
{
|
||||
name: "personal origin-shared data",
|
||||
patch: {
|
||||
accountScope: "ORIGIN_SHARED",
|
||||
logoutAction: "KEEP_ORIGIN_SHARED",
|
||||
accountDeletionAction: "KEEP_ORIGIN_SHARED",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "partition without purge",
|
||||
patch: { logoutAction: "KEEP_ORIGIN_SHARED" },
|
||||
},
|
||||
{
|
||||
name: "unsynced partition without export",
|
||||
patch: { logoutAction: "PURGE_PARTITION" },
|
||||
},
|
||||
{
|
||||
name: "unsafe pressure eviction",
|
||||
patch: { pressureAction: "EVICT_RECONSTRUCTABLE" },
|
||||
},
|
||||
{
|
||||
name: "zero hard budget",
|
||||
patch: { softBudgetBytes: 0, hardBudgetBytes: 0 },
|
||||
},
|
||||
] as const)("rejects $name", ({ patch }) => {
|
||||
expect(() =>
|
||||
assertValidStoragePolicy({
|
||||
...localFirstPolicy,
|
||||
...patch,
|
||||
} as BrowserStoragePolicy),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
assertCiBuildEnvironment,
|
||||
buildDate,
|
||||
ciCheckoutIdentityFailures,
|
||||
ciBuildEnvironmentFailures,
|
||||
isValidSourceDateEpoch,
|
||||
} from "../../scripts/lib/build-environment.ts";
|
||||
|
||||
const validCiEnvironment = {
|
||||
CI: "true",
|
||||
VITE_BUILD_ID: "gitea-42-1",
|
||||
VITE_COMMIT_SHA: "a".repeat(40),
|
||||
RELEASE_ID: "refs/heads/develop-42-1",
|
||||
CI_RUNNER_IMAGE: `registry.test/frontend-runner@sha256:${"b".repeat(64)}`,
|
||||
SOURCE_DATE_EPOCH: "946684800",
|
||||
};
|
||||
|
||||
describe("CI build environment", () => {
|
||||
it("requires complete release identity only in CI", () => {
|
||||
expect(ciBuildEnvironmentFailures({ CI: "false" })).toEqual([]);
|
||||
expect(ciBuildEnvironmentFailures({ CI: "true" })).toEqual(
|
||||
expect.arrayContaining([
|
||||
"missing required CI build environment: VITE_BUILD_ID",
|
||||
"missing required CI build environment: VITE_COMMIT_SHA",
|
||||
"missing required CI build environment: RELEASE_ID",
|
||||
"missing required CI build environment: CI_RUNNER_IMAGE",
|
||||
"missing required CI build environment: SOURCE_DATE_EPOCH",
|
||||
]),
|
||||
);
|
||||
expect(() => assertCiBuildEnvironment(validCiEnvironment)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects abbreviated commit IDs and invalid epochs", () => {
|
||||
expect(
|
||||
ciBuildEnvironmentFailures({
|
||||
...validCiEnvironment,
|
||||
VITE_COMMIT_SHA: "abc123",
|
||||
SOURCE_DATE_EPOCH: "-1",
|
||||
CI_RUNNER_IMAGE: "ubuntu-latest-node24",
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"VITE_COMMIT_SHA must be a full 40- or 64-character hexadecimal commit ID",
|
||||
"SOURCE_DATE_EPOCH must be non-negative epoch seconds",
|
||||
"CI_RUNNER_IMAGE must end with an immutable @sha256 image digest",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("binds the configured identity and timestamp to the checkout", () => {
|
||||
expect(
|
||||
ciCheckoutIdentityFailures(validCiEnvironment, {
|
||||
commitSha: "b".repeat(40),
|
||||
sourceDateEpoch: "946684801",
|
||||
}),
|
||||
).toEqual([
|
||||
"VITE_COMMIT_SHA does not identify the checked-out commit",
|
||||
"SOURCE_DATE_EPOCH does not match the checked-out commit timestamp",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses SOURCE_DATE_EPOCH as the deterministic build timestamp", () => {
|
||||
expect(isValidSourceDateEpoch("946684800")).toBe(true);
|
||||
expect(isValidSourceDateEpoch("not-an-epoch")).toBe(false);
|
||||
expect(buildDate(validCiEnvironment).toISOString()).toBe(
|
||||
"2000-01-01T00:00:00.000Z",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { classifyViteJavascript } from "../../scripts/lib/classify-vite-bundle.mjs";
|
||||
import { classifyViteJavascript } from "../../scripts/lib/classify-vite-bundle.ts";
|
||||
|
||||
describe("Vite bundle classification", () => {
|
||||
it("counts transitive static imports as initial and keeps dynamic chunks lazy", () => {
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createApplication } from "../../src/application/create-application.js";
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js";
|
||||
import type { StoragePort } from "../../src/application/ports/storage-port.js";
|
||||
import type { DiagnosticsPort } from "../../src/application/ports/diagnostics-port.js";
|
||||
import type { TelemetryPort } from "../../src/application/ports/telemetry-port.js";
|
||||
import { createApplication } from "../../src/application/create-application.ts";
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||
import type { StoragePort } from "../../src/application/ports/storage-port.ts";
|
||||
import type { DiagnosticsPort } from "../../src/application/ports/diagnostics-port.ts";
|
||||
import type { TelemetryPort } from "../../src/application/ports/telemetry-port.ts";
|
||||
|
||||
type ReleaseFixture = {
|
||||
buildId: string;
|
||||
@@ -125,7 +125,7 @@ describe("production chunk recovery application input", () => {
|
||||
read: () => ({
|
||||
ok: false as const,
|
||||
error: {
|
||||
kind: "STORAGE_UNAVAILABLE",
|
||||
kind: "STORAGE_UNAVAILABLE" as const,
|
||||
code: "STORAGE_UNAVAILABLE",
|
||||
retryable: false,
|
||||
operationId: "STORAGE",
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { decideChunkRecovery } from "../../src/application/use-cases/decide-chunk-recovery.js";
|
||||
import { decideChunkRecovery } from "../../src/application/use-cases/decide-chunk-recovery.ts";
|
||||
import type { StoragePort } from "../../src/application/ports/storage-port.ts";
|
||||
|
||||
function memoryStorage() {
|
||||
/** @type {unknown} */
|
||||
let value;
|
||||
return /** @type {import("../../src/application/ports/storage-port.js").StoragePort} */ ({
|
||||
read: () => ({ ok: /** @type {const} */ (true), value }),
|
||||
write: (_key, next) => {
|
||||
function memoryStorage(): StoragePort {
|
||||
let value: unknown;
|
||||
return {
|
||||
read: () => ({ ok: true, value }),
|
||||
write: (_key: string, next: unknown) => {
|
||||
value = next;
|
||||
return { ok: /** @type {const} */ (true) };
|
||||
return { ok: true };
|
||||
},
|
||||
remove: () => ({ ok: /** @type {const} */ (true) }),
|
||||
});
|
||||
remove: () => ({ ok: true }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("controlled chunk recovery", () => {
|
||||
it("records the release pair before allowing one reload", () => {
|
||||
const storage = memoryStorage();
|
||||
const input = {
|
||||
const input: Parameters<typeof decideChunkRecovery>[0] = {
|
||||
failureKind: "CHUNK_LOAD_FAILURE",
|
||||
manifestLoaded: true,
|
||||
currentBuildId: "build-a",
|
||||
@@ -5,8 +5,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
normalizeColorSchemePreference,
|
||||
resolveColorScheme,
|
||||
} from "../../src/application/policies/color-scheme.js";
|
||||
import { initializeColorScheme } from "../../src/bootstrap/initialize-color-scheme.js";
|
||||
} from "../../src/application/policies/color-scheme.ts";
|
||||
import { initializeColorScheme } from "../../src/bootstrap/initialize-color-scheme.ts";
|
||||
|
||||
describe("color scheme policy", () => {
|
||||
it("normalizes unknown persisted values to the safe system default", () => {
|
||||
@@ -25,8 +25,7 @@ describe("color scheme policy", () => {
|
||||
getColorScheme: () => "system",
|
||||
}, {
|
||||
documentElement: document.documentElement,
|
||||
matchMedia: () =>
|
||||
/** @type {MediaQueryList} */ ({ matches: true }),
|
||||
matchMedia: () => ({ matches: true }) as MediaQueryList,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ preference: "system", resolved: "dark" });
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
isVersionCompatible,
|
||||
parseNumericVersion,
|
||||
verifyCompatibilityTuple,
|
||||
} from "../../src/application/policies/compatibility.js";
|
||||
} from "../../src/application/policies/compatibility.ts";
|
||||
|
||||
describe("contract compatibility", () => {
|
||||
it("uses numeric version parsing rather than lexical comparison", () => {
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createConditionalValidatorStore } from "../../src/adapters/query-cache/conditional-validator-store.ts";
|
||||
import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts";
|
||||
|
||||
function scope() {
|
||||
let current = true;
|
||||
return {
|
||||
snapshot: {
|
||||
generation: 3,
|
||||
fingerprint: "scope-token-00000003",
|
||||
identities: createRuntimeIdentityRegistry({
|
||||
tokenFactory: () => crypto.randomUUID(),
|
||||
}),
|
||||
isCurrent: () => current,
|
||||
},
|
||||
expire: () => {
|
||||
current = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("conditional validator CAS sidecar", () => {
|
||||
it("accepts 304 only for the exact scope, representation and cache revision", () => {
|
||||
const selectedScope = scope();
|
||||
const binding = {
|
||||
definitionId: "resource-detail-v1",
|
||||
identityToken: "identity-token-00000001",
|
||||
representationVersion: 1,
|
||||
scope: selectedScope.snapshot,
|
||||
};
|
||||
const store = createConditionalValidatorStore();
|
||||
expect(store.install(binding, '"etag-a"', 7)).toBe(true);
|
||||
expect(store.prepare(binding, 7)).toBe('"etag-a"');
|
||||
expect(store.acceptNotModified(binding, 7, true)).toBe(true);
|
||||
expect(store.acceptNotModified(binding, 8, true)).toBe(false);
|
||||
expect(store.acceptNotModified(binding, 7, false)).toBe(false);
|
||||
|
||||
selectedScope.expire();
|
||||
expect(store.prepare(binding, 7)).toBeNull();
|
||||
expect(store.acceptNotModified(binding, 7, true)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed validators and bounded-capacity overflow", () => {
|
||||
const firstScope = scope();
|
||||
const store = createConditionalValidatorStore(1);
|
||||
const first = {
|
||||
definitionId: "resource-detail-v1",
|
||||
identityToken: "identity-token-00000001",
|
||||
representationVersion: 1,
|
||||
scope: firstScope.snapshot,
|
||||
};
|
||||
expect(store.install(first, "raw-etag", 1)).toBe(false);
|
||||
expect(store.install(first, 'W/"etag-a"', 1)).toBe(true);
|
||||
expect(
|
||||
store.install(
|
||||
{ ...first, identityToken: "identity-token-00000002" },
|
||||
'"etag-b"',
|
||||
1,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,626 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
CACHE_INVALIDATION_PROTOCOL_VERSION,
|
||||
CACHE_INVALIDATION_WIRE_LIMITS,
|
||||
decodeCacheInvalidationWireEvent,
|
||||
parseCacheInvalidationWireEvent,
|
||||
type CacheInvalidationWireEvent,
|
||||
} from "../../src/contracts/cache-invalidation.ts";
|
||||
import {
|
||||
createBrowserCrossContextInvalidation,
|
||||
type BroadcastChannelFacade,
|
||||
type BroadcastMessageListener,
|
||||
type BrowserCrossContextInvalidationDependencies,
|
||||
type CrossContextInvalidationDelivery,
|
||||
type CrossContextInvalidationObservation,
|
||||
type StorageEventTargetFacade,
|
||||
type StoragePulseFacade,
|
||||
type StoragePulseListener,
|
||||
} from "../../src/adapters/cross-context-invalidation/index.ts";
|
||||
|
||||
const NOW = 1_000_000;
|
||||
const CACHE_EPOCH = "cache-epoch-0001";
|
||||
const TOPIC = "reference-resources";
|
||||
const CHANNEL_NAME = "cache-invalidation-v1";
|
||||
const STORAGE_PULSE_KEY = "ca-frontend:cache-invalidation:v1:pulse";
|
||||
|
||||
class FakeBroadcastNetwork {
|
||||
readonly channels: FakeBroadcastChannel[] = [];
|
||||
readonly messages: unknown[] = [];
|
||||
|
||||
createChannel = (name: string): FakeBroadcastChannel => {
|
||||
const channel = new FakeBroadcastChannel(name, this);
|
||||
this.channels.push(channel);
|
||||
return channel;
|
||||
};
|
||||
|
||||
emit(value: unknown): void {
|
||||
this.messages.push(value);
|
||||
for (const channel of this.channels) channel.emit(value);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeBroadcastChannel implements BroadcastChannelFacade {
|
||||
readonly listeners = new Set<BroadcastMessageListener>();
|
||||
closed = false;
|
||||
failPost = false;
|
||||
closeCount = 0;
|
||||
removeCount = 0;
|
||||
|
||||
constructor(
|
||||
readonly name: string,
|
||||
private readonly network: FakeBroadcastNetwork,
|
||||
) {}
|
||||
|
||||
postMessage(value: unknown): void {
|
||||
if (this.closed || this.failPost) {
|
||||
throw new DOMException("Broadcast failed", "InvalidStateError");
|
||||
}
|
||||
// Deliberately includes the sender. Native BroadcastChannel can still
|
||||
// deliver through another same-context channel, so the adapter must use
|
||||
// source identity rather than relying on provider echo behavior.
|
||||
this.network.emit(value);
|
||||
}
|
||||
|
||||
addEventListener(
|
||||
_type: "message",
|
||||
listener: BroadcastMessageListener,
|
||||
): void {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(
|
||||
_type: "message",
|
||||
listener: BroadcastMessageListener,
|
||||
): void {
|
||||
this.removeCount += 1;
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closeCount += 1;
|
||||
this.closed = true;
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
emit(value: unknown): void {
|
||||
if (this.closed) return;
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener({ data: value });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeStorageEventTarget implements StorageEventTargetFacade {
|
||||
readonly listeners = new Set<StoragePulseListener>();
|
||||
removeCount = 0;
|
||||
|
||||
addEventListener(
|
||||
_type: "storage",
|
||||
listener: StoragePulseListener,
|
||||
): void {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
removeEventListener(
|
||||
_type: "storage",
|
||||
listener: StoragePulseListener,
|
||||
): void {
|
||||
this.removeCount += 1;
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
emit(key: string | null, newValue: string | null): void {
|
||||
for (const listener of [...this.listeners]) {
|
||||
listener({ key, newValue });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeStorageBus {
|
||||
readonly values = new Map<string, string>();
|
||||
readonly targets = new Set<FakeStorageEventTarget>();
|
||||
|
||||
createEndpoint(): Readonly<{
|
||||
storage: FakeStorageEndpoint;
|
||||
target: FakeStorageEventTarget;
|
||||
}> {
|
||||
const target = new FakeStorageEventTarget();
|
||||
this.targets.add(target);
|
||||
return Object.freeze({
|
||||
storage: new FakeStorageEndpoint(this, target),
|
||||
target,
|
||||
});
|
||||
}
|
||||
|
||||
set(
|
||||
owner: FakeStorageEventTarget,
|
||||
key: string,
|
||||
value: string,
|
||||
): void {
|
||||
this.values.set(key, value);
|
||||
for (const target of this.targets) {
|
||||
if (target !== owner) target.emit(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
remove(owner: FakeStorageEventTarget, key: string): void {
|
||||
this.values.delete(key);
|
||||
for (const target of this.targets) {
|
||||
if (target !== owner) target.emit(key, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class FakeStorageEndpoint implements StoragePulseFacade {
|
||||
failSet = false;
|
||||
failRemove = false;
|
||||
setCount = 0;
|
||||
removeCount = 0;
|
||||
|
||||
constructor(
|
||||
private readonly bus: FakeStorageBus,
|
||||
private readonly owner: FakeStorageEventTarget,
|
||||
) {}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.setCount += 1;
|
||||
if (this.failSet) {
|
||||
throw new DOMException("Storage denied", "SecurityError");
|
||||
}
|
||||
this.bus.set(this.owner, key, value);
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.removeCount += 1;
|
||||
if (this.failRemove) {
|
||||
throw new DOMException("Storage denied", "SecurityError");
|
||||
}
|
||||
this.bus.remove(this.owner, key);
|
||||
}
|
||||
}
|
||||
|
||||
function dependencies(
|
||||
id: string,
|
||||
overrides: Partial<BrowserCrossContextInvalidationDependencies> = {},
|
||||
): BrowserCrossContextInvalidationDependencies {
|
||||
let eventNumber = 0;
|
||||
return {
|
||||
channelName: CHANNEL_NAME,
|
||||
storagePulseKey: STORAGE_PULSE_KEY,
|
||||
sourceId: `${id}-source`,
|
||||
sourceEpoch: `${id}-epoch`,
|
||||
cacheEpoch: CACHE_EPOCH,
|
||||
topics: [{ topic: TOPIC, topicVersion: 1 }],
|
||||
createEventId: () =>
|
||||
`${id}-event-${String(++eventNumber).padStart(8, "0")}`,
|
||||
nowEpochMilliseconds: () => NOW,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function wireEvent(
|
||||
overrides: Partial<CacheInvalidationWireEvent> = {},
|
||||
): CacheInvalidationWireEvent {
|
||||
return {
|
||||
protocolVersion: CACHE_INVALIDATION_PROTOCOL_VERSION,
|
||||
eventId: "remote-event-00000001",
|
||||
sourceId: "remote-source-0000001",
|
||||
sourceEpoch: "remote-epoch-0000001",
|
||||
sequence: 1,
|
||||
cacheEpoch: CACHE_EPOCH,
|
||||
topic: TOPIC,
|
||||
topicVersion: 1,
|
||||
emittedAt: NOW,
|
||||
expiresAt: NOW + 60_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("cache invalidation wire contract", () => {
|
||||
const policy = {
|
||||
cacheEpoch: CACHE_EPOCH,
|
||||
topicVersions: { [TOPIC]: 1 },
|
||||
nowEpochMilliseconds: NOW,
|
||||
} as const;
|
||||
|
||||
it("accepts only the exact payload-free, query-key-free envelope", () => {
|
||||
const accepted = parseCacheInvalidationWireEvent(wireEvent(), policy);
|
||||
expect(accepted).toMatchObject({ ok: true });
|
||||
if (!accepted.ok) throw new Error("Expected a valid event");
|
||||
|
||||
expect(Object.keys(accepted.value).sort()).toEqual([
|
||||
"cacheEpoch",
|
||||
"emittedAt",
|
||||
"eventId",
|
||||
"expiresAt",
|
||||
"protocolVersion",
|
||||
"sequence",
|
||||
"sourceEpoch",
|
||||
"sourceId",
|
||||
"topic",
|
||||
"topicVersion",
|
||||
]);
|
||||
expect(accepted.value).not.toHaveProperty("payload");
|
||||
expect(accepted.value).not.toHaveProperty("queryKey");
|
||||
|
||||
expect(
|
||||
parseCacheInvalidationWireEvent(
|
||||
{ ...wireEvent(), payload: { secret: "must-not-cross" } },
|
||||
policy,
|
||||
),
|
||||
).toEqual({ ok: false, reason: "INVALID_ENVELOPE" });
|
||||
expect(
|
||||
parseCacheInvalidationWireEvent(
|
||||
{ ...wireEvent(), queryKey: ["resource", "sensitive-id"] },
|
||||
policy,
|
||||
),
|
||||
).toEqual({ ok: false, reason: "INVALID_ENVELOPE" });
|
||||
});
|
||||
|
||||
it("bounds bytes, protocol, allowlist, cache epoch and event lifetime", () => {
|
||||
expect(
|
||||
decodeCacheInvalidationWireEvent("{not-json", policy),
|
||||
).toEqual({ ok: false, reason: "MALFORMED_JSON" });
|
||||
expect(
|
||||
decodeCacheInvalidationWireEvent(
|
||||
"x".repeat(
|
||||
CACHE_INVALIDATION_WIRE_LIMITS.maxWireBytes + 1,
|
||||
),
|
||||
policy,
|
||||
),
|
||||
).toEqual({ ok: false, reason: "OVERSIZED" });
|
||||
expect(
|
||||
parseCacheInvalidationWireEvent(
|
||||
{ ...wireEvent(), protocolVersion: 2 },
|
||||
policy,
|
||||
),
|
||||
).toEqual({ ok: false, reason: "PROTOCOL_MISMATCH" });
|
||||
expect(
|
||||
parseCacheInvalidationWireEvent(
|
||||
{ ...wireEvent(), topic: "unknown-topic" },
|
||||
policy,
|
||||
),
|
||||
).toEqual({ ok: false, reason: "TOPIC_REJECTED" });
|
||||
expect(
|
||||
parseCacheInvalidationWireEvent(
|
||||
{ ...wireEvent(), topicVersion: 2 },
|
||||
policy,
|
||||
),
|
||||
).toEqual({ ok: false, reason: "TOPIC_REJECTED" });
|
||||
expect(
|
||||
parseCacheInvalidationWireEvent(
|
||||
{ ...wireEvent(), cacheEpoch: "other-cache-epoch" },
|
||||
policy,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "CACHE_EPOCH_MISMATCH",
|
||||
});
|
||||
expect(
|
||||
parseCacheInvalidationWireEvent(
|
||||
wireEvent({ emittedAt: NOW - 60_000, expiresAt: NOW }),
|
||||
policy,
|
||||
),
|
||||
).toEqual({ ok: false, reason: "EXPIRED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser cross-context invalidation transport", () => {
|
||||
it("publishes one exact BroadcastChannel event and filters self echo", () => {
|
||||
const network = new FakeBroadcastNetwork();
|
||||
const first = createBrowserCrossContextInvalidation(
|
||||
dependencies("tab-a", {
|
||||
createBroadcastChannel: network.createChannel,
|
||||
}),
|
||||
);
|
||||
const second = createBrowserCrossContextInvalidation(
|
||||
dependencies("tab-b", {
|
||||
createBroadcastChannel: network.createChannel,
|
||||
}),
|
||||
);
|
||||
const local = vi.fn();
|
||||
const remote = vi.fn();
|
||||
first.subscribe(local);
|
||||
second.subscribe(remote);
|
||||
|
||||
expect(first.getStatus()).toBe("ACTIVE_BROADCAST");
|
||||
expect(first.publish({ topic: TOPIC, topicVersion: 1 })).toEqual({
|
||||
ok: true,
|
||||
transport: "BROADCAST",
|
||||
});
|
||||
expect(local).not.toHaveBeenCalled();
|
||||
expect(remote).toHaveBeenCalledOnce();
|
||||
expect(remote.mock.calls[0]?.[0]).toMatchObject({
|
||||
ordering: "NEXT",
|
||||
transport: "BROADCAST",
|
||||
event: {
|
||||
protocolVersion: 1,
|
||||
sequence: 1,
|
||||
cacheEpoch: CACHE_EPOCH,
|
||||
topic: TOPIC,
|
||||
topicVersion: 1,
|
||||
},
|
||||
});
|
||||
expect(network.messages).toHaveLength(1);
|
||||
expect(JSON.stringify(network.messages[0])).not.toMatch(
|
||||
/payload|queryKey|sensitive/,
|
||||
);
|
||||
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
|
||||
it("de-duplicates the same event delivered by both transports", () => {
|
||||
const network = new FakeBroadcastNetwork();
|
||||
const storage = new FakeStorageBus().createEndpoint();
|
||||
const runtime = createBrowserCrossContextInvalidation(
|
||||
dependencies("tab-b", {
|
||||
createBroadcastChannel: network.createChannel,
|
||||
storage: storage.storage,
|
||||
storageEvents: storage.target,
|
||||
}),
|
||||
);
|
||||
const received = vi.fn();
|
||||
runtime.subscribe(received);
|
||||
const event = wireEvent();
|
||||
|
||||
network.emit(event);
|
||||
storage.target.emit(STORAGE_PULSE_KEY, JSON.stringify(event));
|
||||
|
||||
expect(received).toHaveBeenCalledOnce();
|
||||
expect(received.mock.calls[0]?.[0]).toMatchObject({
|
||||
transport: "BROADCAST",
|
||||
ordering: "NEXT",
|
||||
});
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
it("reports per-source gaps and drops stale out-of-order events", () => {
|
||||
const network = new FakeBroadcastNetwork();
|
||||
const observations: CrossContextInvalidationObservation[] = [];
|
||||
const runtime = createBrowserCrossContextInvalidation(
|
||||
dependencies("tab-b", {
|
||||
createBroadcastChannel: network.createChannel,
|
||||
observe: (observation) => observations.push(observation),
|
||||
}),
|
||||
);
|
||||
const delivered: CrossContextInvalidationDelivery[] = [];
|
||||
runtime.subscribe((delivery) => delivered.push(delivery));
|
||||
|
||||
network.emit(wireEvent({ eventId: "remote-event-00000001", sequence: 1 }));
|
||||
network.emit(wireEvent({ eventId: "remote-event-00000003", sequence: 3 }));
|
||||
network.emit(wireEvent({ eventId: "remote-event-00000002", sequence: 2 }));
|
||||
network.emit(
|
||||
wireEvent({
|
||||
eventId: "remote-new-epoch-event",
|
||||
sourceEpoch: "remote-epoch-0000002",
|
||||
sequence: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
delivered.map(({ event, ordering }) => [
|
||||
event.sequence,
|
||||
ordering,
|
||||
]),
|
||||
).toEqual([
|
||||
[1, "NEXT"],
|
||||
[3, "GAP"],
|
||||
[1, "NEXT"],
|
||||
]);
|
||||
expect(observations).toContainEqual({
|
||||
operation: "RECEIVE",
|
||||
outcome: "DROPPED",
|
||||
transport: "BROADCAST",
|
||||
reason: "STALE",
|
||||
});
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
it("falls back when BroadcastChannel open or publish fails", () => {
|
||||
const storageBus = new FakeStorageBus();
|
||||
const firstStorage = storageBus.createEndpoint();
|
||||
const secondStorage = storageBus.createEndpoint();
|
||||
const firstNetwork = new FakeBroadcastNetwork();
|
||||
const secondNetwork = new FakeBroadcastNetwork();
|
||||
|
||||
const first = createBrowserCrossContextInvalidation(
|
||||
dependencies("tab-a", {
|
||||
createBroadcastChannel: firstNetwork.createChannel,
|
||||
storage: firstStorage.storage,
|
||||
storageEvents: firstStorage.target,
|
||||
}),
|
||||
);
|
||||
const second = createBrowserCrossContextInvalidation(
|
||||
dependencies("tab-b", {
|
||||
createBroadcastChannel: () => {
|
||||
throw new DOMException("Denied", "SecurityError");
|
||||
},
|
||||
storage: secondStorage.storage,
|
||||
storageEvents: secondStorage.target,
|
||||
}),
|
||||
);
|
||||
firstNetwork.channels[0]!.failPost = true;
|
||||
const received = vi.fn();
|
||||
second.subscribe(received);
|
||||
|
||||
expect(second.getStatus()).toBe("ACTIVE_STORAGE_FALLBACK");
|
||||
expect(first.publish({ topic: TOPIC, topicVersion: 1 })).toEqual({
|
||||
ok: true,
|
||||
transport: "STORAGE",
|
||||
});
|
||||
expect(first.getStatus()).toBe("ACTIVE_STORAGE_FALLBACK");
|
||||
expect(received).toHaveBeenCalledOnce();
|
||||
expect(firstStorage.storage.setCount).toBe(1);
|
||||
expect(firstStorage.storage.removeCount).toBe(1);
|
||||
expect(storageBus.values.has(STORAGE_PULSE_KEY)).toBe(false);
|
||||
expect(secondNetwork.channels).toHaveLength(0);
|
||||
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
|
||||
it("enters explicit local-only degradation when every transport fails", () => {
|
||||
const storageBus = new FakeStorageBus();
|
||||
const endpoint = storageBus.createEndpoint();
|
||||
endpoint.storage.failSet = true;
|
||||
const observations: CrossContextInvalidationObservation[] = [];
|
||||
const runtime = createBrowserCrossContextInvalidation(
|
||||
dependencies("tab-a", {
|
||||
createBroadcastChannel: () => {
|
||||
throw new DOMException("Denied", "SecurityError");
|
||||
},
|
||||
storage: endpoint.storage,
|
||||
storageEvents: endpoint.target,
|
||||
observe: (observation) => observations.push(observation),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.getStatus()).toBe("ACTIVE_STORAGE_FALLBACK");
|
||||
expect(runtime.publish({ topic: TOPIC, topicVersion: 1 })).toEqual({
|
||||
ok: false,
|
||||
reason: "TRANSPORT_UNAVAILABLE",
|
||||
});
|
||||
expect(runtime.getStatus()).toBe("DEGRADED_LOCAL_ONLY");
|
||||
expect(observations).toContainEqual({
|
||||
operation: "PUBLISH",
|
||||
outcome: "DEGRADED",
|
||||
transport: "STORAGE",
|
||||
reason: "STORAGE_PUBLISH_FAILED",
|
||||
});
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
it("isolates handler and diagnostics failures without leaking identifiers", () => {
|
||||
const network = new FakeBroadcastNetwork();
|
||||
const observations: CrossContextInvalidationObservation[] = [];
|
||||
const runtime = createBrowserCrossContextInvalidation(
|
||||
dependencies("tab-b", {
|
||||
createBroadcastChannel: network.createChannel,
|
||||
observe(observation) {
|
||||
observations.push(observation);
|
||||
if (observation.reason === "HANDLER_FAILED") {
|
||||
throw new Error("diagnostics unavailable");
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
const healthy = vi.fn();
|
||||
runtime.subscribe(() => {
|
||||
throw new Error("listener secret");
|
||||
});
|
||||
runtime.subscribe(healthy);
|
||||
|
||||
network.emit(
|
||||
wireEvent({
|
||||
eventId: "sensitive-event-identifier",
|
||||
topic: TOPIC,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(healthy).toHaveBeenCalledOnce();
|
||||
expect(observations.some(({ reason }) => reason === "HANDLER_FAILED")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(JSON.stringify(observations)).not.toMatch(
|
||||
/sensitive-event-identifier|reference-resources|cache-epoch-0001/,
|
||||
);
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
it("bounds TTL and LRU tracking instead of growing with remote sources", () => {
|
||||
const network = new FakeBroadcastNetwork();
|
||||
let currentTime = NOW;
|
||||
const runtime = createBrowserCrossContextInvalidation(
|
||||
dependencies("tab-b", {
|
||||
createBroadcastChannel: network.createChannel,
|
||||
nowEpochMilliseconds: () => currentTime,
|
||||
dedupeCapacity: 2,
|
||||
sourceCapacity: 2,
|
||||
}),
|
||||
);
|
||||
const received = vi.fn();
|
||||
runtime.subscribe(received);
|
||||
const distinctSource = (
|
||||
index: number,
|
||||
eventId: string,
|
||||
emittedAt = currentTime,
|
||||
) =>
|
||||
wireEvent({
|
||||
eventId,
|
||||
sourceId: `remote-source-${index}`,
|
||||
sourceEpoch: `remote-epoch-${index}`,
|
||||
emittedAt,
|
||||
expiresAt: emittedAt + 1_000,
|
||||
});
|
||||
|
||||
network.emit(distinctSource(1, "bounded-event-1"));
|
||||
network.emit(distinctSource(2, "bounded-event-2"));
|
||||
network.emit(distinctSource(3, "bounded-event-3"));
|
||||
network.emit(distinctSource(1, "bounded-event-1"));
|
||||
expect(received).toHaveBeenCalledTimes(4);
|
||||
|
||||
currentTime += 1_001;
|
||||
network.emit(
|
||||
distinctSource(3, "bounded-event-3", currentTime),
|
||||
);
|
||||
expect(received).toHaveBeenCalledTimes(5);
|
||||
runtime.close();
|
||||
});
|
||||
|
||||
it("cleans up listeners once and rejects queued work after close", () => {
|
||||
const network = new FakeBroadcastNetwork();
|
||||
const storage = new FakeStorageBus().createEndpoint();
|
||||
const runtime = createBrowserCrossContextInvalidation(
|
||||
dependencies("tab-b", {
|
||||
createBroadcastChannel: network.createChannel,
|
||||
storage: storage.storage,
|
||||
storageEvents: storage.target,
|
||||
}),
|
||||
);
|
||||
const received = vi.fn();
|
||||
const unsubscribe = runtime.subscribe(received);
|
||||
unsubscribe();
|
||||
unsubscribe();
|
||||
runtime.subscribe(received);
|
||||
|
||||
runtime.close();
|
||||
runtime.close();
|
||||
network.emit(wireEvent());
|
||||
storage.target.emit(
|
||||
STORAGE_PULSE_KEY,
|
||||
JSON.stringify(wireEvent()),
|
||||
);
|
||||
|
||||
expect(runtime.getStatus()).toBe("CLOSED");
|
||||
expect(runtime.publish({ topic: TOPIC, topicVersion: 1 })).toEqual({
|
||||
ok: false,
|
||||
reason: "CLOSED",
|
||||
});
|
||||
expect(received).not.toHaveBeenCalled();
|
||||
expect(network.channels[0]?.closeCount).toBe(1);
|
||||
expect(network.channels[0]?.removeCount).toBe(1);
|
||||
expect(storage.target.removeCount).toBe(1);
|
||||
expect(storage.target.listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it("fails invalid publish inputs closed without touching a provider", () => {
|
||||
const network = new FakeBroadcastNetwork();
|
||||
const runtime = createBrowserCrossContextInvalidation(
|
||||
dependencies("tab-a", {
|
||||
createBroadcastChannel: network.createChannel,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
runtime.publish({ topic: "not-allowlisted", topicVersion: 1 }),
|
||||
).toEqual({ ok: false, reason: "INVALID_EVENT" });
|
||||
expect(
|
||||
runtime.publish({ topic: TOPIC, topicVersion: 2 }),
|
||||
).toEqual({ ok: false, reason: "INVALID_EVENT" });
|
||||
expect(network.messages).toHaveLength(0);
|
||||
runtime.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createCursorPaginationRuntime } from "../../src/adapters/query-cache/cursor-pagination-runtime.ts";
|
||||
|
||||
const profile = {
|
||||
profileId: "bounded-cursor-v1",
|
||||
maxPages: 3,
|
||||
maxTotalItems: 4,
|
||||
maxEstimatedBytes: 1_024,
|
||||
maxCursorBytes: 64,
|
||||
allowSparsePage: false,
|
||||
} as const;
|
||||
|
||||
describe("bounded cursor pagination runtime", () => {
|
||||
it("loads a stable finite chain without exposing cursors in its value", async () => {
|
||||
const loadPage = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
value: {
|
||||
items: ["one"],
|
||||
nextCursor: "cursor-2",
|
||||
hasMore: true,
|
||||
snapshotToken: "snapshot-a",
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
value: {
|
||||
items: ["two"],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
snapshotToken: "snapshot-a",
|
||||
},
|
||||
});
|
||||
const runtime = createCursorPaginationRuntime({
|
||||
definitionId: "LIST_ALL",
|
||||
profile,
|
||||
loadPage,
|
||||
});
|
||||
|
||||
await expect(runtime.loadAll({})).resolves.toEqual({
|
||||
ok: true,
|
||||
value: ["one", "two"],
|
||||
});
|
||||
expect(loadPage.mock.calls.map(([cursor]) => cursor)).toEqual([
|
||||
null,
|
||||
"cursor-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
page: {
|
||||
items: [],
|
||||
nextCursor: "next",
|
||||
hasMore: true,
|
||||
snapshotToken: null,
|
||||
},
|
||||
code: "PAGINATION_PAGE_INVALID",
|
||||
},
|
||||
{
|
||||
page: {
|
||||
items: ["one"],
|
||||
nextCursor: null,
|
||||
hasMore: true,
|
||||
snapshotToken: null,
|
||||
},
|
||||
code: "PAGINATION_PAGE_INVALID",
|
||||
},
|
||||
])("rejects invalid page invariants", async ({ page, code }) => {
|
||||
const runtime = createCursorPaginationRuntime({
|
||||
definitionId: "LIST_ALL",
|
||||
profile,
|
||||
loadPage: async () => ({ ok: true, value: page }),
|
||||
});
|
||||
await expect(runtime.loadAll({})).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PAGINATION_CONTRACT_VIOLATION", code },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects cursor loops and snapshot drift", async () => {
|
||||
const loop = createCursorPaginationRuntime({
|
||||
definitionId: "LIST_ALL",
|
||||
profile,
|
||||
loadPage: async () => ({
|
||||
ok: true,
|
||||
value: {
|
||||
items: ["one"],
|
||||
nextCursor: "same",
|
||||
hasMore: true,
|
||||
snapshotToken: null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
await expect(loop.loadAll({})).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "PAGINATION_CURSOR_LOOP" },
|
||||
});
|
||||
|
||||
let page = 0;
|
||||
const drift = createCursorPaginationRuntime({
|
||||
definitionId: "LIST_ALL",
|
||||
profile,
|
||||
loadPage: async () => ({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [String(page)],
|
||||
nextCursor: page++ === 0 ? "next" : null,
|
||||
hasMore: page === 1,
|
||||
snapshotToken: page === 1 ? "snapshot-a" : "snapshot-b",
|
||||
},
|
||||
}),
|
||||
});
|
||||
await expect(drift.loadAll({})).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "PAGINATION_SNAPSHOT_CHANGED" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
REQUIRED_COMPONENT_TOKENS,
|
||||
REQUIRED_PRIMITIVE_TOKENS,
|
||||
REQUIRED_SEMANTIC_TOKENS,
|
||||
} from "../../src/presentation/design-system/index.js";
|
||||
} from "../../src/presentation/design-system/index.ts";
|
||||
|
||||
describe("design token contract", () => {
|
||||
it("defines every registered token in its owning layer", async () => {
|
||||
|
||||
@@ -5,11 +5,11 @@ import {
|
||||
getLastBootEvidence,
|
||||
noOpDiagnostics,
|
||||
recordBootFailure,
|
||||
} from "../../src/adapters/diagnostics/bounded-diagnostics.js";
|
||||
} from "../../src/adapters/diagnostics/bounded-diagnostics.ts";
|
||||
import {
|
||||
projectDiagnosticRecord,
|
||||
safeErrorKind,
|
||||
} from "../../src/contracts/diagnostics.js";
|
||||
} from "../../src/contracts/diagnostics.ts";
|
||||
|
||||
describe("structured diagnostics contract", () => {
|
||||
it("projects only registered, bounded context with deterministic time", () => {
|
||||
|
||||
@@ -5,11 +5,11 @@ import {
|
||||
createFailure,
|
||||
kindForStatus,
|
||||
normalizeUnknownFailure,
|
||||
} from "../../src/contracts/errors.js";
|
||||
} from "../../src/contracts/errors.ts";
|
||||
|
||||
describe("frontend failure classification", () => {
|
||||
it("defines all 26 stable error kinds with the seven contract fields", () => {
|
||||
expect(Object.keys(ERROR_REGISTRY)).toHaveLength(31);
|
||||
it("defines all stable error kinds with the seven contract fields", () => {
|
||||
expect(Object.keys(ERROR_REGISTRY)).toHaveLength(38);
|
||||
for (const definition of Object.values(ERROR_REGISTRY)) {
|
||||
expect(definition).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { validateFieldEvidenceInput } from "../../scripts/lib/field-vitals-evidence.mjs";
|
||||
import { validateFieldEvidenceInput } from "../../scripts/lib/field-vitals-evidence.ts";
|
||||
|
||||
const input = {
|
||||
schemaVersion: 1,
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { classifyLiveHostingBaseUrl } from "../../scripts/lib/hosting-probe.mjs";
|
||||
import { classifyLiveHostingBaseUrl } from "../../scripts/lib/hosting-probe.ts";
|
||||
|
||||
describe("live hosting evidence target", () => {
|
||||
it("accepts a canonical production HTTPS root", () => {
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
resolveMessage,
|
||||
selectMessage,
|
||||
selectPlural,
|
||||
} from "../../src/presentation/i18n/index.js";
|
||||
} from "../../src/presentation/i18n/index.ts";
|
||||
import {
|
||||
EN_MESSAGES,
|
||||
KO_MESSAGES,
|
||||
MESSAGE_CATALOGS,
|
||||
} from "../../src/presentation/i18n/catalog.js";
|
||||
} from "../../src/presentation/i18n/catalog.ts";
|
||||
|
||||
describe("internationalization message contract", () => {
|
||||
it("keeps every registered catalog and interpolation contract in parity", () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,811 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createIndexedDbMaintenance } from "../../src/adapters/storage/indexeddb/indexeddb-maintenance.ts";
|
||||
import { createIndexedDbRuntime } from "../../src/adapters/storage/indexeddb/indexeddb-runtime.ts";
|
||||
import type {
|
||||
IndexedDbDataMigrationPolicy,
|
||||
IndexedDbObservation,
|
||||
} from "../../src/adapters/storage/indexeddb/indexeddb-types.ts";
|
||||
import { MemoryIndexedDbFactory } from "../helpers/memory-indexeddb.ts";
|
||||
|
||||
type CurrentPayload = Readonly<{
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
const TEST_SCOPE = Object.freeze({
|
||||
authorityToken: "authoritytoken_002",
|
||||
namespaceToken: "namespacetoken_002",
|
||||
partitionToken: "partitiontoken_002",
|
||||
accountScope: "ORIGIN_SHARED" as const,
|
||||
});
|
||||
|
||||
const TEST_STORAGE_POLICY = Object.freeze({
|
||||
owner: "platform-storage",
|
||||
namespace: "indexeddb-maintenance-test",
|
||||
classification: "INTERNAL" as const,
|
||||
authority: "SERVER" as const,
|
||||
accountScope: "ORIGIN_SHARED" as const,
|
||||
retention: Object.freeze({ kind: "EXPLICIT_DELETE" as const }),
|
||||
softBudgetBytes: 1_000_000,
|
||||
hardBudgetBytes: 2_000_000,
|
||||
evictionPriority: "SYNCED_COPY" as const,
|
||||
logoutAction: "KEEP_ORIGIN_SHARED" as const,
|
||||
accountDeletionAction: "KEEP_ORIGIN_SHARED" as const,
|
||||
pressureAction: "RETAIN" as const,
|
||||
unavailableFallback: "ONLINE_ONLY" as const,
|
||||
});
|
||||
|
||||
function createSchemaRuntime(memory: MemoryIndexedDbFactory) {
|
||||
return createIndexedDbRuntime<CurrentPayload, CurrentPayload, null>({
|
||||
scope: TEST_SCOPE,
|
||||
storagePolicy: TEST_STORAGE_POLICY,
|
||||
schemaVersion: 1,
|
||||
recordStore: "records",
|
||||
governanceStore: "governance",
|
||||
retentionStore: "retention",
|
||||
retentionEligibilityIndex: "by-eligibility",
|
||||
lifecycleMetadataStores: ["maintenance"],
|
||||
idempotencyStore: "receipts",
|
||||
idempotencyExpiryIndex: "by-expiry",
|
||||
receiptRetentionMs: 60_000,
|
||||
maxIdempotencyReceipts: 1_000,
|
||||
migrations: [
|
||||
{
|
||||
id: "schema-v1",
|
||||
fromVersion: 0,
|
||||
toVersion: 1,
|
||||
operations: [
|
||||
{
|
||||
kind: "CREATE_STORE",
|
||||
name: "governance",
|
||||
keyPath: "bindingKey",
|
||||
},
|
||||
{
|
||||
kind: "CREATE_STORE",
|
||||
name: "retention",
|
||||
keyPath: "recordKey",
|
||||
indexes: [
|
||||
{
|
||||
name: "by-eligibility",
|
||||
keyPath: "eligibleAtEpochMs",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "CREATE_STORE",
|
||||
name: "records",
|
||||
keyPath: "key",
|
||||
},
|
||||
{
|
||||
kind: "CREATE_STORE",
|
||||
name: "receipts",
|
||||
keyPath: "idempotencyKey",
|
||||
indexes: [
|
||||
{
|
||||
name: "by-expiry",
|
||||
keyPath: "expiresAtEpochMs",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "CREATE_STORE",
|
||||
name: "maintenance",
|
||||
keyPath: "checkpointKey",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
codec: {
|
||||
currentVersion: 2,
|
||||
encode: (value) => ({ ok: true, value }),
|
||||
measureStoredBytes: (value) =>
|
||||
new TextEncoder().encode(JSON.stringify(value)).byteLength,
|
||||
decode: (version, value) =>
|
||||
version === 2 &&
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as Partial<CurrentPayload>).label === "string"
|
||||
? { ok: true, value: value as CurrentPayload }
|
||||
: { ok: false },
|
||||
fingerprint: () => "0".repeat(64),
|
||||
},
|
||||
queryPolicy: {
|
||||
plan: () => ({ limit: 10 }),
|
||||
},
|
||||
factory: memory.factory,
|
||||
authorizeLifecycle: () => ({
|
||||
authorized: true,
|
||||
proofToken: "authorityproof_002",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareSchema(
|
||||
memory: MemoryIndexedDbFactory,
|
||||
): Promise<void> {
|
||||
const runtime = createSchemaRuntime(memory);
|
||||
expect(await runtime.open()).toMatchObject({ ok: true });
|
||||
runtime.close();
|
||||
}
|
||||
|
||||
function seedLegacy(
|
||||
memory: MemoryIndexedDbFactory,
|
||||
key: string,
|
||||
legacyLabel: string,
|
||||
revision = 1,
|
||||
): void {
|
||||
const measuredBytes =
|
||||
new TextEncoder().encode(JSON.stringify({ label: legacyLabel }))
|
||||
.byteLength +
|
||||
512 +
|
||||
key.length * 2;
|
||||
memory.seed("records", {
|
||||
key,
|
||||
codecVersion: 1,
|
||||
revision,
|
||||
payload: { legacyLabel },
|
||||
});
|
||||
memory.seed("retention", {
|
||||
recordKey: key,
|
||||
writtenAtEpochMs: 1,
|
||||
synchronization: "NONE",
|
||||
measuredBytes,
|
||||
});
|
||||
const budget = memory.readRaw(
|
||||
"governance",
|
||||
"dataset-budget",
|
||||
) as Readonly<{
|
||||
bindingKey: string;
|
||||
budgetVersion: number;
|
||||
usedBytes: number;
|
||||
receiptCount: number;
|
||||
}>;
|
||||
memory.seed("governance", {
|
||||
...budget,
|
||||
usedBytes: budget.usedBytes + measuredBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function defaultPolicy(
|
||||
migrate = vi.fn(
|
||||
async ({ payload }: { payload: unknown }) => {
|
||||
const legacyLabel =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
typeof (
|
||||
payload as Readonly<{ legacyLabel?: unknown }>
|
||||
).legacyLabel === "string"
|
||||
? (
|
||||
payload as Readonly<{ legacyLabel: string }>
|
||||
).legacyLabel
|
||||
: null;
|
||||
return legacyLabel === null
|
||||
? ({ ok: false } as const)
|
||||
: ({
|
||||
ok: true,
|
||||
value: { label: legacyLabel },
|
||||
} as const);
|
||||
},
|
||||
),
|
||||
): IndexedDbDataMigrationPolicy<CurrentPayload> {
|
||||
return {
|
||||
migrationId: "records-to-codec-v2",
|
||||
targetCodecVersion: 2,
|
||||
measureStoredBytes: (value) =>
|
||||
new TextEncoder().encode(JSON.stringify(value)).byteLength,
|
||||
isOldWriterDrainConfirmed: () => true,
|
||||
migrate,
|
||||
};
|
||||
}
|
||||
|
||||
function createMaintenance(
|
||||
memory: MemoryIndexedDbFactory,
|
||||
policy: IndexedDbDataMigrationPolicy<CurrentPayload>,
|
||||
options: Readonly<{
|
||||
now?: () => number;
|
||||
nowEpochMilliseconds?: () => number;
|
||||
observe?: (event: IndexedDbObservation) => void;
|
||||
}> = {},
|
||||
) {
|
||||
return createIndexedDbMaintenance<CurrentPayload>({
|
||||
scope: TEST_SCOPE,
|
||||
storagePolicy: TEST_STORAGE_POLICY,
|
||||
schemaVersion: 1,
|
||||
recordStore: "records",
|
||||
governanceStore: "governance",
|
||||
retentionStore: "retention",
|
||||
checkpointStore: "maintenance",
|
||||
checkpointKey: "records-codec",
|
||||
idempotencyStore: "receipts",
|
||||
idempotencyExpiryIndex: "by-expiry",
|
||||
migrationPolicy: policy,
|
||||
factory: memory.factory,
|
||||
keyRange: memory.keyRange,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function seedReceipt(
|
||||
memory: MemoryIndexedDbFactory,
|
||||
idempotencyKey: string,
|
||||
expiresAtEpochMs: number,
|
||||
): void {
|
||||
memory.seed("receipts", {
|
||||
idempotencyKey,
|
||||
operation: "PUT",
|
||||
recordKey: `record-${idempotencyKey}`,
|
||||
expectedRevision: null,
|
||||
fingerprint: "a".repeat(64),
|
||||
synchronization: "NONE",
|
||||
revision: 1,
|
||||
expiresAtEpochMs,
|
||||
});
|
||||
const budget = memory.readRaw(
|
||||
"governance",
|
||||
"dataset-budget",
|
||||
) as Readonly<{
|
||||
bindingKey: string;
|
||||
budgetVersion: number;
|
||||
usedBytes: number;
|
||||
receiptCount: number;
|
||||
}>;
|
||||
memory.seed("governance", {
|
||||
...budget,
|
||||
receiptCount: budget.receiptCount + 1,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForWriteTransaction(
|
||||
memory: MemoryIndexedDbFactory,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (memory.lastTransaction?.mode === "readwrite") return;
|
||||
await Promise.resolve();
|
||||
}
|
||||
throw new Error("Timed out waiting for receipt prune transaction.");
|
||||
}
|
||||
|
||||
describe("IndexedDB bounded codec maintenance", () => {
|
||||
it("refuses keyset migration until old-codec writers are durably drained", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedLegacy(memory, "a-before-checkpoint", "old-writer");
|
||||
const policy = {
|
||||
...defaultPolicy(),
|
||||
isOldWriterDrainConfirmed: () => false,
|
||||
};
|
||||
const maintenance = createMaintenance(memory, policy);
|
||||
|
||||
expect(
|
||||
await maintenance.migrateCodecBatch({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "BLOCKED",
|
||||
operation: "INDEXEDDB_MIGRATE",
|
||||
retryable: true,
|
||||
recovery: "RELOAD_OTHER_CONTEXTS",
|
||||
},
|
||||
});
|
||||
expect(policy.migrate).not.toHaveBeenCalled();
|
||||
expect(
|
||||
memory.readRaw("maintenance", "records-codec"),
|
||||
).toBeUndefined();
|
||||
expect(memory.readRaw("records", "a-before-checkpoint")).toMatchObject({
|
||||
codecVersion: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("resumes from a durable checkpoint and completes in bounded row batches", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedLegacy(memory, "a", "alpha", 4);
|
||||
seedLegacy(memory, "b", "beta", 7);
|
||||
seedLegacy(memory, "c", "gamma", 9);
|
||||
const observations: IndexedDbObservation[] = [];
|
||||
const policy = defaultPolicy();
|
||||
const maintenance = createMaintenance(memory, policy, {
|
||||
observe: (event) => observations.push(event),
|
||||
});
|
||||
|
||||
expect(
|
||||
await maintenance.migrateCodecBatch({
|
||||
maxRows: 2,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "MORE",
|
||||
scannedRows: 2,
|
||||
checkpointedRows: 2,
|
||||
migratedRows: 2,
|
||||
concurrentlyChangedRows: 0,
|
||||
budgetExhausted: false,
|
||||
},
|
||||
});
|
||||
expect(memory.readRaw("maintenance", "records-codec")).toEqual({
|
||||
checkpointKey: "records-codec",
|
||||
migrationId: "records-to-codec-v2",
|
||||
targetCodecVersion: 2,
|
||||
lastKey: "b",
|
||||
state: "MORE",
|
||||
});
|
||||
|
||||
expect(
|
||||
await maintenance.migrateCodecBatch({
|
||||
maxRows: 2,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "COMPLETE",
|
||||
scannedRows: 1,
|
||||
checkpointedRows: 1,
|
||||
migratedRows: 1,
|
||||
concurrentlyChangedRows: 0,
|
||||
budgetExhausted: false,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await maintenance.migrateCodecBatch({
|
||||
maxRows: 2,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "COMPLETE",
|
||||
scannedRows: 0,
|
||||
checkpointedRows: 0,
|
||||
migratedRows: 0,
|
||||
concurrentlyChangedRows: 0,
|
||||
budgetExhausted: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(policy.migrate).toHaveBeenCalledTimes(3);
|
||||
expect(memory.readRaw("records", "a")).toEqual({
|
||||
key: "a",
|
||||
codecVersion: 2,
|
||||
revision: 4,
|
||||
payload: { label: "alpha" },
|
||||
});
|
||||
expect(memory.readRaw("records", "c")).toEqual({
|
||||
key: "c",
|
||||
codecVersion: 2,
|
||||
revision: 9,
|
||||
payload: { label: "gamma" },
|
||||
});
|
||||
const migratedBudget = memory.readRaw(
|
||||
"governance",
|
||||
"dataset-budget",
|
||||
) as Readonly<{ usedBytes: number }>;
|
||||
const migratedSidecars = ["a", "b", "c"].map(
|
||||
(key) =>
|
||||
memory.readRaw(
|
||||
"retention",
|
||||
key,
|
||||
) as Readonly<{ measuredBytes: number }>,
|
||||
);
|
||||
expect(migratedBudget.usedBytes).toBe(
|
||||
migratedSidecars.reduce(
|
||||
(total, row) => total + row.measuredBytes,
|
||||
0,
|
||||
),
|
||||
);
|
||||
expect(JSON.stringify(observations)).not.toMatch(
|
||||
/alpha|beta|gamma|records-codec/,
|
||||
);
|
||||
});
|
||||
|
||||
it("commits migrated records and their checkpoint atomically", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedLegacy(memory, "atomic", "before");
|
||||
const maintenance = createMaintenance(
|
||||
memory,
|
||||
defaultPolicy(),
|
||||
);
|
||||
memory.failNextWriteCommit(
|
||||
new DOMException("private payload", "QuotaExceededError"),
|
||||
);
|
||||
|
||||
expect(
|
||||
await maintenance.migrateCodecBatch({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "QUOTA_EXCEEDED",
|
||||
operation: "INDEXEDDB_MIGRATE",
|
||||
},
|
||||
});
|
||||
expect(memory.readRaw("records", "atomic")).toEqual({
|
||||
key: "atomic",
|
||||
codecVersion: 1,
|
||||
revision: 1,
|
||||
payload: { legacyLabel: "before" },
|
||||
});
|
||||
expect(
|
||||
memory.readRaw("maintenance", "records-codec"),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(
|
||||
await maintenance.migrateCodecBatch({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { state: "COMPLETE", migratedRows: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("runs async domain transforms outside transactions and honors abort before commit", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedLegacy(memory, "abort", "unchanged");
|
||||
let entered: (() => void) | undefined;
|
||||
let release: (() => void) | undefined;
|
||||
const transformStarted = new Promise<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const transformGate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const policy = defaultPolicy(
|
||||
vi.fn(async () => {
|
||||
entered?.();
|
||||
await transformGate;
|
||||
return {
|
||||
ok: true,
|
||||
value: { label: "must-not-commit" },
|
||||
} as const;
|
||||
}),
|
||||
);
|
||||
const maintenance = createMaintenance(memory, policy);
|
||||
const controller = new AbortController();
|
||||
|
||||
const pending = maintenance.migrateCodecBatch({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 10_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await transformStarted;
|
||||
controller.abort();
|
||||
release?.();
|
||||
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
expect(memory.readRaw("records", "abort")).toEqual({
|
||||
key: "abort",
|
||||
codecVersion: 1,
|
||||
revision: 1,
|
||||
payload: { legacyLabel: "unchanged" },
|
||||
});
|
||||
expect(
|
||||
memory.readRaw("maintenance", "records-codec"),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rechecks revision and codec fencing before every migrated write", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedLegacy(memory, "concurrent", "first", 1);
|
||||
let changed = false;
|
||||
const policy = defaultPolicy(
|
||||
vi.fn(async () => {
|
||||
if (!changed) {
|
||||
changed = true;
|
||||
seedLegacy(memory, "concurrent", "newer", 2);
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: { label: changed ? "newer" : "first" },
|
||||
} as const;
|
||||
}),
|
||||
);
|
||||
const maintenance = createMaintenance(memory, policy);
|
||||
|
||||
expect(
|
||||
await maintenance.migrateCodecBatch({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "MORE",
|
||||
scannedRows: 1,
|
||||
checkpointedRows: 0,
|
||||
migratedRows: 0,
|
||||
concurrentlyChangedRows: 1,
|
||||
budgetExhausted: false,
|
||||
},
|
||||
});
|
||||
expect(memory.readRaw("records", "concurrent")).toEqual({
|
||||
key: "concurrent",
|
||||
codecVersion: 1,
|
||||
revision: 2,
|
||||
payload: { legacyLabel: "newer" },
|
||||
});
|
||||
|
||||
expect(
|
||||
await maintenance.migrateCodecBatch({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "COMPLETE",
|
||||
migratedRows: 1,
|
||||
},
|
||||
});
|
||||
expect(memory.readRaw("records", "concurrent")).toEqual({
|
||||
key: "concurrent",
|
||||
codecVersion: 2,
|
||||
revision: 2,
|
||||
payload: { label: "newer" },
|
||||
});
|
||||
});
|
||||
|
||||
it("stops before transform work when its cooperative time budget is exhausted", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedLegacy(memory, "budget", "later");
|
||||
const policy = defaultPolicy();
|
||||
let currentTime = 0;
|
||||
const maintenance = createMaintenance(memory, policy, {
|
||||
now: () => {
|
||||
const value = currentTime;
|
||||
currentTime += 5;
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await maintenance.migrateCodecBatch({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "MORE",
|
||||
scannedRows: 0,
|
||||
checkpointedRows: 0,
|
||||
migratedRows: 0,
|
||||
concurrentlyChangedRows: 0,
|
||||
budgetExhausted: true,
|
||||
},
|
||||
});
|
||||
expect(policy.migrate).not.toHaveBeenCalled();
|
||||
expect(memory.readRaw("records", "budget")).toMatchObject({
|
||||
codecVersion: 1,
|
||||
revision: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a historical payload cannot be transformed", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedLegacy(memory, "invalid", "value");
|
||||
const maintenance = createMaintenance(
|
||||
memory,
|
||||
defaultPolicy(vi.fn(async () => ({ ok: false } as const))),
|
||||
);
|
||||
|
||||
expect(
|
||||
await maintenance.migrateCodecBatch({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "MIGRATION_FAILED",
|
||||
operation: "INDEXEDDB_MIGRATE",
|
||||
retryable: false,
|
||||
recovery: "READ_ONLY",
|
||||
},
|
||||
});
|
||||
expect(memory.readRaw("records", "invalid")).toMatchObject({
|
||||
codecVersion: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a size-increasing migration that exceeds the dataset hard budget", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedLegacy(memory, "oversized", "legacy");
|
||||
const beforeBudget = memory.readRaw(
|
||||
"governance",
|
||||
"dataset-budget",
|
||||
);
|
||||
const policy = {
|
||||
...defaultPolicy(),
|
||||
measureStoredBytes: () => 2_000_000,
|
||||
};
|
||||
const maintenance = createMaintenance(memory, policy);
|
||||
|
||||
expect(
|
||||
await maintenance.migrateCodecBatch({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "MIGRATION_FAILED",
|
||||
recovery: "READ_ONLY",
|
||||
},
|
||||
});
|
||||
expect(memory.readRaw("records", "oversized")).toMatchObject({
|
||||
codecVersion: 1,
|
||||
});
|
||||
expect(
|
||||
memory.readRaw("governance", "dataset-budget"),
|
||||
).toEqual(beforeBudget);
|
||||
});
|
||||
|
||||
it("prunes only expired receipts and bounds each committed batch", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedReceipt(memory, "expired-a", 100);
|
||||
seedReceipt(memory, "expired-b", 200);
|
||||
seedReceipt(memory, "expires-now", 300);
|
||||
seedReceipt(memory, "inside-replay-window", 301);
|
||||
const maintenance = createMaintenance(
|
||||
memory,
|
||||
defaultPolicy(),
|
||||
{ nowEpochMilliseconds: () => 300 },
|
||||
);
|
||||
|
||||
expect(
|
||||
await maintenance.pruneExpiredReceipts({
|
||||
maxRows: 2,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "MORE",
|
||||
scannedRows: 2,
|
||||
deletedRows: 2,
|
||||
budgetExhausted: false,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
memory.readRaw("receipts", "inside-replay-window"),
|
||||
).toBeDefined();
|
||||
|
||||
expect(
|
||||
await maintenance.pruneExpiredReceipts({
|
||||
maxRows: 2,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "COMPLETE",
|
||||
scannedRows: 1,
|
||||
deletedRows: 1,
|
||||
budgetExhausted: false,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
memory.readRaw("receipts", "expires-now"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
memory.readRaw("receipts", "inside-replay-window"),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
memory.readRaw("governance", "dataset-budget"),
|
||||
).toMatchObject({ receiptCount: 1 });
|
||||
});
|
||||
|
||||
it("reports prune success only after commit and rolls back quota failure", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedReceipt(memory, "quota-receipt", 100);
|
||||
const maintenance = createMaintenance(
|
||||
memory,
|
||||
defaultPolicy(),
|
||||
{ nowEpochMilliseconds: () => 200 },
|
||||
);
|
||||
memory.failNextWriteCommit(
|
||||
new DOMException("private receipt", "QuotaExceededError"),
|
||||
);
|
||||
|
||||
expect(
|
||||
await maintenance.pruneExpiredReceipts({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 10_000,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "QUOTA_EXCEEDED" },
|
||||
});
|
||||
expect(
|
||||
memory.readRaw("receipts", "quota-receipt"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("aborts an in-flight prune transaction without deleting receipts", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedReceipt(memory, "abort-receipt", 100);
|
||||
const maintenance = createMaintenance(
|
||||
memory,
|
||||
defaultPolicy(),
|
||||
{ nowEpochMilliseconds: () => 200 },
|
||||
);
|
||||
memory.clearLastTransaction();
|
||||
memory.pauseTransactions();
|
||||
const controller = new AbortController();
|
||||
|
||||
const pending = maintenance.pruneExpiredReceipts({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 10_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await waitForWriteTransaction(memory);
|
||||
controller.abort();
|
||||
expect(await pending).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
memory.resumeTransactions();
|
||||
await Promise.resolve();
|
||||
expect(
|
||||
memory.readRaw("receipts", "abort-receipt"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("honors the prune time budget before deleting a replay receipt", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
await prepareSchema(memory);
|
||||
seedReceipt(memory, "budget-receipt", 100);
|
||||
let monotonicTime = 0;
|
||||
const maintenance = createMaintenance(
|
||||
memory,
|
||||
defaultPolicy(),
|
||||
{
|
||||
now: () => {
|
||||
const value = monotonicTime;
|
||||
monotonicTime += 5;
|
||||
return value;
|
||||
},
|
||||
nowEpochMilliseconds: () => 200,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
await maintenance.pruneExpiredReceipts({
|
||||
maxRows: 10,
|
||||
maxDurationMs: 1,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "MORE",
|
||||
scannedRows: 0,
|
||||
deletedRows: 0,
|
||||
budgetExhausted: true,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
memory.readRaw("receipts", "budget-receipt"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
BeginOpfsJournalTransaction,
|
||||
OpfsPreparedObject,
|
||||
OpfsStorageScope,
|
||||
} from "../../src/application/ports/browser-file-storage/opfs-ports.ts";
|
||||
import type {
|
||||
BrowserStoragePolicy,
|
||||
} from "../../src/application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
createIndexedDbOpfsJournal,
|
||||
opfsJournalDatabaseName,
|
||||
} from "../../src/adapters/storage/opfs/indexeddb-opfs-journal.ts";
|
||||
import { MemoryIndexedDbFactory } from "../helpers/memory-indexeddb.ts";
|
||||
|
||||
const authorityToken = "authority_12345678";
|
||||
const scope: OpfsStorageScope = Object.freeze({
|
||||
namespace: "durable-objects",
|
||||
authorityToken,
|
||||
namespaceToken: "namespace_12345678",
|
||||
partitionToken: "partition_12345678",
|
||||
});
|
||||
const otherPartition: OpfsStorageScope = Object.freeze({
|
||||
...scope,
|
||||
partitionToken: "partition_87654321",
|
||||
});
|
||||
const policy: BrowserStoragePolicy = Object.freeze({
|
||||
owner: "test-owner",
|
||||
namespace: scope.namespace,
|
||||
classification: "PERSONAL",
|
||||
authority: "LOCAL_FIRST",
|
||||
accountScope: "OPAQUE_PARTITION",
|
||||
retention: Object.freeze({ kind: "EXPLICIT_DELETE" }),
|
||||
softBudgetBytes: 8,
|
||||
hardBudgetBytes: 10,
|
||||
evictionPriority: "USER_AUTHORED",
|
||||
logoutAction: "EXPORT_THEN_PURGE",
|
||||
accountDeletionAction: "PURGE_PARTITION",
|
||||
pressureAction: "RETAIN",
|
||||
unavailableFallback: "EXPORT_REQUIRED",
|
||||
});
|
||||
|
||||
function beginInput(
|
||||
transactionId: string,
|
||||
objectId: string,
|
||||
byteLength: number,
|
||||
targetScope = scope,
|
||||
): BeginOpfsJournalTransaction {
|
||||
return {
|
||||
transactionId,
|
||||
mutation: "PUT",
|
||||
scope: targetScope,
|
||||
objectId,
|
||||
expectedGeneration: null,
|
||||
targetGeneration: 1,
|
||||
targetByteLength: byteLength,
|
||||
targetStoragePolicy: {
|
||||
...policy,
|
||||
namespace: targetScope.namespace,
|
||||
},
|
||||
startedAtEpochMs: 100,
|
||||
};
|
||||
}
|
||||
|
||||
function prepared(
|
||||
input: BeginOpfsJournalTransaction,
|
||||
): OpfsPreparedObject {
|
||||
return {
|
||||
physicalSchemaVersion: 1,
|
||||
descriptor: {
|
||||
objectId: input.objectId,
|
||||
scope: input.scope,
|
||||
generation: input.targetGeneration,
|
||||
byteLength: input.targetByteLength,
|
||||
mediaType: "application/octet-stream",
|
||||
createdAtEpochMs: input.startedAtEpochMs,
|
||||
integrity: {
|
||||
algorithm: "SHA-256-TREE-V1",
|
||||
rootDigestHex: "a".repeat(64),
|
||||
chunkSizeBytes: 64 * 1024,
|
||||
},
|
||||
storagePolicy: input.targetStoragePolicy,
|
||||
},
|
||||
chunks:
|
||||
input.targetByteLength === 0
|
||||
? []
|
||||
: [
|
||||
{
|
||||
sequence: 0,
|
||||
byteLength: input.targetByteLength,
|
||||
digestHex: "b".repeat(64),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness(factory = new MemoryIndexedDbFactory()) {
|
||||
let tokenSequence = 0;
|
||||
const journal = createIndexedDbOpfsJournal({
|
||||
authorityToken,
|
||||
factory: factory.factory,
|
||||
createFencingToken: () =>
|
||||
`fencing_${String(++tokenSequence).padStart(8, "0")}`,
|
||||
});
|
||||
return { factory, journal };
|
||||
}
|
||||
|
||||
describe("IndexedDB OPFS journal", () => {
|
||||
it("fences stale writers and atomically publishes object, budget and chunk refs", async () => {
|
||||
const { factory, journal } = createHarness();
|
||||
const input = beginInput(
|
||||
"transaction_12345678",
|
||||
"object_12345678",
|
||||
3,
|
||||
);
|
||||
const begun = await journal.begin(input);
|
||||
expect(begun).toMatchObject({ ok: true });
|
||||
if (!begun.ok) throw new Error("begin failed");
|
||||
|
||||
expect(
|
||||
await journal.markFilesReady(
|
||||
input.transactionId,
|
||||
"fencing_stale_1234",
|
||||
prepared(input),
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { code: "CONFLICT" } });
|
||||
expect(
|
||||
await journal.markFilesReady(
|
||||
input.transactionId,
|
||||
begun.value.fencingToken,
|
||||
prepared(input),
|
||||
),
|
||||
).toMatchObject({ ok: true, value: { phase: "FILES_READY" } });
|
||||
|
||||
factory.failNextWriteCommit(
|
||||
new DOMException("quota", "QuotaExceededError"),
|
||||
);
|
||||
expect(
|
||||
await journal.commitPut(
|
||||
input.transactionId,
|
||||
begun.value.fencingToken,
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { code: "QUOTA_EXCEEDED" } });
|
||||
expect(
|
||||
await journal.getCommittedObject(scope, input.objectId),
|
||||
).toEqual({ ok: true, value: null });
|
||||
expect(
|
||||
await journal.isChunkReferenced(scope, "b".repeat(64)),
|
||||
).toEqual({ ok: true, value: false });
|
||||
|
||||
expect(
|
||||
await journal.commitPut(
|
||||
input.transactionId,
|
||||
begun.value.fencingToken,
|
||||
),
|
||||
).toMatchObject({ ok: true, value: { phase: "COMMITTED" } });
|
||||
expect(
|
||||
await journal.getCommittedObject(scope, input.objectId),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { descriptor: { byteLength: 3, generation: 1 } },
|
||||
});
|
||||
expect(
|
||||
await journal.isChunkReferenced(scope, "b".repeat(64)),
|
||||
).toEqual({ ok: true, value: true });
|
||||
expect(
|
||||
await journal.isChunkReferenced(otherPartition, "b".repeat(64)),
|
||||
).toEqual({ ok: true, value: false });
|
||||
});
|
||||
|
||||
it("reserves the hard budget in the same transaction and releases it on rollback", async () => {
|
||||
const { journal } = createHarness();
|
||||
const first = await journal.begin(
|
||||
beginInput("transaction_11111111", "object_11111111", 6),
|
||||
);
|
||||
expect(first).toMatchObject({ ok: true });
|
||||
if (!first.ok) throw new Error("begin failed");
|
||||
|
||||
expect(
|
||||
await journal.begin(
|
||||
beginInput("transaction_22222222", "object_22222222", 5),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
expect(
|
||||
await journal.begin(
|
||||
beginInput(
|
||||
"transaction_33333333",
|
||||
"object_33333333",
|
||||
10,
|
||||
otherPartition,
|
||||
),
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
|
||||
expect(
|
||||
await journal.rollback(
|
||||
"transaction_11111111",
|
||||
first.value.fencingToken,
|
||||
),
|
||||
).toEqual({ ok: true, value: undefined });
|
||||
expect(
|
||||
await journal.begin(
|
||||
beginInput("transaction_44444444", "object_44444444", 5),
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("commits and completes a DELETE row without requiring a prepared object", async () => {
|
||||
const { journal } = createHarness();
|
||||
const putInput = beginInput(
|
||||
"transaction_55555555",
|
||||
"object_55555555",
|
||||
2,
|
||||
);
|
||||
const put = await journal.begin(putInput);
|
||||
if (!put.ok) throw new Error("put begin failed");
|
||||
await journal.markFilesReady(
|
||||
putInput.transactionId,
|
||||
put.value.fencingToken,
|
||||
prepared(putInput),
|
||||
);
|
||||
await journal.commitPut(
|
||||
putInput.transactionId,
|
||||
put.value.fencingToken,
|
||||
);
|
||||
await journal.complete(
|
||||
putInput.transactionId,
|
||||
put.value.fencingToken,
|
||||
);
|
||||
|
||||
const deletion = await journal.begin({
|
||||
transactionId: "transaction_66666666",
|
||||
mutation: "DELETE",
|
||||
scope,
|
||||
objectId: putInput.objectId,
|
||||
expectedGeneration: 1,
|
||||
targetGeneration: 2,
|
||||
targetByteLength: 0,
|
||||
targetStoragePolicy: policy,
|
||||
startedAtEpochMs: 200,
|
||||
});
|
||||
if (!deletion.ok) throw new Error("delete begin failed");
|
||||
expect(
|
||||
await journal.commitDelete(
|
||||
"transaction_66666666",
|
||||
deletion.value.fencingToken,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { mutation: "DELETE", phase: "COMMITTED" },
|
||||
});
|
||||
expect(
|
||||
await journal.complete(
|
||||
"transaction_66666666",
|
||||
deletion.value.fencingToken,
|
||||
),
|
||||
).toEqual({ ok: true, value: undefined });
|
||||
expect(
|
||||
await journal.getCommittedObject(scope, putInput.objectId),
|
||||
).toEqual({ ok: true, value: null });
|
||||
});
|
||||
|
||||
it("binds one opaque physical authority to one deterministic database", async () => {
|
||||
expect(opfsJournalDatabaseName(authorityToken)).toBe(
|
||||
`ca-frontend-opfs-metadata-v1:${authorityToken}`,
|
||||
);
|
||||
expect(() =>
|
||||
createIndexedDbOpfsJournal({
|
||||
authorityToken,
|
||||
databaseName: "unrelated-database",
|
||||
factory: new MemoryIndexedDbFactory().factory,
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
const { journal } = createHarness();
|
||||
const binding = await journal.begin(
|
||||
beginInput(
|
||||
"transaction_binding_1234",
|
||||
"object_binding_12345678",
|
||||
1,
|
||||
),
|
||||
);
|
||||
if (!binding.ok) throw new Error("scope binding failed");
|
||||
await journal.rollback(
|
||||
"transaction_binding_1234",
|
||||
binding.value.fencingToken,
|
||||
);
|
||||
|
||||
const remappedScope: OpfsStorageScope = {
|
||||
...scope,
|
||||
namespace: "different-namespace",
|
||||
};
|
||||
expect(
|
||||
await journal.begin(
|
||||
beginInput(
|
||||
"transaction_remap_1234",
|
||||
"object_remap_12345678",
|
||||
1,
|
||||
remappedScope,
|
||||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
const retokenedScope: OpfsStorageScope = {
|
||||
...scope,
|
||||
namespaceToken: "namespace_87654321",
|
||||
};
|
||||
expect(
|
||||
await journal.begin(
|
||||
beginInput(
|
||||
"transaction_retoken_1234",
|
||||
"object_retoken_12345678",
|
||||
1,
|
||||
retokenedScope,
|
||||
),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
expect(
|
||||
await journal.begin({
|
||||
...beginInput(
|
||||
"transaction_policy_1234",
|
||||
"object_policy_12345678",
|
||||
1,
|
||||
),
|
||||
targetStoragePolicy: {
|
||||
...policy,
|
||||
owner: "different-owner",
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
|
||||
const foreignScope: OpfsStorageScope = {
|
||||
...scope,
|
||||
authorityToken: "authority_87654321",
|
||||
};
|
||||
expect(
|
||||
await journal.getCommittedObject(
|
||||
foreignScope,
|
||||
"object_12345678",
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { code: "INVALID_INPUT" } });
|
||||
expect(
|
||||
await journal.begin(
|
||||
beginInput(
|
||||
"transaction_77777777",
|
||||
"object_77777777",
|
||||
1,
|
||||
foreignScope,
|
||||
),
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { code: "INVALID_INPUT" } });
|
||||
});
|
||||
|
||||
it("maps synchronous open failures and blocked upgrades to Result failures", async () => {
|
||||
const throwingFactory = {
|
||||
open() {
|
||||
throw new DOMException("denied", "SecurityError");
|
||||
},
|
||||
} as unknown as IDBFactory;
|
||||
const throwingJournal = createIndexedDbOpfsJournal({
|
||||
authorityToken,
|
||||
factory: throwingFactory,
|
||||
});
|
||||
await expect(
|
||||
throwingJournal.getCommittedObject(scope, "object_12345678"),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
|
||||
const factory = new MemoryIndexedDbFactory();
|
||||
factory.blockNextOpen();
|
||||
const callbacks: Array<() => void> = [];
|
||||
const blockedJournal = createIndexedDbOpfsJournal({
|
||||
authorityToken,
|
||||
factory: factory.factory,
|
||||
blockedTimeoutMs: 1,
|
||||
scheduler: {
|
||||
setTimeout(callback) {
|
||||
callbacks.push(callback);
|
||||
return callback;
|
||||
},
|
||||
clearTimeout() {},
|
||||
},
|
||||
});
|
||||
const opening = blockedJournal.getCommittedObject(
|
||||
scope,
|
||||
"object_12345678",
|
||||
);
|
||||
await Promise.resolve();
|
||||
callbacks.forEach((callback) => callback());
|
||||
await expect(opening).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "BLOCKED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reopens after versionchange and rejects corrupt persisted journal rows", async () => {
|
||||
const { factory, journal } = createHarness();
|
||||
expect(
|
||||
await journal.getCommittedObject(scope, "object_12345678"),
|
||||
).toEqual({ ok: true, value: null });
|
||||
factory.triggerVersionChange(2);
|
||||
expect(factory.isConnectionClosed()).toBe(true);
|
||||
expect(
|
||||
await journal.getCommittedObject(scope, "object_12345678"),
|
||||
).toEqual({ ok: true, value: null });
|
||||
|
||||
factory.seed("opfs-journal", {
|
||||
transactionId: "transaction_88888888",
|
||||
startedAtEpochMs: 1,
|
||||
corrupt: true,
|
||||
});
|
||||
expect(await journal.listIncomplete(10)).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "CORRUPT_DATA" },
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MANUAL_A11Y_ROUTE_IDS,
|
||||
validateManualA11yEvidence,
|
||||
} from "../../scripts/lib/manual-a11y-evidence.mjs";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
|
||||
} from "../../scripts/lib/manual-a11y-evidence.ts";
|
||||
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.ts";
|
||||
|
||||
const reviewed = `Status: reviewed
|
||||
Route ID: APP_HOME
|
||||
@@ -3,11 +3,11 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
NAVIGATION_ROUTES,
|
||||
ROUTE_REGISTRY,
|
||||
} from "../../src/features/installed-feature-contracts.js";
|
||||
} from "../../src/features/installed-feature-contracts.ts";
|
||||
import {
|
||||
createRedirectLoopGuard,
|
||||
decideRouteAccess,
|
||||
} from "../../src/presentation/routes/navigation-policy.js";
|
||||
} from "../../src/presentation/routes/navigation-policy.ts";
|
||||
|
||||
describe("installed route registry", () => {
|
||||
it("derives visible navigation in explicit order", () => {
|
||||
@@ -15,8 +15,7 @@ describe("installed route registry", () => {
|
||||
.filter((route) => route.navigationOrder !== null)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
/** @type {number} */ (left.navigationOrder) -
|
||||
/** @type {number} */ (right.navigationOrder),
|
||||
left.navigationOrder! - right.navigationOrder!,
|
||||
)
|
||||
.map((route) => route.routeId);
|
||||
expect(NAVIGATION_ROUTES.map(({ routeId }) => routeId)).toEqual(expected);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,649 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type {
|
||||
OpfsPreparedObject,
|
||||
OpfsStorageScope,
|
||||
} from "../../src/application/ports/browser-file-storage/opfs-ports.ts";
|
||||
import type {
|
||||
BrowserStoragePolicy,
|
||||
} from "../../src/application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
resolveOpfsRuntimePolicy,
|
||||
} from "../../src/adapters/storage/opfs/opfs-policy.ts";
|
||||
import {
|
||||
createOpfsWorkerGateway,
|
||||
type OpfsWorkerLike,
|
||||
} from "../../src/adapters/storage/opfs/opfs-worker-client.ts";
|
||||
import type {
|
||||
OpfsWorkerRequest,
|
||||
OpfsWorkerResponse,
|
||||
} from "../../src/adapters/storage/opfs/opfs-worker-protocol.ts";
|
||||
import {
|
||||
createOpfsWorkerRuntime,
|
||||
type OpfsMutationLeaseManager,
|
||||
} from "../../src/adapters/storage/opfs/opfs-worker-runtime.ts";
|
||||
|
||||
const scopeA: OpfsStorageScope = Object.freeze({
|
||||
namespace: "durable-objects",
|
||||
authorityToken: "authority_12345678",
|
||||
namespaceToken: "namespace_12345678",
|
||||
partitionToken: "partition_12345678",
|
||||
});
|
||||
const scopeB: OpfsStorageScope = Object.freeze({
|
||||
...scopeA,
|
||||
authorityToken: "authority_87654321",
|
||||
});
|
||||
const storagePolicy: BrowserStoragePolicy = Object.freeze({
|
||||
owner: "test-owner",
|
||||
namespace: scopeA.namespace,
|
||||
classification: "PERSONAL",
|
||||
authority: "LOCAL_FIRST",
|
||||
accountScope: "OPAQUE_PARTITION",
|
||||
retention: Object.freeze({ kind: "EXPLICIT_DELETE" }),
|
||||
softBudgetBytes: 1024 * 1024,
|
||||
hardBudgetBytes: 2 * 1024 * 1024,
|
||||
evictionPriority: "USER_AUTHORED",
|
||||
logoutAction: "EXPORT_THEN_PURGE",
|
||||
accountDeletionAction: "PURGE_PARTITION",
|
||||
pressureAction: "RETAIN",
|
||||
unavailableFallback: "EXPORT_REQUIRED",
|
||||
});
|
||||
const runtimePolicy = resolveOpfsRuntimePolicy({
|
||||
chunkSizeBytes: 64 * 1024,
|
||||
maxObjectBytes: 64 * 1024,
|
||||
maxChunkCount: 1,
|
||||
orphanGracePeriodMs: 60_000,
|
||||
});
|
||||
|
||||
type MemoryNode = MemoryDirectory | MemoryFile;
|
||||
|
||||
class MemoryFile {
|
||||
readonly kind = "file";
|
||||
bytes = new Uint8Array();
|
||||
lastModified = 0;
|
||||
}
|
||||
|
||||
class MemoryDirectory {
|
||||
readonly kind = "directory";
|
||||
readonly children = new Map<string, MemoryNode>();
|
||||
|
||||
async getDirectoryHandle(
|
||||
name: string,
|
||||
options: FileSystemGetDirectoryOptions = {},
|
||||
): Promise<FileSystemDirectoryHandle> {
|
||||
const current = this.children.get(name);
|
||||
if (current instanceof MemoryDirectory) {
|
||||
return current as unknown as FileSystemDirectoryHandle;
|
||||
}
|
||||
if (current || !options.create) throw notFound();
|
||||
const created = new MemoryDirectory();
|
||||
this.children.set(name, created);
|
||||
return created as unknown as FileSystemDirectoryHandle;
|
||||
}
|
||||
|
||||
async getFileHandle(
|
||||
name: string,
|
||||
options: FileSystemGetFileOptions = {},
|
||||
): Promise<FileSystemFileHandle> {
|
||||
const current = this.children.get(name);
|
||||
if (current instanceof MemoryFile) {
|
||||
return fileHandle(current);
|
||||
}
|
||||
if (current || !options.create) throw notFound();
|
||||
const created = new MemoryFile();
|
||||
this.children.set(name, created);
|
||||
return fileHandle(created);
|
||||
}
|
||||
|
||||
async removeEntry(
|
||||
name: string,
|
||||
options: FileSystemRemoveOptions = {},
|
||||
): Promise<void> {
|
||||
const current = this.children.get(name);
|
||||
if (!current) throw notFound();
|
||||
if (
|
||||
current instanceof MemoryDirectory &&
|
||||
current.children.size > 0 &&
|
||||
!options.recursive
|
||||
) {
|
||||
throw new DOMException("Directory is not empty.", "InvalidModificationError");
|
||||
}
|
||||
this.children.delete(name);
|
||||
}
|
||||
|
||||
async *entries(): AsyncIterableIterator<
|
||||
[string, FileSystemDirectoryHandle | FileSystemFileHandle]
|
||||
> {
|
||||
for (const [name, node] of this.children) {
|
||||
yield [
|
||||
name,
|
||||
node instanceof MemoryDirectory
|
||||
? (node as unknown as FileSystemDirectoryHandle)
|
||||
: fileHandle(node),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
has(path: readonly string[]): boolean {
|
||||
let current: MemoryNode = this;
|
||||
for (const segment of path) {
|
||||
if (!(current instanceof MemoryDirectory)) return false;
|
||||
const next = current.children.get(segment);
|
||||
if (!next) return false;
|
||||
current = next;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function fileHandle(file: MemoryFile): FileSystemFileHandle {
|
||||
return {
|
||||
kind: "file",
|
||||
name: "memory",
|
||||
async getFile() {
|
||||
const blob = new Blob([Uint8Array.from(file.bytes)]);
|
||||
Object.defineProperty(blob, "lastModified", {
|
||||
value: file.lastModified,
|
||||
});
|
||||
return blob as File;
|
||||
},
|
||||
async createWritable() {
|
||||
let pending = Uint8Array.from(file.bytes);
|
||||
return {
|
||||
async write(data: FileSystemWriteChunkType) {
|
||||
if (!(data instanceof Uint8Array)) {
|
||||
throw new TypeError("The test writer accepts Uint8Array only.");
|
||||
}
|
||||
pending = Uint8Array.from(data);
|
||||
},
|
||||
async close() {
|
||||
file.bytes = pending;
|
||||
file.lastModified = Date.now();
|
||||
},
|
||||
async abort() {},
|
||||
} as unknown as FileSystemWritableFileStream;
|
||||
},
|
||||
} as FileSystemFileHandle;
|
||||
}
|
||||
|
||||
function notFound(): DOMException {
|
||||
return new DOMException("Entry was not found.", "NotFoundError");
|
||||
}
|
||||
|
||||
function beginRequest(
|
||||
requestId: string,
|
||||
transactionId: string,
|
||||
scope: OpfsStorageScope,
|
||||
): OpfsWorkerRequest {
|
||||
return {
|
||||
requestId,
|
||||
kind: "BEGIN_PUT",
|
||||
transactionId,
|
||||
scope,
|
||||
objectId: "object_12345678",
|
||||
generation: 1,
|
||||
declaredByteLength: 1,
|
||||
mediaType: "application/octet-stream",
|
||||
createdAtEpochMs: 100,
|
||||
storagePolicy,
|
||||
chunkSizeBytes: runtimePolicy.chunkSizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function immediateLeases(releases: { count: number }): OpfsMutationLeaseManager {
|
||||
return {
|
||||
async acquire() {
|
||||
let released = false;
|
||||
return {
|
||||
release() {
|
||||
if (released) return;
|
||||
released = true;
|
||||
releases.count += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function expectFailureCode(
|
||||
response: OpfsWorkerResponse | null,
|
||||
code: string,
|
||||
): void {
|
||||
expect(response).toMatchObject({ ok: false, failure: { code } });
|
||||
}
|
||||
|
||||
function preparedValue(
|
||||
response: OpfsWorkerResponse | null,
|
||||
): OpfsPreparedObject {
|
||||
if (
|
||||
!response?.ok ||
|
||||
!("value" in response) ||
|
||||
!response.value ||
|
||||
typeof response.value !== "object" ||
|
||||
response.value instanceof ArrayBuffer ||
|
||||
!("descriptor" in response.value)
|
||||
) {
|
||||
throw new Error("Expected a prepared OPFS object.");
|
||||
}
|
||||
return response.value;
|
||||
}
|
||||
|
||||
describe("OPFS dedicated worker runtime", () => {
|
||||
it("cancels an exact BEGIN while waiting for a lock and releases a late lease", async () => {
|
||||
const root = new MemoryDirectory();
|
||||
let resolveLease:
|
||||
| ((lease: { release(): void }) => void)
|
||||
| undefined;
|
||||
let acquireSignal: AbortSignal | undefined;
|
||||
let releases = 0;
|
||||
const leaseManager: OpfsMutationLeaseManager = {
|
||||
acquire(signal) {
|
||||
acquireSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
resolveLease = resolve;
|
||||
});
|
||||
},
|
||||
};
|
||||
const runtime = createOpfsWorkerRuntime({
|
||||
root: root as unknown as FileSystemDirectoryHandle,
|
||||
crypto: globalThis.crypto,
|
||||
policy: runtimePolicy,
|
||||
leaseManager,
|
||||
dedicatedWorker: true,
|
||||
supportsSynchronousAccessHandles: false,
|
||||
});
|
||||
|
||||
const beginning = runtime.handleRequest(
|
||||
beginRequest(
|
||||
"request_begin_1234",
|
||||
"transaction_12345678",
|
||||
scopeA,
|
||||
),
|
||||
);
|
||||
await Promise.resolve();
|
||||
const aborted = await runtime.handleRequest({
|
||||
requestId: "request_abort_1234",
|
||||
kind: "ABORT_PUT",
|
||||
scope: scopeA,
|
||||
transactionId: "transaction_12345678",
|
||||
});
|
||||
expect(aborted).toMatchObject({ ok: true });
|
||||
expect(acquireSignal?.aborted).toBe(true);
|
||||
|
||||
resolveLease?.({
|
||||
release() {
|
||||
releases += 1;
|
||||
},
|
||||
});
|
||||
expectFailureCode(await beginning, "ABORTED");
|
||||
expect(releases).toBe(1);
|
||||
expect(root.children.size).toBe(0);
|
||||
});
|
||||
|
||||
it("serializes APPEND against ABORT so no receipt or generation is resurrected", async () => {
|
||||
const root = new MemoryDirectory();
|
||||
const releases = { count: 0 };
|
||||
let resolveDigest:
|
||||
| ((digest: ArrayBuffer) => void)
|
||||
| undefined;
|
||||
const delayedCrypto = {
|
||||
subtle: {
|
||||
digest: () =>
|
||||
new Promise<ArrayBuffer>((resolve) => {
|
||||
resolveDigest = resolve;
|
||||
}),
|
||||
},
|
||||
} as unknown as Crypto;
|
||||
const runtime = createOpfsWorkerRuntime({
|
||||
root: root as unknown as FileSystemDirectoryHandle,
|
||||
crypto: delayedCrypto,
|
||||
policy: runtimePolicy,
|
||||
leaseManager: immediateLeases(releases),
|
||||
dedicatedWorker: true,
|
||||
supportsSynchronousAccessHandles: false,
|
||||
});
|
||||
expect(
|
||||
await runtime.handleRequest(
|
||||
beginRequest(
|
||||
"request_begin_5678",
|
||||
"transaction_56785678",
|
||||
scopeA,
|
||||
),
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
|
||||
const appending = runtime.handleRequest({
|
||||
requestId: "request_append_5678",
|
||||
kind: "APPEND_CHUNK",
|
||||
scope: scopeA,
|
||||
transactionId: "transaction_56785678",
|
||||
sequence: 0,
|
||||
bytes: new Uint8Array([7]).buffer,
|
||||
});
|
||||
await Promise.resolve();
|
||||
const aborting = runtime.handleRequest({
|
||||
requestId: "request_abort_5678",
|
||||
kind: "ABORT_PUT",
|
||||
scope: scopeA,
|
||||
transactionId: "transaction_56785678",
|
||||
});
|
||||
resolveDigest?.(new Uint8Array(32).buffer);
|
||||
|
||||
expectFailureCode(await appending, "ABORTED");
|
||||
expect(await aborting).toMatchObject({ ok: true });
|
||||
expect(releases.count).toBe(1);
|
||||
expect(
|
||||
root.has([
|
||||
"authorities",
|
||||
scopeA.authorityToken,
|
||||
scopeA.namespaceToken,
|
||||
scopeA.partitionToken,
|
||||
"staging",
|
||||
"transaction_56785678",
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("isolates physical chunks and cancellation tombstones by opaque authority", async () => {
|
||||
const root = new MemoryDirectory();
|
||||
const releases = { count: 0 };
|
||||
const runtime = createOpfsWorkerRuntime({
|
||||
root: root as unknown as FileSystemDirectoryHandle,
|
||||
crypto: globalThis.crypto,
|
||||
policy: runtimePolicy,
|
||||
leaseManager: immediateLeases(releases),
|
||||
dedicatedWorker: true,
|
||||
supportsSynchronousAccessHandles: false,
|
||||
});
|
||||
const transactionId = "transaction_99999999";
|
||||
for (const [index, targetScope] of [scopeA, scopeB].entries()) {
|
||||
expect(
|
||||
await runtime.handleRequest(
|
||||
beginRequest(
|
||||
`request_begin_iso_${index}`,
|
||||
transactionId,
|
||||
targetScope,
|
||||
),
|
||||
),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: `request_append_iso_${index}`,
|
||||
kind: "APPEND_CHUNK",
|
||||
scope: targetScope,
|
||||
transactionId,
|
||||
sequence: 0,
|
||||
bytes: new Uint8Array([42]).buffer,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
}
|
||||
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_abort_iso_a",
|
||||
kind: "ABORT_PUT",
|
||||
scope: scopeA,
|
||||
transactionId,
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
const finished = await runtime.handleRequest({
|
||||
requestId: "request_finish_iso_b",
|
||||
kind: "FINISH_PUT",
|
||||
scope: scopeB,
|
||||
transactionId,
|
||||
});
|
||||
expect(finished).toMatchObject({ ok: true });
|
||||
const preparedObject = preparedValue(finished);
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_verify_iso_b",
|
||||
kind: "VERIFY_OBJECT",
|
||||
preparedObject,
|
||||
}),
|
||||
).toEqual({
|
||||
requestId: "request_verify_iso_b",
|
||||
ok: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("garbage-collects orphan chunks only inside the requested physical authority", async () => {
|
||||
const root = new MemoryDirectory();
|
||||
const releases = { count: 0 };
|
||||
const runtime = createOpfsWorkerRuntime({
|
||||
root: root as unknown as FileSystemDirectoryHandle,
|
||||
crypto: globalThis.crypto,
|
||||
policy: runtimePolicy,
|
||||
leaseManager: immediateLeases(releases),
|
||||
dedicatedWorker: true,
|
||||
supportsSynchronousAccessHandles: false,
|
||||
});
|
||||
const preparedByScope = new Map<string, OpfsPreparedObject>();
|
||||
for (const [index, targetScope] of [scopeA, scopeB].entries()) {
|
||||
const transactionId = `transaction_gc_${index}_1234`;
|
||||
await runtime.handleRequest(
|
||||
beginRequest(
|
||||
`request_gc_begin_${index}`,
|
||||
transactionId,
|
||||
targetScope,
|
||||
),
|
||||
);
|
||||
await runtime.handleRequest({
|
||||
requestId: `request_gc_append_${index}`,
|
||||
kind: "APPEND_CHUNK",
|
||||
scope: targetScope,
|
||||
transactionId,
|
||||
sequence: 0,
|
||||
bytes: new Uint8Array([99]).buffer,
|
||||
});
|
||||
const object = preparedValue(
|
||||
await runtime.handleRequest({
|
||||
requestId: `request_gc_finish_${index}`,
|
||||
kind: "FINISH_PUT",
|
||||
scope: targetScope,
|
||||
transactionId,
|
||||
}),
|
||||
);
|
||||
preparedByScope.set(targetScope.authorityToken, object);
|
||||
await runtime.handleRequest({
|
||||
requestId: `request_gc_finalize_${index}`,
|
||||
kind: "FINALIZE_PUT",
|
||||
transactionId,
|
||||
preparedObject: object,
|
||||
});
|
||||
}
|
||||
const digestHex = preparedByScope.get(
|
||||
scopeA.authorityToken,
|
||||
)!.chunks[0]!.digestHex;
|
||||
const cutoff = Date.now() + 10_000;
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_gc_list_a",
|
||||
kind: "LIST_ORPHAN_CANDIDATES",
|
||||
scope: scopeA,
|
||||
olderThanEpochMs: cutoff,
|
||||
maxEntries: 10,
|
||||
}),
|
||||
).toMatchObject({ ok: true, value: { digests: [digestHex] } });
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_gc_delete_a",
|
||||
kind: "DELETE_ORPHAN_CHUNK",
|
||||
scope: scopeA,
|
||||
digestHex,
|
||||
olderThanEpochMs: cutoff,
|
||||
}),
|
||||
).toMatchObject({ ok: true, value: { deleted: true } });
|
||||
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_gc_verify_a",
|
||||
kind: "VERIFY_OBJECT",
|
||||
preparedObject: preparedByScope.get(scopeA.authorityToken)!,
|
||||
}),
|
||||
).toEqual({
|
||||
requestId: "request_gc_verify_a",
|
||||
ok: true,
|
||||
value: false,
|
||||
});
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_gc_verify_b",
|
||||
kind: "VERIFY_OBJECT",
|
||||
preparedObject: preparedByScope.get(scopeB.authorityToken)!,
|
||||
}),
|
||||
).toEqual({
|
||||
requestId: "request_gc_verify_b",
|
||||
ok: true,
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports every required capability and fails closed when the lock is absent", async () => {
|
||||
const runtime = createOpfsWorkerRuntime({
|
||||
root: new MemoryDirectory() as unknown as FileSystemDirectoryHandle,
|
||||
crypto: globalThis.crypto,
|
||||
policy: runtimePolicy,
|
||||
leaseManager: null,
|
||||
dedicatedWorker: true,
|
||||
supportsSynchronousAccessHandles: true,
|
||||
});
|
||||
expect(
|
||||
await runtime.handleRequest({
|
||||
requestId: "request_caps_1234",
|
||||
kind: "CAPABILITIES",
|
||||
}),
|
||||
).toEqual({
|
||||
requestId: "request_caps_1234",
|
||||
ok: true,
|
||||
value: {
|
||||
available: false,
|
||||
dedicatedWorkerRequired: true,
|
||||
crossContextMutationLockAvailable: false,
|
||||
synchronousAccessHandleAvailable: true,
|
||||
},
|
||||
});
|
||||
expectFailureCode(
|
||||
await runtime.handleRequest(
|
||||
beginRequest(
|
||||
"request_begin_caps",
|
||||
"transaction_caps_1234",
|
||||
scopeA,
|
||||
),
|
||||
),
|
||||
"UNSUPPORTED",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OPFS worker client lifecycle", () => {
|
||||
it("scopes the open signal to verification and leaves the acquired source readable", async () => {
|
||||
let listener:
|
||||
| ((event: MessageEvent<unknown>) => void)
|
||||
| undefined;
|
||||
const worker: OpfsWorkerLike = {
|
||||
postMessage(message) {
|
||||
const response: OpfsWorkerResponse =
|
||||
message.kind === "VERIFY_OBJECT"
|
||||
? {
|
||||
requestId: message.requestId,
|
||||
ok: true,
|
||||
value: true,
|
||||
}
|
||||
: {
|
||||
requestId: message.requestId,
|
||||
ok: true,
|
||||
value: new Uint8Array([4, 2]).buffer,
|
||||
};
|
||||
queueMicrotask(() =>
|
||||
listener?.({ data: response } as MessageEvent<unknown>),
|
||||
);
|
||||
},
|
||||
addEventListener(_type, nextListener) {
|
||||
listener = nextListener;
|
||||
},
|
||||
removeEventListener() {
|
||||
listener = undefined;
|
||||
},
|
||||
};
|
||||
let requestSequence = 0;
|
||||
const gateway = createOpfsWorkerGateway({
|
||||
worker,
|
||||
policy: runtimePolicy,
|
||||
createRequestId: () => `request_open_${++requestSequence}_1234`,
|
||||
});
|
||||
const preparedObject: OpfsPreparedObject = {
|
||||
physicalSchemaVersion: 1,
|
||||
descriptor: {
|
||||
objectId: "object_open_12345678",
|
||||
scope: scopeA,
|
||||
generation: 1,
|
||||
byteLength: 2,
|
||||
mediaType: "application/octet-stream",
|
||||
createdAtEpochMs: 100,
|
||||
integrity: {
|
||||
algorithm: "SHA-256-TREE-V1",
|
||||
rootDigestHex: "a".repeat(64),
|
||||
chunkSizeBytes: runtimePolicy.chunkSizeBytes,
|
||||
},
|
||||
storagePolicy,
|
||||
},
|
||||
chunks: [
|
||||
{
|
||||
sequence: 0,
|
||||
byteLength: 2,
|
||||
digestHex: "b".repeat(64),
|
||||
},
|
||||
],
|
||||
};
|
||||
const openController = new AbortController();
|
||||
const opened = await gateway.openObject(
|
||||
preparedObject,
|
||||
openController.signal,
|
||||
);
|
||||
expect(opened.ok).toBe(true);
|
||||
openController.abort();
|
||||
|
||||
const chunks: number[][] = [];
|
||||
if (opened.ok) {
|
||||
for await (const result of opened.value.stream(
|
||||
new AbortController().signal,
|
||||
)) {
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) chunks.push([...result.value]);
|
||||
}
|
||||
}
|
||||
expect(chunks).toEqual([[4, 2]]);
|
||||
gateway.close();
|
||||
});
|
||||
|
||||
it("removes its listener and rejects all pending RPCs before Worker termination", async () => {
|
||||
const listeners = new Set<(event: MessageEvent<unknown>) => void>();
|
||||
const worker: OpfsWorkerLike = {
|
||||
postMessage() {},
|
||||
addEventListener(_type, listener) {
|
||||
listeners.add(listener);
|
||||
},
|
||||
removeEventListener(_type, listener) {
|
||||
listeners.delete(listener);
|
||||
},
|
||||
};
|
||||
const gateway = createOpfsWorkerGateway({
|
||||
worker,
|
||||
policy: runtimePolicy,
|
||||
createRequestId: () => "request_pending_1234",
|
||||
});
|
||||
const pending = gateway.capabilities();
|
||||
expect(listeners.size).toBe(1);
|
||||
gateway.close();
|
||||
expect(listeners.size).toBe(0);
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE" },
|
||||
});
|
||||
await expect(gateway.capabilities()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createOptimisticLayerRuntime } from "../../src/presentation/adapters/query/optimistic-layer-runtime.ts";
|
||||
import { createRuntimeIdentityRegistry } from "../../src/contracts/query-keys.ts";
|
||||
|
||||
function scope() {
|
||||
let current = true;
|
||||
return {
|
||||
snapshot: {
|
||||
generation: 1,
|
||||
fingerprint: "scope-token-00000001",
|
||||
identities: createRuntimeIdentityRegistry({
|
||||
tokenFactory: () => crypto.randomUUID(),
|
||||
}),
|
||||
isCurrent: () => current,
|
||||
},
|
||||
expire: () => {
|
||||
current = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("revision-safe optimistic layer runtime", () => {
|
||||
it("removes only the failed layer when commands settle out of order", () => {
|
||||
const client = new QueryClient();
|
||||
const key = ["query", "resources"];
|
||||
client.setQueryData(key, ["base"]);
|
||||
const selectedScope = scope();
|
||||
const runtime = createOptimisticLayerRuntime(client);
|
||||
const append = (previous: unknown, input: string) => [
|
||||
...(previous as string[]),
|
||||
input,
|
||||
];
|
||||
const first = runtime.begin(
|
||||
key,
|
||||
"first",
|
||||
append,
|
||||
selectedScope.snapshot,
|
||||
);
|
||||
const second = runtime.begin(
|
||||
key,
|
||||
"second",
|
||||
append,
|
||||
selectedScope.snapshot,
|
||||
);
|
||||
expect(client.getQueryData(key)).toEqual(["base", "first", "second"]);
|
||||
|
||||
second?.commit();
|
||||
first?.rollback();
|
||||
expect(client.getQueryData(key)).toEqual(["base", "second"]);
|
||||
});
|
||||
|
||||
it("reapplies pending layers over an authoritative external cache update", () => {
|
||||
const client = new QueryClient();
|
||||
const key = ["query", "resources"];
|
||||
client.setQueryData(key, ["base"]);
|
||||
const selectedScope = scope();
|
||||
const runtime = createOptimisticLayerRuntime(client);
|
||||
runtime.begin(
|
||||
key,
|
||||
"pending",
|
||||
(previous, input) => [...(previous as string[]), input],
|
||||
selectedScope.snapshot,
|
||||
);
|
||||
|
||||
client.setQueryData(key, ["server"]);
|
||||
expect(client.getQueryData(key)).toEqual(["server", "pending"]);
|
||||
});
|
||||
|
||||
it("removes scoped data instead of restoring it after scope expiry", () => {
|
||||
const client = new QueryClient();
|
||||
const key = ["query", "resources"];
|
||||
client.setQueryData(key, ["base"]);
|
||||
const selectedScope = scope();
|
||||
const runtime = createOptimisticLayerRuntime(client);
|
||||
const layer = runtime.begin(
|
||||
key,
|
||||
"pending",
|
||||
(previous, input) => [...(previous as string[]), input],
|
||||
selectedScope.snapshot,
|
||||
);
|
||||
selectedScope.expire();
|
||||
layer?.rollback();
|
||||
expect(client.getQueryData(key)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
evaluateFieldBudget,
|
||||
evaluateLabBudget,
|
||||
percentile75,
|
||||
} from "../../src/application/policies/performance-budgets.js";
|
||||
} from "../../src/application/policies/performance-budgets.ts";
|
||||
|
||||
describe("performance budgets", () => {
|
||||
it("rejects initial and lazy JavaScript above their named limits", () => {
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,13 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
evaluatePromotionReadiness,
|
||||
PROMOTION_FORMULA,
|
||||
} from "../../src/application/policies/promotion-readiness.js";
|
||||
} from "../../src/application/policies/promotion-readiness.ts";
|
||||
|
||||
const allGateIds = Object.values(PROMOTION_FORMULA).flat();
|
||||
const passing =
|
||||
/** @type {Record<string, "PASS" | "FAIL" | "UNVERIFIED">} */ (
|
||||
Object.fromEntries(allGateIds.map((gateId) => [gateId, "PASS"]))
|
||||
);
|
||||
type GateResults = Parameters<typeof evaluatePromotionReadiness>[0];
|
||||
const passing: GateResults = Object.fromEntries(
|
||||
allGateIds.map((gateId) => [gateId, "PASS"]),
|
||||
);
|
||||
|
||||
describe("promotion readiness formula", () => {
|
||||
it("requires every upstream tier before downstream readiness", () => {
|
||||
@@ -28,16 +28,12 @@ describe("promotion readiness formula", () => {
|
||||
["FE-GATE-016", "PROD_PROMOTION_READY"],
|
||||
["FE-GATE-018", "FIELD_SLO_READY"],
|
||||
["FE-GATE-017", "DOCUMENTATION_READY"],
|
||||
])("fails closed when %s fails", (failedGate, readiness) => {
|
||||
] as const)("fails closed when %s fails", (failedGate, readiness) => {
|
||||
const result = evaluatePromotionReadiness({
|
||||
...passing,
|
||||
[failedGate]: "FAIL",
|
||||
});
|
||||
const readinessKey =
|
||||
/** @type {keyof ReturnType<typeof evaluatePromotionReadiness>} */ (
|
||||
readiness
|
||||
);
|
||||
expect(result[readinessKey]).toBe(false);
|
||||
expect(result[readiness]).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat missing or unverified gates as pass", () => {
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,15 +4,14 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createQueryCacheAdapter,
|
||||
createQueryClient,
|
||||
} from "../../src/adapters/query-cache/tanstack-query-cache.js";
|
||||
import { canonicalize } from "../../src/contracts/query-keys.js";
|
||||
} from "../../src/adapters/query-cache/tanstack-query-cache.ts";
|
||||
import { canonicalize } from "../../src/contracts/query-keys.ts";
|
||||
|
||||
const queryKeys = Object.freeze({
|
||||
all: () => Object.freeze(["entity", 1]),
|
||||
list: (filters = {}) =>
|
||||
list: (filters: Readonly<Record<string, unknown>> = {}) =>
|
||||
Object.freeze(["entity", 1, "list", canonicalize(filters)]),
|
||||
/** @param {string} entityId */
|
||||
detail: (entityId) =>
|
||||
detail: (entityId: string) =>
|
||||
Object.freeze(["entity", 1, "detail", String(entityId)]),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
canonicalize,
|
||||
createRuntimeIdentityRegistry,
|
||||
runtimeIdentityToken,
|
||||
} from "../../src/contracts/query-keys.ts";
|
||||
|
||||
const sparseValue = Array<string>(2);
|
||||
sparseValue[1] = "sparse";
|
||||
|
||||
describe("strict server-state identity codec", () => {
|
||||
it("is stable across plain-object key ordering without exposing input in the token", () => {
|
||||
const left = { limit: 20, filters: { state: "open" } };
|
||||
const right = { filters: { state: "open" }, limit: 20 };
|
||||
expect(canonicalize(left)).toEqual(canonicalize(right));
|
||||
const token = runtimeIdentityToken(left);
|
||||
expect(token).toBe(runtimeIdentityToken(right));
|
||||
expect(token).not.toContain("open");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ value: { missing: undefined }, label: "undefined" },
|
||||
{ value: { number: Number.NaN }, label: "NaN" },
|
||||
{ value: { date: new Date() }, label: "Date" },
|
||||
{ value: sparseValue, label: "sparse array" },
|
||||
])("rejects $label", ({ value }) => {
|
||||
expect(() => canonicalize(value)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects cycles and shared-reference ambiguity", () => {
|
||||
const shared = {};
|
||||
expect(() => canonicalize({ left: shared, right: shared })).toThrow();
|
||||
const cycle: Record<string, unknown> = {};
|
||||
cycle.self = cycle;
|
||||
expect(() => canonicalize(cycle)).toThrow();
|
||||
});
|
||||
|
||||
it("keeps active identity leases non-evictable and evicts released LRU rows", () => {
|
||||
let sequence = 0;
|
||||
const registry = createRuntimeIdentityRegistry({
|
||||
maxEntries: 1,
|
||||
maxCanonicalBytes: 1_024,
|
||||
tokenFactory: () => `identity-token-${sequence++}`,
|
||||
});
|
||||
const first = registry.intern({ resource: "first" });
|
||||
first.acquire();
|
||||
expect(() => registry.intern({ resource: "second" })).toThrow(
|
||||
"capacity exceeded",
|
||||
);
|
||||
expect(registry.inspect().activeLeases).toBe(1);
|
||||
|
||||
first.release();
|
||||
const second = registry.intern({ resource: "second" });
|
||||
expect(second.token).not.toBe(first.token);
|
||||
expect(registry.inspect()).toMatchObject({
|
||||
entries: 1,
|
||||
activeLeases: 0,
|
||||
closed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when token collisions cannot be resolved", () => {
|
||||
const registry = createRuntimeIdentityRegistry({
|
||||
maxEntries: 2,
|
||||
tokenFactory: () => "identity-token-fixed",
|
||||
});
|
||||
registry.intern({ resource: "first" });
|
||||
expect(() => registry.intern({ resource: "second" })).toThrow(
|
||||
"token collision",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,876 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClockPort } from "../../../src/application/ports/clock-port.ts";
|
||||
import { definePollLeasePolicy } from "../../../src/application/policies/bounded-polling.ts";
|
||||
import {
|
||||
createBoundedPollCoordinator,
|
||||
type BoundedPollAttemptResult,
|
||||
type BoundedPollEnvironment,
|
||||
} from "../../../src/adapters/realtime/polling/bounded-poll-coordinator.ts";
|
||||
|
||||
type Sleeper = {
|
||||
dueAt: number;
|
||||
resolve(): void;
|
||||
reject(): void;
|
||||
signal?: AbortSignal;
|
||||
onAbort?: () => void;
|
||||
};
|
||||
|
||||
class ManualClock implements ClockPort {
|
||||
current = 0;
|
||||
readonly sleepers: Sleeper[] = [];
|
||||
|
||||
now(): number {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
const sleeper: Sleeper = {
|
||||
dueAt: this.current + milliseconds,
|
||||
resolve: () => {
|
||||
signal?.removeEventListener("abort", sleeper.onAbort!);
|
||||
resolve();
|
||||
},
|
||||
reject: () => {
|
||||
signal?.removeEventListener("abort", sleeper.onAbort!);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
signal,
|
||||
};
|
||||
sleeper.onAbort = () => {
|
||||
this.remove(sleeper);
|
||||
sleeper.reject();
|
||||
};
|
||||
signal?.addEventListener("abort", sleeper.onAbort, {
|
||||
once: true,
|
||||
});
|
||||
this.sleepers.push(sleeper);
|
||||
});
|
||||
}
|
||||
|
||||
advance(milliseconds: number): void {
|
||||
this.current += milliseconds;
|
||||
const ready = this.sleepers
|
||||
.filter((sleeper) => sleeper.dueAt <= this.current)
|
||||
.sort((left, right) => left.dueAt - right.dueAt);
|
||||
for (const sleeper of ready) {
|
||||
this.remove(sleeper);
|
||||
sleeper.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
private remove(target: Sleeper): void {
|
||||
const index = this.sleepers.indexOf(target);
|
||||
if (index >= 0) this.sleepers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
class MutableEnvironment implements BoundedPollEnvironment {
|
||||
visible = true;
|
||||
connected = true;
|
||||
readonly visibilityListeners = new Set<
|
||||
(visibility: "HIDDEN" | "VISIBLE") => void
|
||||
>();
|
||||
readonly onlineListeners = new Set<(online: boolean) => void>();
|
||||
|
||||
visibility(): "HIDDEN" | "VISIBLE" {
|
||||
return this.visible ? "VISIBLE" : "HIDDEN";
|
||||
}
|
||||
|
||||
online(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
subscribeVisibility(
|
||||
listener: (visibility: "HIDDEN" | "VISIBLE") => void,
|
||||
): () => void {
|
||||
this.visibilityListeners.add(listener);
|
||||
return () => this.visibilityListeners.delete(listener);
|
||||
}
|
||||
|
||||
subscribeOnline(listener: (online: boolean) => void): () => void {
|
||||
this.onlineListeners.add(listener);
|
||||
return () => this.onlineListeners.delete(listener);
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.visible = false;
|
||||
for (const listener of this.visibilityListeners) listener("HIDDEN");
|
||||
}
|
||||
}
|
||||
|
||||
const policy = definePollLeasePolicy({
|
||||
operationId: "GET_JOB_STATUS",
|
||||
owner: "reference-job",
|
||||
minimumIntervalMs: 5_000,
|
||||
successIntervalMs: 5_000,
|
||||
maxIntervalMs: 60_000,
|
||||
maxAttempts: 3,
|
||||
maxElapsedMs: 60_000,
|
||||
maxResponseBytes: 1_024,
|
||||
visibility: "VISIBLE_ONLY",
|
||||
fallbackReason: "CONVERGENCE",
|
||||
terminalStates: ["COMPLETED", "FAILED"],
|
||||
});
|
||||
const operation = {
|
||||
operationId: "GET_JOB_STATUS",
|
||||
contractVersion: 2,
|
||||
protocol: "REST",
|
||||
semantics: "QUERY",
|
||||
method: "GET",
|
||||
replayPolicy: "SAFE",
|
||||
retry: "never",
|
||||
maxResponseBytes: 1_024,
|
||||
transportMaxAttempts: 1,
|
||||
authRecoveryCount: 0,
|
||||
maxCumulativeSleepMs: 0,
|
||||
serverStream: false,
|
||||
} as const;
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let turn = 0; turn < 12; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe("bounded poll coordinator", () => {
|
||||
it("chains completed attempts without overlap and stops at a terminal state", async () => {
|
||||
const clock = new ManualClock();
|
||||
const environment = new MutableEnvironment();
|
||||
let active = 0;
|
||||
let highWatermark = 0;
|
||||
const execute = vi
|
||||
.fn<
|
||||
(
|
||||
input: Readonly<{
|
||||
operationId: string;
|
||||
attempt: number;
|
||||
maxResponseBytes: number;
|
||||
signal: AbortSignal;
|
||||
}>,
|
||||
) => Promise<BoundedPollAttemptResult<string>>
|
||||
>()
|
||||
.mockImplementation(async ({ attempt }) => {
|
||||
active += 1;
|
||||
highWatermark = Math.max(highWatermark, active);
|
||||
await Promise.resolve();
|
||||
active -= 1;
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: attempt === 1 ? "working" : "done",
|
||||
responseBytes: 32,
|
||||
state: attempt === 1 ? "RUNNING" : "COMPLETED",
|
||||
},
|
||||
};
|
||||
});
|
||||
const onValue = vi.fn();
|
||||
const coordinator = createBoundedPollCoordinator({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment,
|
||||
clock,
|
||||
random: () => 0.5,
|
||||
});
|
||||
|
||||
const result = coordinator.run({ onValue });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(clock.sleepers).toHaveLength(1);
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "TERMINAL",
|
||||
attempts: 2,
|
||||
state: "COMPLETED",
|
||||
value: "done",
|
||||
},
|
||||
});
|
||||
expect(onValue.mock.calls.map(([value]) => value)).toEqual([
|
||||
"working",
|
||||
"done",
|
||||
]);
|
||||
expect(highWatermark).toBe(1);
|
||||
expect(coordinator.getState()).toBe("IDLE");
|
||||
expect(environment.visibilityListeners.size).toBe(0);
|
||||
expect(environment.onlineListeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it("passes the strictest response ceiling to the executor before decode", async () => {
|
||||
const clock = new ManualClock();
|
||||
const execute = vi.fn(
|
||||
async ({
|
||||
maxResponseBytes,
|
||||
}: Readonly<{ maxResponseBytes: number }>) => ({
|
||||
ok: true as const,
|
||||
value: {
|
||||
kind: "VALUE" as const,
|
||||
value: "done",
|
||||
responseBytes: maxResponseBytes,
|
||||
state: "COMPLETED",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation: {
|
||||
...operation,
|
||||
maxResponseBytes: 4_096,
|
||||
},
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { state: "COMPLETED" },
|
||||
});
|
||||
expect(execute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ maxResponseBytes: 1_024 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not start another attempt while a request is unresolved", async () => {
|
||||
const clock = new ManualClock();
|
||||
const environment = new MutableEnvironment();
|
||||
let resolveFirst:
|
||||
| ((result: BoundedPollAttemptResult<string>) => void)
|
||||
| undefined;
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<BoundedPollAttemptResult<string>>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "done",
|
||||
responseBytes: 10,
|
||||
state: "COMPLETED",
|
||||
},
|
||||
});
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment,
|
||||
clock,
|
||||
random: () => 0.5,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
clock.advance(30_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFirst?.({
|
||||
ok: true,
|
||||
value: { kind: "UNCHANGED", responseBytes: 0 },
|
||||
});
|
||||
await flush();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { attempts: 2, state: "COMPLETED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("ends the lease when an in-flight executor ignores abort and exceeds max elapsed", async () => {
|
||||
const clock = new ManualClock();
|
||||
let attemptSignal: AbortSignal | undefined;
|
||||
let settleAttempt:
|
||||
| ((result: BoundedPollAttemptResult<string>) => void)
|
||||
| undefined;
|
||||
const execute = vi.fn(
|
||||
({ signal }: Readonly<{ signal: AbortSignal }>) => {
|
||||
attemptSignal = signal;
|
||||
return new Promise<BoundedPollAttemptResult<string>>(
|
||||
(resolve) => {
|
||||
settleAttempt = resolve;
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(attemptSignal?.aborted).toBe(false);
|
||||
|
||||
clock.advance(54_999);
|
||||
await flush();
|
||||
expect(coordinator.getState()).toBe("RUNNING");
|
||||
clock.advance(1);
|
||||
await flush();
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "POLL_BUDGET_EXHAUSTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(attemptSignal?.aborted).toBe(true);
|
||||
expect(coordinator.getState()).toBe("DRAINING");
|
||||
await expect(coordinator.run()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
settleAttempt?.({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "ABORTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
await flush();
|
||||
expect(coordinator.getState()).toBe("IDLE");
|
||||
});
|
||||
|
||||
it("returns promptly when a caller aborts an executor that ignores its signal", async () => {
|
||||
const clock = new ManualClock();
|
||||
const caller = new AbortController();
|
||||
let settleAttempt:
|
||||
| ((result: BoundedPollAttemptResult<string>) => void)
|
||||
| undefined;
|
||||
const execute = vi.fn(
|
||||
() =>
|
||||
new Promise<BoundedPollAttemptResult<string>>(
|
||||
(resolve) => {
|
||||
settleAttempt = resolve;
|
||||
},
|
||||
),
|
||||
);
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
});
|
||||
|
||||
const pending = coordinator.run({ signal: caller.signal });
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
caller.abort();
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "ABORTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(coordinator.getState()).toBe("DRAINING");
|
||||
settleAttempt?.({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "ABORTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
await flush();
|
||||
expect(coordinator.getState()).toBe("IDLE");
|
||||
});
|
||||
|
||||
it("fails closed and aborts the attempt when the lease deadline clock is unavailable", async () => {
|
||||
let current = 0;
|
||||
let sleeps = 0;
|
||||
const clock: ClockPort = {
|
||||
now: () => current,
|
||||
sleep: async (milliseconds) => {
|
||||
sleeps += 1;
|
||||
if (sleeps > 1) throw new Error("deadline unavailable");
|
||||
current += milliseconds;
|
||||
},
|
||||
};
|
||||
let attemptSignal: AbortSignal | undefined;
|
||||
const execute = vi.fn(
|
||||
async ({ signal }: Readonly<{ signal: AbortSignal }>) => {
|
||||
attemptSignal = signal;
|
||||
return {
|
||||
ok: true as const,
|
||||
value: {
|
||||
kind: "UNCHANGED" as const,
|
||||
responseBytes: 0 as const,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
});
|
||||
|
||||
await expect(coordinator.run()).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROVIDER_UNAVAILABLE",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(attemptSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("fences a non-cooperative apply callback at the lease deadline", async () => {
|
||||
const clock = new ManualClock();
|
||||
let applyContext:
|
||||
| Readonly<{
|
||||
signal: AbortSignal;
|
||||
isCurrent(): boolean;
|
||||
}>
|
||||
| undefined;
|
||||
let releaseApply: (() => void) | undefined;
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute: async () => ({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "working",
|
||||
responseBytes: 10,
|
||||
state: "RUNNING",
|
||||
},
|
||||
}),
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
});
|
||||
|
||||
const pending = coordinator.run({
|
||||
onValue: (_value, context) => {
|
||||
applyContext = context;
|
||||
return new Promise<void>((resolve) => {
|
||||
releaseApply = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(applyContext?.isCurrent()).toBe(true);
|
||||
|
||||
clock.advance(55_000);
|
||||
await flush();
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "POLL_BUDGET_EXHAUSTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(applyContext?.signal.aborted).toBe(true);
|
||||
expect(applyContext?.isCurrent()).toBe(false);
|
||||
expect(coordinator.getState()).toBe("DRAINING");
|
||||
releaseApply?.();
|
||||
await flush();
|
||||
expect(coordinator.getState()).toBe("IDLE");
|
||||
});
|
||||
|
||||
it("honors Retry-After as a floor and exhausts finite attempts", async () => {
|
||||
const clock = new ManualClock();
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "RATE_LIMITED",
|
||||
operation: "POLL",
|
||||
retryable: true,
|
||||
retryAfterMs: 10_000,
|
||||
},
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { kind: "UNCHANGED", responseBytes: 0 },
|
||||
});
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy: definePollLeasePolicy({
|
||||
...policy,
|
||||
maxAttempts: 2,
|
||||
}),
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
random: () => 0,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
clock.advance(9_999);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
clock.advance(1);
|
||||
await flush();
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "POLL_BUDGET_EXHAUSTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["RATE_LIMITED", "PROVIDER_UNAVAILABLE"] as const)(
|
||||
"does not retry %s without a bounded server hint",
|
||||
async (kind) => {
|
||||
const clock = new ManualClock();
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "POLL",
|
||||
retryable: true,
|
||||
},
|
||||
});
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
random: () => 0,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores Retry-After for retryable failure kinds that cannot carry the hint", async () => {
|
||||
const clock = new ManualClock();
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "CONNECT_TIMEOUT",
|
||||
operation: "POLL",
|
||||
retryable: true,
|
||||
retryAfterMs: 10_000,
|
||||
},
|
||||
})
|
||||
.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "done",
|
||||
responseBytes: 10,
|
||||
state: "COMPLETED",
|
||||
},
|
||||
});
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
random: () => 0,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
clock.advance(4_999);
|
||||
await flush();
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
clock.advance(1);
|
||||
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { attempts: 2, state: "COMPLETED" },
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it.each(["AUTH_REQUIRED", "FORBIDDEN"] as const)(
|
||||
"never retries terminal %s failures even when the executor marks them retryable",
|
||||
async (kind) => {
|
||||
const clock = new ManualClock();
|
||||
const execute = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "POLL",
|
||||
retryable: true,
|
||||
retryAfterMs: 10_000,
|
||||
},
|
||||
});
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
execute,
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
clock.advance(5_000);
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
it("aborts in-flight work on hidden lifecycle and rejects late scope results", async () => {
|
||||
const hiddenClock = new ManualClock();
|
||||
const hiddenEnvironment = new MutableEnvironment();
|
||||
const hiddenCoordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
environment: hiddenEnvironment,
|
||||
clock: hiddenClock,
|
||||
execute: ({ signal }) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() =>
|
||||
resolve({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "ABORTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
}),
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
});
|
||||
const hidden = hiddenCoordinator.run();
|
||||
hiddenClock.advance(5_000);
|
||||
await flush();
|
||||
hiddenEnvironment.hide();
|
||||
await expect(hidden).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "ABORTED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const scopeClock = new ManualClock();
|
||||
let current = true;
|
||||
let resolveAttempt:
|
||||
| ((result: BoundedPollAttemptResult<string>) => void)
|
||||
| undefined;
|
||||
const onValue = vi.fn();
|
||||
const scopeCoordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
environment: new MutableEnvironment(),
|
||||
clock: scopeClock,
|
||||
isCurrent: () => current,
|
||||
execute: () =>
|
||||
new Promise((resolve) => {
|
||||
resolveAttempt = resolve;
|
||||
}),
|
||||
});
|
||||
const fenced = scopeCoordinator.run({ onValue });
|
||||
scopeClock.advance(5_000);
|
||||
await flush();
|
||||
current = false;
|
||||
resolveAttempt?.({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "late",
|
||||
responseBytes: 10,
|
||||
state: "COMPLETED",
|
||||
},
|
||||
});
|
||||
await expect(fenced).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "SCOPE_FENCED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(onValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects accessor and extra-key attempt results without re-reading them", async () => {
|
||||
const accessorClock = new ManualClock();
|
||||
const readOk = vi.fn(() => true);
|
||||
const accessorResult = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
ok: {
|
||||
enumerable: true,
|
||||
get: readOk,
|
||||
},
|
||||
value: {
|
||||
enumerable: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "unsafe",
|
||||
responseBytes: 1,
|
||||
state: "COMPLETED",
|
||||
},
|
||||
},
|
||||
},
|
||||
) as BoundedPollAttemptResult<string>;
|
||||
const accessorCoordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
environment: new MutableEnvironment(),
|
||||
clock: accessorClock,
|
||||
execute: async () => accessorResult,
|
||||
});
|
||||
const accessorRun = accessorCoordinator.run();
|
||||
accessorClock.advance(5_000);
|
||||
await expect(accessorRun).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
expect(readOk).not.toHaveBeenCalled();
|
||||
|
||||
const extraClock = new ManualClock();
|
||||
const extraCoordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation,
|
||||
environment: new MutableEnvironment(),
|
||||
clock: extraClock,
|
||||
execute: async () =>
|
||||
({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "unsafe",
|
||||
responseBytes: 1,
|
||||
state: "COMPLETED",
|
||||
},
|
||||
extra: true,
|
||||
}) as BoundedPollAttemptResult<string>,
|
||||
});
|
||||
const extraRun = extraCoordinator.run();
|
||||
extraClock.advance(5_000);
|
||||
await expect(extraRun).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on response ceilings, concurrent runs and close", async () => {
|
||||
const clock = new ManualClock();
|
||||
const coordinator = createBoundedPollCoordinator<string>({
|
||||
policy,
|
||||
operation: {
|
||||
...operation,
|
||||
maxResponseBytes: 512,
|
||||
},
|
||||
environment: new MutableEnvironment(),
|
||||
clock,
|
||||
execute: async () => ({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "VALUE",
|
||||
value: "oversized",
|
||||
responseBytes: 513,
|
||||
state: "RUNNING",
|
||||
},
|
||||
}),
|
||||
});
|
||||
const first = coordinator.run();
|
||||
await expect(coordinator.run()).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
clock.advance(5_000);
|
||||
await expect(first).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const pending = coordinator.run();
|
||||
coordinator.close();
|
||||
coordinator.close();
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "CLOSED",
|
||||
operation: "POLL",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(coordinator.getState()).toBe("CLOSED");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
BOUNDED_POLLING_CEILINGS,
|
||||
assertBoundedPollOperation,
|
||||
definePollLeasePolicy,
|
||||
type BoundedPollOperationContract,
|
||||
} from "../../../src/application/policies/bounded-polling.ts";
|
||||
|
||||
const input = {
|
||||
operationId: "GET_JOB_STATUS",
|
||||
owner: "reference-job",
|
||||
minimumIntervalMs: 5_000,
|
||||
successIntervalMs: 10_000,
|
||||
maxIntervalMs: 60_000,
|
||||
maxAttempts: 5,
|
||||
maxElapsedMs: 120_000,
|
||||
maxResponseBytes: 16_384,
|
||||
visibility: "VISIBLE_ONLY",
|
||||
fallbackReason: "CONVERGENCE",
|
||||
terminalStates: ["COMPLETED", "FAILED"],
|
||||
} as const;
|
||||
|
||||
describe("bounded polling policy", () => {
|
||||
it("copies and freezes a finite immutable lease", () => {
|
||||
const terminalStates = ["COMPLETED", "FAILED"];
|
||||
const policy = definePollLeasePolicy({
|
||||
...input,
|
||||
terminalStates,
|
||||
});
|
||||
terminalStates.push("CANCELLED");
|
||||
|
||||
expect(policy.terminalStates).toEqual(["COMPLETED", "FAILED"]);
|
||||
expect(Object.isFrozen(policy)).toBe(true);
|
||||
expect(Object.isFrozen(policy.terminalStates)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects push-like cadence and unbounded convergence", () => {
|
||||
expect(() =>
|
||||
definePollLeasePolicy({
|
||||
...input,
|
||||
minimumIntervalMs: 4_999,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
definePollLeasePolicy({
|
||||
...input,
|
||||
terminalStates: [],
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
definePollLeasePolicy({
|
||||
...input,
|
||||
maxAttempts: 121,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("admits only a one-request terminal replay-safe REST query", () => {
|
||||
const policy = definePollLeasePolicy(input);
|
||||
const operation: BoundedPollOperationContract = {
|
||||
operationId: "GET_JOB_STATUS",
|
||||
contractVersion: 2,
|
||||
protocol: "REST",
|
||||
semantics: "QUERY",
|
||||
method: "GET",
|
||||
replayPolicy: "SAFE",
|
||||
retry: "never",
|
||||
maxResponseBytes: 16_384,
|
||||
transportMaxAttempts: 1,
|
||||
authRecoveryCount: 0,
|
||||
maxCumulativeSleepMs: 0,
|
||||
serverStream: false,
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
assertBoundedPollOperation(policy, operation),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertBoundedPollOperation(policy, {
|
||||
...operation,
|
||||
transportMaxAttempts: 2 as 1,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
assertBoundedPollOperation(policy, {
|
||||
...operation,
|
||||
maxResponseBytes: 8_192,
|
||||
}),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertBoundedPollOperation(policy, {
|
||||
...operation,
|
||||
maxResponseBytes: 0,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
assertBoundedPollOperation(policy, {
|
||||
...operation,
|
||||
maxResponseBytes:
|
||||
BOUNDED_POLLING_CEILINGS.maxResponseBytes + 1,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
compareRealtimeSequences,
|
||||
isCanonicalRealtimeSequence,
|
||||
isRealtimeResumeCursor,
|
||||
isStrictRealtimeTimestamp,
|
||||
nextRealtimeSequence,
|
||||
REALTIME_MAX_SEQUENCE,
|
||||
} from "../../../src/contracts/realtime-events.ts";
|
||||
import {
|
||||
isValidatedRealtimeEventDto,
|
||||
} from "../../../src/adapters/realtime/event-codec.ts";
|
||||
import {
|
||||
TEST_LIMITS,
|
||||
createTestRealtimeCodec,
|
||||
createTestRealtimeRegistry,
|
||||
realtimeEventJson,
|
||||
realtimeEventValue,
|
||||
} from "./fixture.ts";
|
||||
|
||||
describe("REALTIME_EVENT_V1 codec", () => {
|
||||
it("accepts an exact registered envelope and snapshots schema output", () => {
|
||||
const codec = createTestRealtimeCodec();
|
||||
const result = codec.decode(realtimeEventJson());
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
envelope: {
|
||||
protocol: "REALTIME_EVENT_V1",
|
||||
sequence: "1",
|
||||
recoveryMode: "CURSOR",
|
||||
resumeCursor: "cursor-00000001",
|
||||
payload: { value: "changed" },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!result.ok) return;
|
||||
expect(isValidatedRealtimeEventDto(result.value)).toBe(true);
|
||||
expect(Object.isFrozen(result.value)).toBe(true);
|
||||
expect(Object.isFrozen(result.value.envelope)).toBe(true);
|
||||
expect(Object.isFrozen(result.value.envelope.payload)).toBe(true);
|
||||
expect(result.value.wireBytes).toBeGreaterThan(0);
|
||||
expect(result.value.fingerprintBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects extra keys, unknown registrations and schema failures", () => {
|
||||
const codec = createTestRealtimeCodec();
|
||||
|
||||
expect(
|
||||
codec.decode(
|
||||
JSON.stringify({
|
||||
...realtimeEventValue(),
|
||||
arbitrary: "override",
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT", operation: "DECODE" },
|
||||
});
|
||||
expect(
|
||||
codec.decode(
|
||||
realtimeEventJson({ streamId: "UNKNOWN_STREAM" }),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
expect(
|
||||
codec.decode(
|
||||
realtimeEventJson({ eventType: "UNKNOWN_EVENT" }),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
expect(
|
||||
codec.decode(realtimeEventJson({ payload: { value: 42 } })),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
|
||||
const duplicateEnvelopeMember =
|
||||
`{"eventId":"shadowed",${JSON.stringify(
|
||||
realtimeEventValue(),
|
||||
).slice(1)}`;
|
||||
expect(codec.decode(duplicateEnvelopeMember)).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT", operation: "DECODE" },
|
||||
});
|
||||
const duplicatePayloadMember = realtimeEventJson().replace(
|
||||
'"payload":{"value":"changed"}',
|
||||
'"payload":{"value":"first","\\u0076alue":"changed"}',
|
||||
);
|
||||
expect(codec.decode(duplicatePayloadMember)).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT", operation: "DECODE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on version, sequence, timestamp, scope and cursor syntax", () => {
|
||||
const codec = createTestRealtimeCodec();
|
||||
|
||||
expect(
|
||||
codec.decode(realtimeEventJson({ protocol: "REALTIME_EVENT_V2" })),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
for (const overrides of [
|
||||
{ sequence: "01" },
|
||||
{ sequence: "18446744073709551616" },
|
||||
{ occurredAt: "2026-02-30T01:02:03Z" },
|
||||
{ occurredAt: "2026-07-28 01:02:03Z" },
|
||||
{ scopeBinding: "scope\r\ninjected" },
|
||||
{ resumeCursor: "" },
|
||||
{ resumeCursor: "cursor\ninjected" },
|
||||
{ recoveryMode: "SNAPSHOT_ONLY", resumeCursor: null },
|
||||
]) {
|
||||
expect(codec.decode(realtimeEventJson(overrides))).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("enforces global, per-stream and payload structure ceilings", () => {
|
||||
const strictCodec = createTestRealtimeCodec(
|
||||
createTestRealtimeRegistry({
|
||||
limits: {
|
||||
...TEST_LIMITS,
|
||||
maxEventBytes: 512,
|
||||
maxPayloadNodes: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
strictCodec.decode(realtimeEventJson()),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
|
||||
const byteBoundCodec = createTestRealtimeCodec(
|
||||
createTestRealtimeRegistry({
|
||||
limits: {
|
||||
...TEST_LIMITS,
|
||||
maxEventBytes: 512,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
byteBoundCodec.decode(
|
||||
realtimeEventJson({ payload: { value: "x".repeat(600) } }),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "EVENT_TOO_LARGE" },
|
||||
});
|
||||
|
||||
expect(
|
||||
createTestRealtimeCodec().decode(
|
||||
"x".repeat(64 * 1024 + 1),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "EVENT_TOO_LARGE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("supports the exact null-cursor SESSION_REBUILD discriminant", () => {
|
||||
const registry = createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SESSION_REBUILD",
|
||||
rebuildInputId: "referenceRealtimeRebuild",
|
||||
},
|
||||
delivery: "EPHEMERAL",
|
||||
stateBearing: false,
|
||||
});
|
||||
const codec = createTestRealtimeCodec(registry);
|
||||
|
||||
expect(
|
||||
codec.decode(
|
||||
realtimeEventJson({
|
||||
recoveryMode: "SESSION_REBUILD",
|
||||
resumeCursor: null,
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
envelope: {
|
||||
recoveryMode: "SESSION_REBUILD",
|
||||
resumeCursor: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
codec.decode(
|
||||
realtimeEventJson({
|
||||
recoveryMode: "SESSION_REBUILD",
|
||||
resumeCursor: "synthetic",
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes semantic identity independently of JSON key order", () => {
|
||||
const codec = createTestRealtimeCodec();
|
||||
const value = realtimeEventValue();
|
||||
const reversed = Object.fromEntries(
|
||||
Object.entries(value).reverse(),
|
||||
);
|
||||
const first = codec.decode(JSON.stringify(value));
|
||||
const second = codec.decode(JSON.stringify(reversed));
|
||||
|
||||
expect(first.ok).toBe(true);
|
||||
expect(second.ok).toBe(true);
|
||||
if (!first.ok || !second.ok) return;
|
||||
expect(first.value.semanticFingerprint).toBe(
|
||||
second.value.semanticFingerprint,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses bounded uint64 sequence and header-safe cursor helpers", () => {
|
||||
expect(isCanonicalRealtimeSequence("0")).toBe(true);
|
||||
expect(isCanonicalRealtimeSequence(REALTIME_MAX_SEQUENCE)).toBe(true);
|
||||
expect(isCanonicalRealtimeSequence("00")).toBe(false);
|
||||
expect(isCanonicalRealtimeSequence("18446744073709551616")).toBe(false);
|
||||
expect(compareRealtimeSequences("9", "10")).toBe(-1);
|
||||
expect(nextRealtimeSequence("9")).toBe("10");
|
||||
expect(nextRealtimeSequence(REALTIME_MAX_SEQUENCE)).toBeNull();
|
||||
expect(isRealtimeResumeCursor("cursor:/+=._~-")).toBe(true);
|
||||
expect(isRealtimeResumeCursor("cursor\nunsafe")).toBe(false);
|
||||
expect(isStrictRealtimeTimestamp("2024-02-29T23:59:59Z")).toBe(true);
|
||||
expect(isStrictRealtimeTimestamp("2023-02-29T23:59:59Z")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
RealtimeAcceptDisposition,
|
||||
RealtimeRecoveryCheckpoint,
|
||||
} from "../../../src/application/ports/realtime/event-authority.ts";
|
||||
import type {
|
||||
RealtimeResult,
|
||||
} from "../../../src/application/ports/realtime/shared.ts";
|
||||
import {
|
||||
createRealtimeEventConsumer,
|
||||
} from "../../../src/adapters/realtime/event-consumer.ts";
|
||||
import type {
|
||||
RealtimeEventCodec,
|
||||
ValidatedRealtimeEventDto,
|
||||
} from "../../../src/adapters/realtime/event-codec.ts";
|
||||
import {
|
||||
realtimeFailure,
|
||||
realtimeSuccess,
|
||||
} from "../../../src/adapters/realtime/result.ts";
|
||||
import type {
|
||||
RealtimeStreamCoordinator,
|
||||
} from "../../../src/adapters/realtime/stream-coordinator.ts";
|
||||
|
||||
function setup(
|
||||
recoveryMode:
|
||||
| "CURSOR"
|
||||
| "SNAPSHOT_ONLY"
|
||||
| "SESSION_REBUILD",
|
||||
resumeCursor: string | null,
|
||||
) {
|
||||
const dto = {
|
||||
envelope: {
|
||||
recoveryMode,
|
||||
resumeCursor,
|
||||
streamId: "REFERENCE_STREAM",
|
||||
},
|
||||
} as ValidatedRealtimeEventDto;
|
||||
const codec: RealtimeEventCodec = {
|
||||
decode: vi.fn(() => realtimeSuccess(dto)),
|
||||
};
|
||||
const accept = vi.fn<
|
||||
(
|
||||
event: ValidatedRealtimeEventDto,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<RealtimeResult<RealtimeAcceptDisposition>>
|
||||
>(() =>
|
||||
Promise.resolve(
|
||||
realtimeSuccess({
|
||||
outcome: "DROPPED" as const,
|
||||
reason: "DUPLICATE_EVENT" as const,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const consumer = createRealtimeEventConsumer({
|
||||
codec,
|
||||
coordinator: {
|
||||
accept,
|
||||
} as Pick<RealtimeStreamCoordinator, "accept">,
|
||||
});
|
||||
return { consumer, accept, codec };
|
||||
}
|
||||
|
||||
describe("realtime transport event consumer", () => {
|
||||
it("requires exact equality between SSE id and CURSOR envelope", async () => {
|
||||
const matching = setup("CURSOR", "cursor.0001");
|
||||
await expect(
|
||||
matching.consumer.consume("{}", {
|
||||
kind: "SSE_DIRECT_CURSOR",
|
||||
eventId: "cursor.0001",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(matching.accept).toHaveBeenCalledTimes(1);
|
||||
|
||||
const advanced = setup("CURSOR", "cursor.0002");
|
||||
await expect(
|
||||
advanced.consumer.consume("{}", {
|
||||
kind: "SSE_DIRECT_CURSOR",
|
||||
eventId: "cursor.0001",
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("PROTOCOL_MISMATCH", "RECEIVE"),
|
||||
);
|
||||
expect(advanced.accept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids SSE id semantics for non-CURSOR recovery", async () => {
|
||||
const runtime = setup("SNAPSHOT_ONLY", null);
|
||||
await expect(
|
||||
runtime.consumer.consume("{}", {
|
||||
kind: "SSE_NO_CURSOR",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
await expect(
|
||||
runtime.consumer.consume("{}", {
|
||||
kind: "SSE_DIRECT_CURSOR",
|
||||
eventId: "cursor.0001",
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("PROTOCOL_MISMATCH", "RECEIVE"),
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes an encapsulated WebSocket envelope through the codec", async () => {
|
||||
const runtime = setup("SESSION_REBUILD", null);
|
||||
await expect(
|
||||
runtime.consumer.consumeEncapsulated(
|
||||
Object.freeze({ protocol: "REALTIME_EVENT_V1" }),
|
||||
),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
expect(runtime.codec.decode).toHaveBeenCalledWith(
|
||||
'{"protocol":"REALTIME_EVENT_V1"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("projects common dispositions into the canonical transport outcome", async () => {
|
||||
const runtime = setup("SNAPSHOT_ONLY", null);
|
||||
|
||||
await expect(
|
||||
runtime.consumer.consumeForTransport("{}", {
|
||||
kind: "SSE_NO_CURSOR",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { kind: "CONTINUE" },
|
||||
});
|
||||
|
||||
const checkpoint = Object.freeze({
|
||||
recoveryMode: "SNAPSHOT_ONLY" as const,
|
||||
streamEpoch: "stream-epoch.0001",
|
||||
lastAppliedSequence: "0",
|
||||
resumeCursor: null,
|
||||
}) as RealtimeRecoveryCheckpoint;
|
||||
runtime.accept.mockResolvedValueOnce(
|
||||
realtimeSuccess({
|
||||
outcome: "RECOVERED",
|
||||
reason: "INITIALIZE",
|
||||
resumeState: checkpoint,
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
runtime.consumer.consumeForTransport("{}", {
|
||||
kind: "SSE_NO_CURSOR",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "RECOVERY_COMMITTED",
|
||||
streamId: "REFERENCE_STREAM",
|
||||
checkpoint,
|
||||
},
|
||||
});
|
||||
|
||||
runtime.accept.mockResolvedValueOnce(
|
||||
realtimeSuccess({
|
||||
outcome: "DROPPED",
|
||||
reason: "RECOVERY_IN_PROGRESS",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
runtime.consumer.consumeForTransport("{}", {
|
||||
kind: "SSE_NO_CURSOR",
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("PROTOCOL_MISMATCH", "RECEIVE"),
|
||||
);
|
||||
|
||||
runtime.accept.mockResolvedValueOnce(
|
||||
realtimeSuccess({
|
||||
outcome: "DROPPED",
|
||||
reason: "SCOPE_FENCED",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
runtime.consumer.consumeForTransport("{}", {
|
||||
kind: "SSE_NO_CURSOR",
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("SCOPE_FENCED", "RECEIVE"),
|
||||
);
|
||||
});
|
||||
|
||||
it("threads transport cancellation into the common authority", async () => {
|
||||
const runtime = setup("SNAPSHOT_ONLY", null);
|
||||
const active = new AbortController();
|
||||
|
||||
await runtime.consumer.consume(
|
||||
"{}",
|
||||
{ kind: "SSE_NO_CURSOR" },
|
||||
active.signal,
|
||||
);
|
||||
expect(runtime.accept).toHaveBeenLastCalledWith(
|
||||
expect.anything(),
|
||||
active.signal,
|
||||
);
|
||||
|
||||
const aborted = new AbortController();
|
||||
aborted.abort();
|
||||
await expect(
|
||||
runtime.consumer.consume(
|
||||
"{}",
|
||||
{ kind: "SSE_NO_CURSOR" },
|
||||
aborted.signal,
|
||||
),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("ABORTED", "RECEIVE"),
|
||||
);
|
||||
expect(runtime.accept).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,568 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
SSE_CONTINUE,
|
||||
createFetchSseConnection,
|
||||
} from "../../../src/adapters/realtime/sse/fetch-sse-connection.ts";
|
||||
import {
|
||||
realtimeTransportRecoveryCommitted,
|
||||
type RealtimeRecoveryCheckpoint,
|
||||
} from "../../../src/application/ports/realtime/event-authority.ts";
|
||||
import type { RealtimeResult } from "../../../src/application/ports/realtime/shared.ts";
|
||||
import type {
|
||||
StreamRegistrationId,
|
||||
} from "../../../src/contracts/realtime-streams.ts";
|
||||
import {
|
||||
realtimeFailure,
|
||||
realtimeSuccess,
|
||||
} from "../../../src/adapters/realtime/result.ts";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const continueEvent = () => realtimeSuccess(SSE_CONTINUE);
|
||||
const RECOVERY_CHECKPOINT = Object.freeze({
|
||||
recoveryMode: "CURSOR" as const,
|
||||
streamEpoch: "stream-epoch.0001",
|
||||
lastAppliedSequence: "1",
|
||||
resumeCursor: "cursor-1",
|
||||
}) as RealtimeRecoveryCheckpoint;
|
||||
const RECOVERY_OUTCOME = realtimeTransportRecoveryCommitted(
|
||||
"REFERENCE_STREAM" as StreamRegistrationId,
|
||||
RECOVERY_CHECKPOINT,
|
||||
);
|
||||
|
||||
function eventStream(
|
||||
chunks: readonly string[],
|
||||
options: Readonly<{
|
||||
status?: number;
|
||||
contentType?: string;
|
||||
}> = {},
|
||||
): Response {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: options.status ?? 200,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
options.contentType ?? "text/event-stream; charset=utf-8",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function connection(
|
||||
fetcher: typeof fetch,
|
||||
recoveryMode: "CURSOR" | "SESSION_REBUILD" | "SNAPSHOT_ONLY" =
|
||||
"CURSOR",
|
||||
) {
|
||||
return createFetchSseConnection({
|
||||
endpoint: "https://app.example.test/events",
|
||||
applicationOrigin: "https://app.example.test",
|
||||
recoveryMode,
|
||||
fetcher,
|
||||
});
|
||||
}
|
||||
|
||||
describe("fetch SSE connection", () => {
|
||||
it("uses the fixed request profile and streams events sequentially", async () => {
|
||||
const response = eventStream([
|
||||
": heartbeat\r\n",
|
||||
"retry: 2500\n",
|
||||
"id: cursor-1\ndata: first\n",
|
||||
"data: second\n\n",
|
||||
]);
|
||||
const fetcher = vi.fn(
|
||||
async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
response,
|
||||
);
|
||||
const received: string[] = [];
|
||||
const comments = vi.fn();
|
||||
const hints = vi.fn();
|
||||
const runtime = connection(fetcher as typeof fetch);
|
||||
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: "cursor-0",
|
||||
onEvent: async (event) => {
|
||||
received.push(event.data);
|
||||
return realtimeSuccess(SSE_CONTINUE);
|
||||
},
|
||||
onComment: comments,
|
||||
onRetryHint: hints,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "EOF",
|
||||
incompleteEventDiscarded: false,
|
||||
retryHintMs: 2_500,
|
||||
},
|
||||
});
|
||||
|
||||
expect(received).toEqual(["first\nsecond"]);
|
||||
expect(comments).toHaveBeenCalledOnce();
|
||||
expect(hints).toHaveBeenCalledWith(2_500);
|
||||
const [target, init] = fetcher.mock.calls[0] ?? [];
|
||||
expect(target).toBe("https://app.example.test/events");
|
||||
expect(init).toMatchObject({
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
referrerPolicy: "no-referrer",
|
||||
});
|
||||
expect(new Headers(init?.headers)).toEqual(
|
||||
new Headers({
|
||||
Accept: "text/event-stream",
|
||||
"Last-Event-ID": "cursor-0",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("holds event consumption behind the validated open barrier gate", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: after-barrier\n\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
let release:
|
||||
| ((result: RealtimeResult<void>) => void)
|
||||
| undefined;
|
||||
const gate = new Promise<RealtimeResult<void>>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const onOpen = vi.fn(() => gate);
|
||||
const onEvent = vi.fn(continueEvent);
|
||||
|
||||
const reading = runtime.read({
|
||||
resumeCursor: null,
|
||||
onOpen,
|
||||
onEvent,
|
||||
});
|
||||
await vi.waitFor(() => expect(onOpen).toHaveBeenCalledOnce());
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
|
||||
release?.(realtimeSuccess(undefined));
|
||||
await expect(reading).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "EOF" },
|
||||
});
|
||||
expect(onEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("propagates an exact open-barrier failure before reading events", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: unreachable\n\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
const barrierFailure = realtimeFailure(
|
||||
"SCOPE_FENCED",
|
||||
"RECOVER",
|
||||
false,
|
||||
);
|
||||
const onEvent = vi.fn(continueEvent);
|
||||
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onOpen: () => barrierFailure,
|
||||
onEvent,
|
||||
}),
|
||||
).resolves.toBe(barrierFailure);
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats 204 as terminal and rejects incorrect media types", async () => {
|
||||
const terminal = connection(
|
||||
(async () => new Response(null, { status: 204 })) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
terminal.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { kind: "NO_RECONNECT" },
|
||||
});
|
||||
|
||||
const wrongType = connection(
|
||||
(async () =>
|
||||
eventStream(["data: value\n\n"], {
|
||||
contentType: "application/json",
|
||||
})) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
wrongType.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[401, "AUTH_REQUIRED", false],
|
||||
[403, "FORBIDDEN", false],
|
||||
[409, "CURSOR_EXPIRED", false],
|
||||
[410, "CURSOR_EXPIRED", false],
|
||||
[429, "RATE_LIMITED", true],
|
||||
[502, "PROVIDER_UNAVAILABLE", true],
|
||||
[503, "PROVIDER_UNAVAILABLE", true],
|
||||
[504, "PROVIDER_UNAVAILABLE", true],
|
||||
[500, "PROTOCOL_MISMATCH", false],
|
||||
] as const)(
|
||||
"maps HTTP %i to %s without exposing a response body",
|
||||
async (status, kind, retryable) => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
new Response("private backend text", {
|
||||
status,
|
||||
headers:
|
||||
status === 429 || status === 503
|
||||
? { "Retry-After": "10" }
|
||||
: undefined,
|
||||
})) as typeof fetch,
|
||||
);
|
||||
const result = await runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "CONNECT",
|
||||
retryable,
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("private backend");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[429, "RATE_LIMITED"],
|
||||
[503, "PROVIDER_UNAVAILABLE"],
|
||||
] as const)(
|
||||
"does not retry HTTP %i without a bounded server hint",
|
||||
async (status, kind) => {
|
||||
const runtime = connection(
|
||||
(async () => new Response(null, { status })) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind,
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("does not clamp an excessive Retry-After into an early retry", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
new Response(null, {
|
||||
status: 429,
|
||||
headers: { "Retry-After": "120" },
|
||||
})) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "RATE_LIMITED",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces direct event IDs only for CURSOR recovery", async () => {
|
||||
const missingCursorId = connection(
|
||||
(async () =>
|
||||
eventStream(["data: value\n\n"])) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
missingCursorId.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const unexpectedCursorId = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: value\n\n",
|
||||
])) as typeof fetch,
|
||||
"SNAPSHOT_ONLY",
|
||||
);
|
||||
await expect(
|
||||
unexpectedCursorId.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("stops the old stream after authority recovery commits", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: first\n\n",
|
||||
"id: cursor-2\ndata: stale-generation\n\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
const onEvent = vi.fn(() =>
|
||||
realtimeSuccess(RECOVERY_OUTCOME),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: RECOVERY_OUTCOME,
|
||||
});
|
||||
expect(onEvent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("propagates a canonical consumer failure and aborts its event generation", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: forbidden\n\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
let handlerSignal: AbortSignal | undefined;
|
||||
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: (_event, signal) => {
|
||||
handlerSignal = signal;
|
||||
return realtimeFailure("FORBIDDEN", "RECEIVE");
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("FORBIDDEN", "RECEIVE"),
|
||||
);
|
||||
expect(handlerSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("fences recovery immediately and waits for bounded reader cancellation", async () => {
|
||||
let releaseCancellation: (() => void) | undefined;
|
||||
let cancellationStarted = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(
|
||||
encoder.encode("id: cursor-1\ndata: first\n\n"),
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
cancellationStarted = true;
|
||||
return new Promise<void>((resolve) => {
|
||||
releaseCancellation = resolve;
|
||||
});
|
||||
},
|
||||
});
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
new Response(stream, {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})) as typeof fetch,
|
||||
);
|
||||
const pending = runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: () =>
|
||||
realtimeSuccess(RECOVERY_OUTCOME),
|
||||
});
|
||||
let settled = false;
|
||||
void pending.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
for (let turn = 0; turn < 20; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
expect(cancellationStarted).toBe(true);
|
||||
expect(settled).toBe(false);
|
||||
releaseCancellation?.();
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: true,
|
||||
value: RECOVERY_OUTCOME,
|
||||
});
|
||||
});
|
||||
|
||||
it("discards incomplete EOF and closes an active reader idempotently", async () => {
|
||||
const incomplete = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\ndata: incomplete\n",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
await expect(
|
||||
incomplete.read({
|
||||
resumeCursor: null,
|
||||
onEvent() {
|
||||
throw new Error("must not run");
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "EOF",
|
||||
incompleteEventDiscarded: true,
|
||||
retryHintMs: null,
|
||||
},
|
||||
});
|
||||
|
||||
const pendingStream = new ReadableStream<Uint8Array>({
|
||||
pull: () => new Promise<void>(() => {}),
|
||||
});
|
||||
const active = connection(
|
||||
(async () =>
|
||||
new Response(pendingStream, {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})) as typeof fetch,
|
||||
);
|
||||
const pending = active.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
active.close();
|
||||
active.close();
|
||||
await expect(pending).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "ABORTED",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
active.read({
|
||||
resumeCursor: null,
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "CLOSED",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches a CR-terminated blank block completed at EOF", async () => {
|
||||
const runtime = connection(
|
||||
(async () =>
|
||||
eventStream([
|
||||
"id: cursor-1\rdata: final\r\r",
|
||||
])) as typeof fetch,
|
||||
);
|
||||
const received = vi.fn(() =>
|
||||
realtimeSuccess(SSE_CONTINUE),
|
||||
);
|
||||
await expect(
|
||||
runtime.read({
|
||||
resumeCursor: null,
|
||||
onEvent: received,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
kind: "EOF",
|
||||
incompleteEventDiscarded: false,
|
||||
},
|
||||
});
|
||||
expect(received).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: "final" }),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects arbitrary endpoints and mode-incoherent cursors before fetch", async () => {
|
||||
expect(() =>
|
||||
createFetchSseConnection({
|
||||
endpoint: "https://other.example.test/events",
|
||||
applicationOrigin: "https://app.example.test",
|
||||
recoveryMode: "CURSOR",
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
expect(() =>
|
||||
createFetchSseConnection({
|
||||
endpoint:
|
||||
"https://app.example.test/events?token=not-allowed",
|
||||
applicationOrigin: "https://app.example.test",
|
||||
recoveryMode: "CURSOR",
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
|
||||
const fetcher = vi.fn(
|
||||
async () => eventStream(["data: unreachable\n\n"]),
|
||||
);
|
||||
const snapshotOnly = connection(
|
||||
fetcher as unknown as typeof fetch,
|
||||
"SNAPSHOT_ONLY",
|
||||
);
|
||||
await expect(
|
||||
snapshotOnly.read({
|
||||
resumeCursor: "cursor-not-allowed",
|
||||
onEvent: continueEvent,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROTOCOL_MISMATCH",
|
||||
operation: "CONNECT",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,252 @@
|
||||
import {
|
||||
mappingSuccess,
|
||||
type InstalledBoundaryMapper,
|
||||
} from "../../../src/contracts/boundary-mapper.ts";
|
||||
import type { ApiOperation } from "../../../src/contracts/api-operations.ts";
|
||||
import {
|
||||
createRealtimePolicyRegistry,
|
||||
defineEventTypeId,
|
||||
defineExternalEventEffectProfileId,
|
||||
defineRealtimeEndpointId,
|
||||
defineRealtimeKillSwitchId,
|
||||
defineStreamRegistrationId,
|
||||
type RealtimeEventTypeRegistration,
|
||||
type RealtimeLimits,
|
||||
type RealtimePolicyRegistry,
|
||||
type RealtimeRecoveryProfile,
|
||||
type RealtimeStreamRegistration,
|
||||
} from "../../../src/contracts/realtime-streams.ts";
|
||||
import type { RuntimeSchemaCodec } from "../../../src/contracts/schema-registry.ts";
|
||||
import {
|
||||
createRealtimeEventCodec,
|
||||
type RealtimeEventCodec,
|
||||
} from "../../../src/adapters/realtime/event-codec.ts";
|
||||
|
||||
export const STREAM_ID = defineStreamRegistrationId("REFERENCE_STREAM");
|
||||
export const EVENT_TYPE = defineEventTypeId("REFERENCE_CHANGED");
|
||||
export const ENDPOINT_ID = defineRealtimeEndpointId("REFERENCE_ENDPOINT");
|
||||
export const EFFECT_PROFILE_ID =
|
||||
defineExternalEventEffectProfileId("REFERENCE_INVALIDATE");
|
||||
export const KILL_SWITCH_ID =
|
||||
defineRealtimeKillSwitchId("REFERENCE_KILL_SWITCH");
|
||||
|
||||
export const TEST_LIMITS: RealtimeLimits = Object.freeze({
|
||||
maxEventBytes: 4_096,
|
||||
maxPayloadDepth: 8,
|
||||
maxPayloadNodes: 128,
|
||||
maxQueueEvents: 8,
|
||||
maxQueueBytes: 32_768,
|
||||
maxDedupeEntries: 16,
|
||||
maxDedupeBytes: 32_768,
|
||||
dedupeTtlMs: 60_000,
|
||||
});
|
||||
|
||||
const eventPayloadCodec: RuntimeSchemaCodec = Object.freeze({
|
||||
schemaId: "ReferenceRealtimePayload",
|
||||
parse(value) {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).length !== 1 ||
|
||||
typeof (value as Readonly<Record<string, unknown>>).value !== "string"
|
||||
) {
|
||||
return {
|
||||
success: false,
|
||||
issues: [{ path: "value", code: "INVALID_TYPE" }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
value: (value as Readonly<Record<string, string>>).value,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const checkpointCodec: RuntimeSchemaCodec = Object.freeze({
|
||||
schemaId: "ReferenceRealtimeCheckpoint",
|
||||
parse: (value) => ({ success: true, data: value }),
|
||||
});
|
||||
|
||||
export const TEST_SCHEMA_CODECS = Object.freeze({
|
||||
ReferenceRealtimePayload: eventPayloadCodec,
|
||||
ReferenceRealtimeCheckpoint: checkpointCodec,
|
||||
});
|
||||
|
||||
export const TEST_MAPPER: InstalledBoundaryMapper = Object.freeze({
|
||||
mapperId: "ReferenceRealtimeMapper",
|
||||
mapperVersion: 1,
|
||||
inputSchemaId: "ReferenceRealtimePayload",
|
||||
outputContractId: "ReferenceRealtimeEvent",
|
||||
owner: "reference-feature",
|
||||
maxOutputItems: 1,
|
||||
map(input) {
|
||||
if (
|
||||
!input ||
|
||||
typeof input !== "object" ||
|
||||
typeof (input as Readonly<Record<string, unknown>>).value !== "string"
|
||||
) {
|
||||
return { ok: false, code: "MAPPING_INVARIANT_REJECTED" };
|
||||
}
|
||||
return mappingSuccess(
|
||||
Object.freeze({
|
||||
value: (input as Readonly<Record<string, string>>).value,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const TEST_MAPPERS = Object.freeze({
|
||||
ReferenceRealtimeMapper: TEST_MAPPER,
|
||||
});
|
||||
|
||||
const snapshotOperation: ApiOperation = Object.freeze({
|
||||
method: "GET",
|
||||
path: "/api/reference-snapshot",
|
||||
operationId: "GET_REFERENCE_REALTIME_SNAPSHOT",
|
||||
auth: "external-session",
|
||||
timeoutMs: 10_000,
|
||||
idempotency: "safe",
|
||||
retry: "never",
|
||||
requestSource: "none",
|
||||
requestSchema: "NoRequest",
|
||||
responseSchema: "ReferenceRealtimeCheckpoint",
|
||||
owner: "reference-feature",
|
||||
contractVersion: 2,
|
||||
protocol: "REST",
|
||||
semantics: "QUERY",
|
||||
replayPolicy: "SAFE",
|
||||
idempotencyKeyPolicy: "NONE",
|
||||
mapperId: "ReferenceRealtimeSnapshotMapper",
|
||||
successStatuses: [200],
|
||||
responseMediaTypes: ["application/json"],
|
||||
maxResponseBytes: 16_384,
|
||||
providerId: "PRIMARY_API",
|
||||
authProfileId: "EXTERNAL_SESSION",
|
||||
csrfProfileId: "NONE",
|
||||
pathSchema: "NoRequest",
|
||||
pathParameterNames: [],
|
||||
maxEncodedSearchBytes: 0,
|
||||
});
|
||||
|
||||
export const TEST_API_OPERATIONS = Object.freeze({
|
||||
GET_REFERENCE_REALTIME_SNAPSHOT: snapshotOperation,
|
||||
});
|
||||
|
||||
export type TestRegistryOptions = Readonly<{
|
||||
recovery?: RealtimeRecoveryProfile;
|
||||
delivery?: RealtimeStreamRegistration["delivery"];
|
||||
stateBearing?: boolean;
|
||||
limits?: RealtimeLimits;
|
||||
streamMutator?: (
|
||||
stream: RealtimeStreamRegistration,
|
||||
) => RealtimeStreamRegistration;
|
||||
eventTypeMutator?: (
|
||||
eventType: RealtimeEventTypeRegistration,
|
||||
) => RealtimeEventTypeRegistration;
|
||||
}>;
|
||||
|
||||
export function createTestRealtimeRegistry(
|
||||
options: TestRegistryOptions = {},
|
||||
): RealtimePolicyRegistry {
|
||||
const recovery =
|
||||
options.recovery ??
|
||||
({
|
||||
mode: "CURSOR",
|
||||
snapshotOperationId: "GET_REFERENCE_REALTIME_SNAPSHOT",
|
||||
checkpointCodecId: "ReferenceRealtimeCheckpoint",
|
||||
barrier: "REPLAY",
|
||||
} as const);
|
||||
const eventType: RealtimeEventTypeRegistration = {
|
||||
id: EVENT_TYPE,
|
||||
owner: "reference-feature",
|
||||
payloadSchemaId: "ReferenceRealtimePayload",
|
||||
mapperId: "ReferenceRealtimeMapper",
|
||||
effectProfileId: EFFECT_PROFILE_ID,
|
||||
stateBearing: options.stateBearing ?? true,
|
||||
};
|
||||
const stream: RealtimeStreamRegistration = {
|
||||
id: STREAM_ID,
|
||||
protocol: "REALTIME_EVENT_V1",
|
||||
owner: "reference-feature",
|
||||
scope: "ACCOUNT_BOUND",
|
||||
primaryTransport: "SSE",
|
||||
endpointId: ENDPOINT_ID,
|
||||
eventTypeIds: [EVENT_TYPE],
|
||||
delivery: options.delivery ?? "AUTHORITATIVE_DELTA",
|
||||
recovery,
|
||||
fallback:
|
||||
recovery.mode === "SESSION_REBUILD"
|
||||
? "EXPLICITLY_STALE"
|
||||
: "BOUNDED_POLLING",
|
||||
hiddenPolicy: "CLOSE",
|
||||
limits: options.limits ?? TEST_LIMITS,
|
||||
killSwitchId: KILL_SWITCH_ID,
|
||||
};
|
||||
return createRealtimePolicyRegistry({
|
||||
streams: [options.streamMutator?.(stream) ?? stream],
|
||||
eventTypes: [
|
||||
options.eventTypeMutator?.(eventType) ?? eventType,
|
||||
],
|
||||
bindings: {
|
||||
schemaCodecs: TEST_SCHEMA_CODECS,
|
||||
mappers: TEST_MAPPERS,
|
||||
apiOperations: TEST_API_OPERATIONS,
|
||||
endpointIds: [ENDPOINT_ID],
|
||||
effectProfileIds: [EFFECT_PROFILE_ID],
|
||||
killSwitchIds: [KILL_SWITCH_ID],
|
||||
rebuildInputIds: ["referenceRealtimeRebuild"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createTestRealtimeCodec(
|
||||
registry = createTestRealtimeRegistry(),
|
||||
): RealtimeEventCodec {
|
||||
return createRealtimeEventCodec({
|
||||
registry,
|
||||
schemaCodecs: TEST_SCHEMA_CODECS,
|
||||
});
|
||||
}
|
||||
|
||||
export type EventOverrides = Readonly<{
|
||||
protocol?: unknown;
|
||||
streamId?: unknown;
|
||||
streamEpoch?: unknown;
|
||||
eventType?: unknown;
|
||||
eventId?: unknown;
|
||||
sequence?: unknown;
|
||||
recoveryMode?: unknown;
|
||||
resumeCursor?: unknown;
|
||||
occurredAt?: unknown;
|
||||
scopeBinding?: unknown;
|
||||
payload?: unknown;
|
||||
}>;
|
||||
|
||||
export function realtimeEventValue(
|
||||
overrides: EventOverrides = {},
|
||||
): Readonly<Record<string, unknown>> {
|
||||
return {
|
||||
protocol: "REALTIME_EVENT_V1",
|
||||
streamId: STREAM_ID,
|
||||
streamEpoch: "stream-epoch-0001",
|
||||
eventType: EVENT_TYPE,
|
||||
eventId: "event-00000001",
|
||||
sequence: "1",
|
||||
recoveryMode: "CURSOR",
|
||||
resumeCursor: "cursor-00000001",
|
||||
occurredAt: "2026-07-28T01:02:03.123Z",
|
||||
scopeBinding: "scope-binding-0001",
|
||||
payload: { value: "changed" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function realtimeEventJson(
|
||||
overrides: EventOverrides = {},
|
||||
): string {
|
||||
return JSON.stringify(realtimeEventValue(overrides));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ClockPort } from "../../../src/application/ports/clock-port.ts";
|
||||
import type { RealtimeResult } from "../../../src/application/ports/realtime/shared.ts";
|
||||
import {
|
||||
createLivePollHandoffCoordinator,
|
||||
type LivePollHandoffCoordinatorDependencies,
|
||||
type LivePollHandoffLimits,
|
||||
type LiveProbeLease,
|
||||
} from "../../../src/adapters/realtime/live-poll-handoff-coordinator.ts";
|
||||
import {
|
||||
realtimeFailure,
|
||||
realtimeSuccess,
|
||||
} from "../../../src/adapters/realtime/result.ts";
|
||||
|
||||
type Sleeper = Readonly<{
|
||||
dueAt: number;
|
||||
resolve(): void;
|
||||
reject(): void;
|
||||
signal?: AbortSignal;
|
||||
onAbort(): void;
|
||||
}>;
|
||||
|
||||
class ManualClock implements ClockPort {
|
||||
current = 0;
|
||||
readonly sleepers: Sleeper[] = [];
|
||||
|
||||
now(): number {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
return;
|
||||
}
|
||||
let sleeper: Sleeper;
|
||||
const onAbort = () => {
|
||||
this.remove(sleeper);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
};
|
||||
sleeper = {
|
||||
dueAt: this.current + milliseconds,
|
||||
resolve: () => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
},
|
||||
reject: () => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
reject(new DOMException("Aborted", "AbortError"));
|
||||
},
|
||||
signal,
|
||||
onAbort,
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
this.sleepers.push(sleeper);
|
||||
});
|
||||
}
|
||||
|
||||
advance(milliseconds: number): void {
|
||||
this.current += milliseconds;
|
||||
const ready = this.sleepers.filter(
|
||||
(sleeper) => sleeper.dueAt <= this.current,
|
||||
);
|
||||
for (const sleeper of ready) {
|
||||
this.remove(sleeper);
|
||||
sleeper.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
private remove(target: Sleeper): void {
|
||||
const index = this.sleepers.indexOf(target);
|
||||
if (index >= 0) this.sleepers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const limits: LivePollHandoffLimits = Object.freeze({
|
||||
quiescenceTimeoutMs: 100,
|
||||
maxActiveQueueCount: 3,
|
||||
maxActiveQueueBytes: 30,
|
||||
maxProbeBufferedEvents: 3,
|
||||
maxProbeBufferedBytes: 30,
|
||||
maxItemBytes: 10,
|
||||
});
|
||||
|
||||
function createHarness(
|
||||
input: Readonly<{
|
||||
initial?: "LIVE" | "POLL";
|
||||
limits?: LivePollHandoffLimits;
|
||||
apply?: LivePollHandoffCoordinatorDependencies<string>["apply"];
|
||||
recover?: LivePollHandoffCoordinatorDependencies<string>["establishAuthoritativeCheckpoint"];
|
||||
}> = {},
|
||||
) {
|
||||
const clock = new ManualClock();
|
||||
const effects: string[] = [];
|
||||
const recoveries: string[] = [];
|
||||
const apply =
|
||||
input.apply ??
|
||||
vi.fn(async ({ writer, value }) => {
|
||||
effects.push(`${writer}:${value}`);
|
||||
return realtimeSuccess(undefined);
|
||||
});
|
||||
const recover =
|
||||
input.recover ??
|
||||
vi.fn(async ({ from, to }) => {
|
||||
recoveries.push(`${from}->${to}`);
|
||||
return realtimeSuccess(undefined);
|
||||
});
|
||||
const coordinator = createLivePollHandoffCoordinator({
|
||||
initial: {
|
||||
writer: input.initial ?? "LIVE",
|
||||
authoritativeCheckpointEstablished: true,
|
||||
},
|
||||
limits: input.limits ?? limits,
|
||||
apply,
|
||||
establishAuthoritativeCheckpoint: recover,
|
||||
clock,
|
||||
});
|
||||
return { apply, clock, coordinator, effects, recover, recoveries };
|
||||
}
|
||||
|
||||
async function flush(): Promise<void> {
|
||||
for (let turn = 0; turn < 12; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
describe("live/poll authoritative writer handoff", () => {
|
||||
it("serializes effects through the one current writer lease", async () => {
|
||||
const first = deferred<RealtimeResult<void>>();
|
||||
const starts: string[] = [];
|
||||
let activeEffects = 0;
|
||||
let highWatermark = 0;
|
||||
const harness = createHarness({
|
||||
apply: vi.fn(async ({ value }) => {
|
||||
starts.push(value);
|
||||
activeEffects += 1;
|
||||
highWatermark = Math.max(highWatermark, activeEffects);
|
||||
if (value === "first") await first.promise;
|
||||
activeEffects -= 1;
|
||||
return realtimeSuccess(undefined);
|
||||
}),
|
||||
});
|
||||
const writer = harness.coordinator.currentWriter();
|
||||
expect(writer?.writer).toBe("LIVE");
|
||||
|
||||
const firstWrite = writer!.write("first", 5);
|
||||
const secondWrite = writer!.write("second", 6);
|
||||
await flush();
|
||||
expect(starts).toEqual(["first"]);
|
||||
|
||||
first.resolve(realtimeSuccess(undefined));
|
||||
await expect(firstWrite).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "APPLIED", writer: "LIVE" },
|
||||
});
|
||||
await expect(secondWrite).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "APPLIED", writer: "LIVE" },
|
||||
});
|
||||
expect(starts).toEqual(["first", "second"]);
|
||||
expect(highWatermark).toBe(1);
|
||||
});
|
||||
|
||||
it("fails closed when a non-cooperative active writer fills the bounded tail", async () => {
|
||||
let firstSignal: AbortSignal | undefined;
|
||||
let firstIsCurrent: (() => boolean) | undefined;
|
||||
const starts: string[] = [];
|
||||
const harness = createHarness({
|
||||
limits: {
|
||||
...limits,
|
||||
maxActiveQueueCount: 2,
|
||||
maxActiveQueueBytes: 10,
|
||||
},
|
||||
apply: vi.fn(async ({ value, signal, isCurrent }) => {
|
||||
starts.push(value);
|
||||
if (value === "first") {
|
||||
firstSignal = signal;
|
||||
firstIsCurrent = isCurrent;
|
||||
return await new Promise<RealtimeResult<void>>(() => {});
|
||||
}
|
||||
return realtimeSuccess(undefined);
|
||||
}),
|
||||
});
|
||||
const writer = harness.coordinator.currentWriter()!;
|
||||
const first = writer.write("first", 5);
|
||||
const second = writer.write("second", 5);
|
||||
await flush();
|
||||
|
||||
expect(starts).toEqual(["first"]);
|
||||
expect(firstIsCurrent?.()).toBe(true);
|
||||
await expect(writer.write("overflow", 1)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "QUEUE_OVERFLOW",
|
||||
operation: "APPLY",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
expect(firstSignal?.aborted).toBe(true);
|
||||
expect(firstIsCurrent?.()).toBe(false);
|
||||
expect(writer.isCurrent()).toBe(false);
|
||||
expect(harness.coordinator.inspect()).toMatchObject({
|
||||
state: "CLOSED",
|
||||
activeWriter: null,
|
||||
});
|
||||
expect(starts).toEqual(["first"]);
|
||||
await expect(writer.write("after-close", 1)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "CLOSED" },
|
||||
});
|
||||
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>>();
|
||||
const order: string[] = [];
|
||||
let liveSignal: AbortSignal | undefined;
|
||||
let liveIsCurrent: (() => boolean) | undefined;
|
||||
const harness = createHarness({
|
||||
apply: vi.fn(async ({ writer, value, signal, isCurrent }) => {
|
||||
order.push(`apply:${writer}:${value}`);
|
||||
if (writer === "LIVE") {
|
||||
liveSignal = signal;
|
||||
liveIsCurrent = isCurrent;
|
||||
return await liveEffect.promise;
|
||||
}
|
||||
return realtimeSuccess(undefined);
|
||||
}),
|
||||
recover: vi.fn(async ({ from, to }) => {
|
||||
order.push(`recover:${from}->${to}`);
|
||||
return await checkpoint.promise;
|
||||
}),
|
||||
});
|
||||
const live = harness.coordinator.currentWriter()!;
|
||||
const pendingEffect = live.write("in-flight", 9);
|
||||
await flush();
|
||||
|
||||
const transition = harness.coordinator.switchToPoll();
|
||||
expect(liveSignal?.aborted).toBe(true);
|
||||
expect(liveIsCurrent?.()).toBe(false);
|
||||
expect(live.isCurrent()).toBe(false);
|
||||
await expect(live.write("stale", 999)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "SCOPE_FENCED" },
|
||||
});
|
||||
expect(harness.recover).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
harness.coordinator.switchToPoll(),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
|
||||
liveEffect.resolve(realtimeSuccess(undefined));
|
||||
await expect(pendingEffect).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "SCOPE_FENCED" },
|
||||
});
|
||||
await flush();
|
||||
expect(order).toEqual([
|
||||
"apply:LIVE:in-flight",
|
||||
"recover:LIVE->POLL",
|
||||
]);
|
||||
expect(harness.coordinator.currentWriter()).toBeNull();
|
||||
|
||||
checkpoint.resolve(realtimeSuccess(undefined));
|
||||
const result = await transition;
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: { writer: "POLL" },
|
||||
});
|
||||
if (!result.ok) throw new Error("expected poll writer");
|
||||
await result.value.write("polled", 6);
|
||||
expect(order.at(-1)).toBe("apply:POLL:polled");
|
||||
expect(result.value.generation).toBeGreaterThan(live.generation);
|
||||
expect(harness.coordinator.inspect().state).toBe("POLL_ACTIVE");
|
||||
});
|
||||
|
||||
it("fails closed when the prior writer cannot quiesce before the bound", async () => {
|
||||
const harness = createHarness({
|
||||
apply: vi.fn(
|
||||
async () =>
|
||||
await new Promise<RealtimeResult<void>>(() => {}),
|
||||
),
|
||||
});
|
||||
const live = harness.coordinator.currentWriter()!;
|
||||
void live.write("hung", 4);
|
||||
await flush();
|
||||
|
||||
const transition = harness.coordinator.switchToPoll();
|
||||
await flush();
|
||||
expect(harness.clock.sleepers).toHaveLength(1);
|
||||
harness.clock.advance(100);
|
||||
|
||||
await expect(transition).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "RECOVER" },
|
||||
});
|
||||
expect(harness.recover).not.toHaveBeenCalled();
|
||||
expect(harness.coordinator.inspect()).toMatchObject({
|
||||
state: "CLOSED",
|
||||
activeWriter: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps poll authoritative while probing, then recovers and drains live values serially", async () => {
|
||||
const firstLive = deferred<RealtimeResult<void>>();
|
||||
const order: string[] = [];
|
||||
let activeEffects = 0;
|
||||
let highWatermark = 0;
|
||||
const harness = createHarness({
|
||||
initial: "POLL",
|
||||
apply: vi.fn(async ({ writer, value }) => {
|
||||
activeEffects += 1;
|
||||
highWatermark = Math.max(highWatermark, activeEffects);
|
||||
order.push(`apply:${writer}:${value}`);
|
||||
if (value === "live-1") await firstLive.promise;
|
||||
activeEffects -= 1;
|
||||
return realtimeSuccess(undefined);
|
||||
}),
|
||||
recover: vi.fn(async ({ from, to }) => {
|
||||
order.push(`recover:${from}->${to}`);
|
||||
return realtimeSuccess(undefined);
|
||||
}),
|
||||
});
|
||||
const poll = harness.coordinator.currentWriter()!;
|
||||
const opened = harness.coordinator.beginLiveProbe();
|
||||
expect(opened.ok).toBe(true);
|
||||
if (!opened.ok) throw new Error("expected live probe");
|
||||
const live = opened.value;
|
||||
|
||||
await expect(live.write("live-1", 6)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { kind: "BUFFERED" },
|
||||
});
|
||||
await live.write("live-2", 6);
|
||||
expect(live.isCurrent()).toBe(false);
|
||||
expect(poll.isCurrent()).toBe(true);
|
||||
await poll.write("poll-during-probe", 8);
|
||||
expect(order).toEqual(["apply:POLL:poll-during-probe"]);
|
||||
|
||||
const activation = live.activate();
|
||||
await vi.waitFor(() =>
|
||||
expect(order).toEqual([
|
||||
"apply:POLL:poll-during-probe",
|
||||
"recover:POLL->LIVE",
|
||||
"apply:LIVE:live-1",
|
||||
]),
|
||||
);
|
||||
await live.write("live-3", 6);
|
||||
expect(harness.coordinator.inspect()).toMatchObject({
|
||||
state: "LIVE_PROBING",
|
||||
bufferedEvents: 2,
|
||||
transitioning: true,
|
||||
});
|
||||
|
||||
firstLive.resolve(realtimeSuccess(undefined));
|
||||
const activated = await activation;
|
||||
expect(activated).toMatchObject({
|
||||
ok: true,
|
||||
value: { writer: "LIVE" },
|
||||
});
|
||||
expect(order).toEqual([
|
||||
"apply:POLL:poll-during-probe",
|
||||
"recover:POLL->LIVE",
|
||||
"apply:LIVE:live-1",
|
||||
"apply:LIVE:live-2",
|
||||
"apply:LIVE:live-3",
|
||||
]);
|
||||
expect(highWatermark).toBe(1);
|
||||
expect(live.isCurrent()).toBe(true);
|
||||
expect(poll.isCurrent()).toBe(false);
|
||||
await expect(poll.write("stale-poll", 5)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "SCOPE_FENCED" },
|
||||
});
|
||||
expect(harness.coordinator.inspect()).toMatchObject({
|
||||
state: "LIVE_ACTIVE",
|
||||
bufferedEvents: 0,
|
||||
bufferedBytes: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops an overflowing probe without displacing poll and never reuses its generation", async () => {
|
||||
const harness = createHarness({
|
||||
initial: "POLL",
|
||||
limits: {
|
||||
...limits,
|
||||
maxProbeBufferedEvents: 2,
|
||||
},
|
||||
});
|
||||
const poll = harness.coordinator.currentWriter()!;
|
||||
const first = expectProbe(harness.coordinator.beginLiveProbe());
|
||||
await first.write("one", 3);
|
||||
await first.write("two", 3);
|
||||
await expect(first.write("overflow", 3)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "QUEUE_OVERFLOW" },
|
||||
});
|
||||
expect(harness.coordinator.inspect().state).toBe("POLL_ACTIVE");
|
||||
expect(poll.isCurrent()).toBe(true);
|
||||
await expect(first.write("stale", 999)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "SCOPE_FENCED" },
|
||||
});
|
||||
|
||||
const second = expectProbe(harness.coordinator.beginLiveProbe());
|
||||
expect(second.generation).toBeGreaterThan(first.generation);
|
||||
const canceled = second.cancel();
|
||||
expect(canceled).toMatchObject({
|
||||
ok: true,
|
||||
value: { writer: "POLL", generation: poll.generation },
|
||||
});
|
||||
expect(second.signal.aborted).toBe(true);
|
||||
expect(harness.coordinator.inspect().state).toBe("POLL_ACTIVE");
|
||||
});
|
||||
|
||||
it("closes if authoritative recovery fails and never activates the candidate", async () => {
|
||||
const harness = createHarness({
|
||||
recover: vi.fn(async () =>
|
||||
realtimeFailure("CURSOR_EXPIRED", "RECOVER"),
|
||||
),
|
||||
});
|
||||
const live = harness.coordinator.currentWriter()!;
|
||||
|
||||
await expect(
|
||||
harness.coordinator.switchToPoll(),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "CURSOR_EXPIRED", operation: "RECOVER" },
|
||||
});
|
||||
expect(live.signal.aborted).toBe(true);
|
||||
expect(harness.coordinator.inspect()).toMatchObject({
|
||||
state: "CLOSED",
|
||||
activeWriter: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds a non-cooperative authoritative checkpoint", async () => {
|
||||
let checkpointIsCurrent: (() => boolean) | undefined;
|
||||
const harness = createHarness({
|
||||
recover: vi.fn(async ({ isCurrent }) => {
|
||||
checkpointIsCurrent = isCurrent;
|
||||
return await new Promise<RealtimeResult<void>>(() => {});
|
||||
}),
|
||||
});
|
||||
|
||||
const transition = harness.coordinator.switchToPoll();
|
||||
await flush();
|
||||
expect(checkpointIsCurrent?.()).toBe(true);
|
||||
expect(harness.clock.sleepers).toHaveLength(1);
|
||||
harness.clock.advance(100);
|
||||
|
||||
await expect(transition).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "RECOVER" },
|
||||
});
|
||||
expect(checkpointIsCurrent?.()).toBe(false);
|
||||
expect(harness.coordinator.inspect().state).toBe("CLOSED");
|
||||
});
|
||||
|
||||
it("aborts and bounds close quiescence when an effect ignores cancellation", async () => {
|
||||
let effectSignal: AbortSignal | undefined;
|
||||
const harness = createHarness({
|
||||
apply: vi.fn(
|
||||
async ({ signal }) => {
|
||||
effectSignal = signal;
|
||||
return await new Promise<RealtimeResult<void>>(() => {});
|
||||
},
|
||||
),
|
||||
});
|
||||
const live = harness.coordinator.currentWriter()!;
|
||||
void live.write("hung-close", 8);
|
||||
await flush();
|
||||
|
||||
const closing = harness.coordinator.close();
|
||||
expect(effectSignal?.aborted).toBe(true);
|
||||
expect(harness.coordinator.inspect().state).toBe("CLOSED");
|
||||
await flush();
|
||||
harness.clock.advance(100);
|
||||
await expect(closing).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
await expect(harness.coordinator.close()).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "IDLE_TIMEOUT", operation: "CLOSE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid initial authority and resource ceilings", () => {
|
||||
expect(() =>
|
||||
createLivePollHandoffCoordinator({
|
||||
initial: {
|
||||
writer: "LIVE",
|
||||
authoritativeCheckpointEstablished: false,
|
||||
} as never,
|
||||
limits,
|
||||
apply: async () => realtimeSuccess(undefined),
|
||||
establishAuthoritativeCheckpoint: async () =>
|
||||
realtimeSuccess(undefined),
|
||||
}),
|
||||
).toThrow(/initial checkpoint/u);
|
||||
expect(() =>
|
||||
createLivePollHandoffCoordinator({
|
||||
initial: {
|
||||
writer: "POLL",
|
||||
authoritativeCheckpointEstablished: true,
|
||||
},
|
||||
limits: { ...limits, maxProbeBufferedEvents: 257 },
|
||||
apply: async () => realtimeSuccess(undefined),
|
||||
establishAuthoritativeCheckpoint: async () =>
|
||||
realtimeSuccess(undefined),
|
||||
}),
|
||||
).toThrow(/limits/u);
|
||||
expect(() =>
|
||||
createLivePollHandoffCoordinator({
|
||||
initial: {
|
||||
writer: "POLL",
|
||||
authoritativeCheckpointEstablished: true,
|
||||
},
|
||||
limits: { ...limits, maxActiveQueueCount: 257 },
|
||||
apply: async () => realtimeSuccess(undefined),
|
||||
establishAuthoritativeCheckpoint: async () =>
|
||||
realtimeSuccess(undefined),
|
||||
}),
|
||||
).toThrow(/limits/u);
|
||||
});
|
||||
});
|
||||
|
||||
function expectProbe(
|
||||
result: RealtimeResult<LiveProbeLease<string>>,
|
||||
): LiveProbeLease<string> {
|
||||
if (!result.ok) throw new Error("expected live probe");
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function deferred<Value>() {
|
||||
let resolve!: (value: Value) => void;
|
||||
const promise = new Promise<Value>((selectedResolve) => {
|
||||
resolve = selectedResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
calculateReconnectDelay,
|
||||
defineReconnectPolicy,
|
||||
isReconnectAttemptResetEligible,
|
||||
parseRetryAfterDelay,
|
||||
REALTIME_RECONNECT_CEILINGS,
|
||||
reconnectBudgetRemaining,
|
||||
type ReconnectPolicy,
|
||||
} from "../../../src/adapters/realtime/reconnect-policy.ts";
|
||||
|
||||
const policy = defineReconnectPolicy({
|
||||
baseDelayMs: 1_000,
|
||||
maxDelayMs: 60_000,
|
||||
maxAttempts: 10,
|
||||
maxElapsedMs: 300_000,
|
||||
stableOpenMs: 30_000,
|
||||
});
|
||||
|
||||
describe("realtime reconnect policy", () => {
|
||||
it("uses full jitter and treats a server hint as a not-before floor", () => {
|
||||
expect(
|
||||
calculateReconnectDelay({
|
||||
policy,
|
||||
attemptIndex: 2,
|
||||
remainingElapsedMs: 10_000,
|
||||
random: () => 0.5,
|
||||
}),
|
||||
).toBe(2_000);
|
||||
expect(
|
||||
calculateReconnectDelay({
|
||||
policy,
|
||||
attemptIndex: 2,
|
||||
remainingElapsedMs: 10_000,
|
||||
random: () => 0.5,
|
||||
serverNotBeforeMs: 3_000,
|
||||
}),
|
||||
).toBe(3_000);
|
||||
});
|
||||
|
||||
it("stops instead of clamping a hint past a hard or remaining budget", () => {
|
||||
expect(
|
||||
calculateReconnectDelay({
|
||||
policy,
|
||||
attemptIndex: 0,
|
||||
remainingElapsedMs: 100_000,
|
||||
random: () => 0,
|
||||
serverNotBeforeMs: 60_001,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
calculateReconnectDelay({
|
||||
policy,
|
||||
attemptIndex: 0,
|
||||
remainingElapsedMs: 3_000,
|
||||
random: () => 0,
|
||||
serverNotBeforeMs: 3_000,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
calculateReconnectDelay({
|
||||
policy,
|
||||
attemptIndex: 10,
|
||||
remainingElapsedMs: 100_000,
|
||||
random: () => 0,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("parses only bounded-shape Retry-After syntax for caller validation", () => {
|
||||
expect(parseRetryAfterDelay("12", 0)).toBe(12_000);
|
||||
expect(
|
||||
parseRetryAfterDelay(
|
||||
"Thu, 01 Jan 1970 00:00:20 GMT",
|
||||
5_000,
|
||||
),
|
||||
).toBe(15_000);
|
||||
expect(parseRetryAfterDelay("1.5", 0)).toBeNull();
|
||||
expect(parseRetryAfterDelay("-1", 0)).toBeNull();
|
||||
});
|
||||
|
||||
it("resets attempts only after stable open or a valid signal", () => {
|
||||
expect(
|
||||
isReconnectAttemptResetEligible({
|
||||
policy,
|
||||
openedAtMs: 10,
|
||||
nowMs: 29_000,
|
||||
observedValidHeartbeatOrEvent: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isReconnectAttemptResetEligible({
|
||||
policy,
|
||||
openedAtMs: 10,
|
||||
nowMs: 30_010,
|
||||
observedValidHeartbeatOrEvent: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isReconnectAttemptResetEligible({
|
||||
policy,
|
||||
openedAtMs: 10,
|
||||
nowMs: 11,
|
||||
observedValidHeartbeatOrEvent: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(reconnectBudgetRemaining(policy, 1_000, 2_000)).toBe(
|
||||
299_000,
|
||||
);
|
||||
expect(reconnectBudgetRemaining(policy, 2_000, 1_000)).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects policies above the implementation ceiling", () => {
|
||||
expect(() =>
|
||||
defineReconnectPolicy({
|
||||
...policy,
|
||||
maxAttempts: 11,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("keeps aborted-task drain below the absolute implementation ceiling", () => {
|
||||
expect(REALTIME_RECONNECT_CEILINGS.drainTimeoutMs).toBe(2_000);
|
||||
expect(
|
||||
REALTIME_RECONNECT_CEILINGS.drainTimeoutMs,
|
||||
).toBeLessThanOrEqual(
|
||||
REALTIME_RECONNECT_CEILINGS.maxDrainTimeoutMs,
|
||||
);
|
||||
expect(
|
||||
REALTIME_RECONNECT_CEILINGS.maxDrainTimeoutMs,
|
||||
).toBe(30_000);
|
||||
});
|
||||
|
||||
it("snapshots only exact own data properties", () => {
|
||||
const inherited = Object.create(policy) as ReconnectPolicy;
|
||||
const accessor = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
baseDelayMs: { enumerable: true, value: 1_000 },
|
||||
maxDelayMs: {
|
||||
enumerable: true,
|
||||
get: () => 60_000,
|
||||
},
|
||||
maxAttempts: { enumerable: true, value: 10 },
|
||||
maxElapsedMs: { enumerable: true, value: 300_000 },
|
||||
stableOpenMs: { enumerable: true, value: 30_000 },
|
||||
},
|
||||
) as ReconnectPolicy;
|
||||
|
||||
expect(() => defineReconnectPolicy(inherited)).toThrow(TypeError);
|
||||
expect(() => defineReconnectPolicy(accessor)).toThrow(TypeError);
|
||||
expect(() =>
|
||||
defineReconnectPolicy({
|
||||
...policy,
|
||||
extra: true,
|
||||
} as ReconnectPolicy),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createRealtimePolicyRegistry,
|
||||
defineEventTypeId,
|
||||
defineStreamRegistrationId,
|
||||
REALTIME_HARD_LIMITS,
|
||||
type RealtimeLimits,
|
||||
type RealtimeStreamRegistration,
|
||||
} from "../../../src/contracts/realtime-streams.ts";
|
||||
import {
|
||||
EFFECT_PROFILE_ID,
|
||||
ENDPOINT_ID,
|
||||
EVENT_TYPE,
|
||||
KILL_SWITCH_ID,
|
||||
STREAM_ID,
|
||||
TEST_API_OPERATIONS,
|
||||
TEST_LIMITS,
|
||||
TEST_MAPPERS,
|
||||
TEST_SCHEMA_CODECS,
|
||||
createTestRealtimeRegistry,
|
||||
} from "./fixture.ts";
|
||||
|
||||
describe("realtime policy registry", () => {
|
||||
it("deep-snapshots registrations and resolves only stream-owned event types", () => {
|
||||
const baseline = createTestRealtimeRegistry();
|
||||
const sourceEventIds = [EVENT_TYPE];
|
||||
const sourceLimits = { ...TEST_LIMITS };
|
||||
const sourceStream: RealtimeStreamRegistration = {
|
||||
...baseline.listStreams()[0]!,
|
||||
eventTypeIds: sourceEventIds,
|
||||
limits: sourceLimits,
|
||||
};
|
||||
const sourceEventType = {
|
||||
...baseline.listEventTypes()[0]!,
|
||||
};
|
||||
const registry = createRealtimePolicyRegistry({
|
||||
streams: [sourceStream],
|
||||
eventTypes: [sourceEventType],
|
||||
bindings: bindings(),
|
||||
});
|
||||
|
||||
sourceEventIds[0] = defineEventTypeId("MUTATED_EVENT");
|
||||
sourceLimits.maxQueueEvents = 1;
|
||||
sourceEventType.owner = "mutated-owner";
|
||||
|
||||
const installed = registry.findStream(STREAM_ID);
|
||||
expect(installed).toMatchObject({
|
||||
id: STREAM_ID,
|
||||
eventTypeIds: [EVENT_TYPE],
|
||||
limits: { maxQueueEvents: TEST_LIMITS.maxQueueEvents },
|
||||
});
|
||||
expect(registry.findEventType(EVENT_TYPE)?.owner).toBe(
|
||||
"reference-feature",
|
||||
);
|
||||
expect(
|
||||
registry.findStreamEventType(STREAM_ID, EVENT_TYPE)?.id,
|
||||
).toBe(EVENT_TYPE);
|
||||
expect(
|
||||
registry.findStreamEventType(STREAM_ID, "MUTATED_EVENT"),
|
||||
).toBeUndefined();
|
||||
expect(Object.isFrozen(installed)).toBe(true);
|
||||
expect(Object.isFrozen(installed?.eventTypeIds)).toBe(true);
|
||||
expect(Object.isFrozen(installed?.limits)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicates, unknown references and extra keys", () => {
|
||||
const baseline = createTestRealtimeRegistry();
|
||||
const stream = baseline.listStreams()[0]!;
|
||||
const eventType = baseline.listEventTypes()[0]!;
|
||||
|
||||
expect(() =>
|
||||
createRealtimePolicyRegistry({
|
||||
streams: [stream, stream],
|
||||
eventTypes: [eventType],
|
||||
bindings: bindings(),
|
||||
}),
|
||||
).toThrow("stream is duplicated");
|
||||
|
||||
expect(() =>
|
||||
createRealtimePolicyRegistry({
|
||||
streams: [
|
||||
{
|
||||
...stream,
|
||||
eventTypeIds: [defineEventTypeId("UNKNOWN_EVENT")],
|
||||
},
|
||||
],
|
||||
eventTypes: [eventType],
|
||||
bindings: bindings(),
|
||||
}),
|
||||
).toThrow("stream registration is invalid");
|
||||
|
||||
expect(() =>
|
||||
createRealtimePolicyRegistry({
|
||||
streams: [
|
||||
{
|
||||
...stream,
|
||||
unregisteredOverride: true,
|
||||
} as RealtimeStreamRegistration,
|
||||
],
|
||||
eventTypes: [eventType],
|
||||
bindings: bindings(),
|
||||
}),
|
||||
).toThrow("stream registration is invalid");
|
||||
|
||||
expect(() =>
|
||||
createTestRealtimeRegistry({
|
||||
eventTypeMutator: (candidate) => ({
|
||||
...candidate,
|
||||
mapperId: "MissingMapper",
|
||||
}),
|
||||
}),
|
||||
).toThrow("event type registration is invalid");
|
||||
});
|
||||
|
||||
it("closes state-bearing, ephemeral and recovery contradictions", () => {
|
||||
expect(() =>
|
||||
createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SNAPSHOT_ONLY",
|
||||
snapshotOperationId: "GET_REFERENCE_REALTIME_SNAPSHOT",
|
||||
checkpointCodecId: "ReferenceRealtimeCheckpoint",
|
||||
barrier: "NONE",
|
||||
},
|
||||
delivery: "INVALIDATION_HINT",
|
||||
stateBearing: true,
|
||||
}),
|
||||
).toThrow("recovery contract is contradictory");
|
||||
|
||||
expect(() =>
|
||||
createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SESSION_REBUILD",
|
||||
rebuildInputId: "referenceRealtimeRebuild",
|
||||
},
|
||||
delivery: "EPHEMERAL",
|
||||
stateBearing: true,
|
||||
}),
|
||||
).toThrow("recovery contract is contradictory");
|
||||
|
||||
expect(() =>
|
||||
createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SESSION_REBUILD",
|
||||
rebuildInputId: "referenceRealtimeRebuild",
|
||||
},
|
||||
delivery: "INVALIDATION_HINT",
|
||||
stateBearing: false,
|
||||
}),
|
||||
).toThrow("recovery contract is contradictory");
|
||||
|
||||
const registry = createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SESSION_REBUILD",
|
||||
rebuildInputId: "referenceRealtimeRebuild",
|
||||
},
|
||||
delivery: "EPHEMERAL",
|
||||
stateBearing: false,
|
||||
});
|
||||
expect(registry.findStream(STREAM_ID)?.recovery.mode).toBe(
|
||||
"SESSION_REBUILD",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows only reductions of implementation ceilings", () => {
|
||||
expect(() =>
|
||||
createTestRealtimeRegistry({
|
||||
limits: {
|
||||
...TEST_LIMITS,
|
||||
maxQueueEvents: REALTIME_HARD_LIMITS.maxQueueEvents + 1,
|
||||
},
|
||||
}),
|
||||
).toThrow("exceed implementation ceilings");
|
||||
|
||||
expect(() =>
|
||||
createTestRealtimeRegistry({
|
||||
limits: {
|
||||
...TEST_LIMITS,
|
||||
maxQueueBytes: TEST_LIMITS.maxEventBytes - 1,
|
||||
},
|
||||
}),
|
||||
).toThrow("cannot hold one event");
|
||||
|
||||
const strictLimits: RealtimeLimits = {
|
||||
...TEST_LIMITS,
|
||||
maxQueueEvents: 1,
|
||||
maxDedupeEntries: 1,
|
||||
};
|
||||
expect(
|
||||
createTestRealtimeRegistry({ limits: strictLimits }).findStream(
|
||||
STREAM_ID,
|
||||
)?.limits,
|
||||
).toMatchObject({
|
||||
maxQueueEvents: 1,
|
||||
maxDedupeEntries: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("issues only bounded closed registry IDs", () => {
|
||||
expect(defineStreamRegistrationId("VALID_STREAM")).toBe(
|
||||
"VALID_STREAM",
|
||||
);
|
||||
expect(() => defineStreamRegistrationId("arbitrary-channel")).toThrow(
|
||||
"stream ID is invalid",
|
||||
);
|
||||
expect(() => defineEventTypeId("X")).toThrow(
|
||||
"event type ID is invalid",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function bindings() {
|
||||
return {
|
||||
schemaCodecs: TEST_SCHEMA_CODECS,
|
||||
mappers: TEST_MAPPERS,
|
||||
apiOperations: TEST_API_OPERATIONS,
|
||||
endpointIds: [ENDPOINT_ID],
|
||||
effectProfileIds: [EFFECT_PROFILE_ID],
|
||||
killSwitchIds: [KILL_SWITCH_ID],
|
||||
rebuildInputIds: ["referenceRealtimeRebuild"],
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isRealtimeResult,
|
||||
realtimeFailure,
|
||||
realtimeSuccess,
|
||||
snapshotRealtimeResult,
|
||||
} from "../../../src/adapters/realtime/result.ts";
|
||||
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === "string";
|
||||
}
|
||||
|
||||
describe("realtime result boundary", () => {
|
||||
it("uses an immutable one-shot snapshot instead of rereading a mutable result", () => {
|
||||
const source = { ok: true, value: "captured" };
|
||||
const captured = snapshotRealtimeResult(source, (value): value is string => {
|
||||
source.value = "changed-during-validation";
|
||||
return value === "captured";
|
||||
});
|
||||
source.value = "changed-after-validation";
|
||||
|
||||
expect(captured).toEqual(
|
||||
realtimeSuccess("captured"),
|
||||
);
|
||||
expect(Object.isFrozen(captured)).toBe(true);
|
||||
expect(isRealtimeResult(source, isString)).toBe(false);
|
||||
});
|
||||
|
||||
it("canonicalizes failure fields before the source can change", () => {
|
||||
const source = {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "FORBIDDEN",
|
||||
operation: "RECOVER",
|
||||
retryable: false,
|
||||
},
|
||||
};
|
||||
const captured = snapshotRealtimeResult(
|
||||
source,
|
||||
(_value): _value is never => false,
|
||||
);
|
||||
source.error.kind = "PROVIDER_UNAVAILABLE";
|
||||
source.error.operation = "CONNECT";
|
||||
source.error.retryable = true;
|
||||
|
||||
expect(captured).toEqual(
|
||||
realtimeFailure("FORBIDDEN", "RECOVER", false),
|
||||
);
|
||||
expect(
|
||||
captured && !captured.ok
|
||||
? Object.isFrozen(captured.error)
|
||||
: false,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects accessors without invoking them", () => {
|
||||
let outerReads = 0;
|
||||
const outerAccessor = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
ok: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
outerReads += 1;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
value: {
|
||||
enumerable: true,
|
||||
value: "safe",
|
||||
},
|
||||
},
|
||||
);
|
||||
let nestedReads = 0;
|
||||
const nestedAccessor = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
kind: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
nestedReads += 1;
|
||||
return "FORBIDDEN";
|
||||
},
|
||||
},
|
||||
operation: {
|
||||
enumerable: true,
|
||||
value: "RECOVER",
|
||||
},
|
||||
retryable: {
|
||||
enumerable: true,
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
snapshotRealtimeResult(outerAccessor, isString),
|
||||
).toBeNull();
|
||||
expect(
|
||||
snapshotRealtimeResult(
|
||||
{ ok: false, error: nestedAccessor },
|
||||
(_value): _value is never => false,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(outerReads).toBe(0);
|
||||
expect(nestedReads).toBe(0);
|
||||
});
|
||||
|
||||
it("consumes proxy fields only from one descriptor snapshot", () => {
|
||||
const propertyReads: PropertyKey[] = [];
|
||||
const descriptorReads = new Map<PropertyKey, number>();
|
||||
const source = new Proxy(
|
||||
{ ok: true, value: "descriptor-value" },
|
||||
{
|
||||
get(_target, key) {
|
||||
propertyReads.push(key);
|
||||
return key === "value" ? "get-trap-value" : false;
|
||||
},
|
||||
getOwnPropertyDescriptor(target, key) {
|
||||
descriptorReads.set(
|
||||
key,
|
||||
(descriptorReads.get(key) ?? 0) + 1,
|
||||
);
|
||||
return Reflect.getOwnPropertyDescriptor(target, key);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(snapshotRealtimeResult(source, isString)).toEqual(
|
||||
realtimeSuccess("descriptor-value"),
|
||||
);
|
||||
expect(propertyReads).toEqual([]);
|
||||
expect(descriptorReads).toEqual(
|
||||
new Map<PropertyKey, number>([
|
||||
["ok", 1],
|
||||
["value", 1],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed when a proxy is revoked", () => {
|
||||
const revocable = Proxy.revocable(
|
||||
{ ok: true, value: "safe" },
|
||||
{},
|
||||
);
|
||||
revocable.revoke();
|
||||
|
||||
expect(
|
||||
snapshotRealtimeResult(revocable.proxy, isString),
|
||||
).toBeNull();
|
||||
expect(isRealtimeResult(revocable.proxy, isString)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ok: true, value: "safe", extra: true },
|
||||
Object.assign(
|
||||
Object.create({ inherited: true }) as Record<string, unknown>,
|
||||
{ ok: true, value: "safe" },
|
||||
),
|
||||
Object.assign(
|
||||
{ ok: true, value: "safe" },
|
||||
{ [Symbol("hidden")]: true },
|
||||
),
|
||||
])("rejects extra, inherited and symbol-key shapes", (source) => {
|
||||
expect(snapshotRealtimeResult(source, isString)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createIncrementalSseParser,
|
||||
type SseParserItem,
|
||||
} from "../../../src/adapters/realtime/sse/sse-parser.ts";
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function pushText(
|
||||
parser: ReturnType<typeof createIncrementalSseParser>,
|
||||
text: string,
|
||||
): readonly SseParserItem[] {
|
||||
const result = parser.push(encoder.encode(text));
|
||||
if (!result.ok) throw new Error(result.error.kind);
|
||||
return result.value;
|
||||
}
|
||||
|
||||
describe("incremental SSE parser", () => {
|
||||
it("handles BOM, chunk boundaries, CR/LF/CRLF, comments and multi-line data", () => {
|
||||
const parser = createIncrementalSseParser();
|
||||
const chunks = [
|
||||
"\uFEFF: ready\r",
|
||||
"\nretry: 2500\revent: resource.updated\n",
|
||||
"id: cursor-1\r\ndata: first\rdata:second\n\n",
|
||||
];
|
||||
const items = chunks.flatMap((chunk) => pushText(parser, chunk));
|
||||
|
||||
expect(items).toEqual([
|
||||
{ kind: "COMMENT" },
|
||||
{ kind: "RETRY", retryMs: 2_500 },
|
||||
{
|
||||
kind: "EVENT",
|
||||
eventType: "resource.updated",
|
||||
data: "first\nsecond",
|
||||
id: "cursor-1",
|
||||
hasExplicitId: true,
|
||||
},
|
||||
]);
|
||||
expect(parser.finish()).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [],
|
||||
incompleteEventDiscarded: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves standard inherited ID state while marking direct IDs", () => {
|
||||
const parser = createIncrementalSseParser();
|
||||
const items = pushText(
|
||||
parser,
|
||||
"id: cursor-a\ndata: one\n\ndata: two\n\ndata:\n\n",
|
||||
);
|
||||
|
||||
expect(items).toEqual([
|
||||
{
|
||||
kind: "EVENT",
|
||||
eventType: "message",
|
||||
data: "one",
|
||||
id: "cursor-a",
|
||||
hasExplicitId: true,
|
||||
},
|
||||
{
|
||||
kind: "EVENT",
|
||||
eventType: "message",
|
||||
data: "two",
|
||||
id: "cursor-a",
|
||||
hasExplicitId: false,
|
||||
},
|
||||
{
|
||||
kind: "EVENT",
|
||||
eventType: "message",
|
||||
data: "",
|
||||
id: "cursor-a",
|
||||
hasExplicitId: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores invalid ID and retry fields without inventing a cursor", () => {
|
||||
const parser = createIncrementalSseParser();
|
||||
expect(
|
||||
pushText(
|
||||
parser,
|
||||
"id: invalid\u0000cursor\nretry: 99999\ndata: value\n\n",
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "EVENT",
|
||||
eventType: "message",
|
||||
data: "value",
|
||||
id: null,
|
||||
hasExplicitId: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("discards an event that was not terminated by a blank line", () => {
|
||||
const parser = createIncrementalSseParser();
|
||||
expect(pushText(parser, "data: incomplete\n")).toEqual([]);
|
||||
expect(parser.finish()).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
items: [],
|
||||
incompleteEventDiscarded: true,
|
||||
},
|
||||
});
|
||||
expect(parser.push(encoder.encode("data: late\n\n"))).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "CLOSED",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed on malformed UTF-8 and parser ceilings", () => {
|
||||
const malformed = createIncrementalSseParser();
|
||||
expect(
|
||||
malformed.push(new Uint8Array([0xc3, 0x28])),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "MALFORMED_EVENT",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const longLine = createIncrementalSseParser({
|
||||
maxLineBytes: 4,
|
||||
maxEventBytes: 8,
|
||||
maxIncompleteBufferBytes: 8,
|
||||
maxRetryMs: 100,
|
||||
});
|
||||
expect(longLine.push(encoder.encode("data:"))).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "EVENT_TOO_LARGE",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const largeEvent = createIncrementalSseParser({
|
||||
maxLineBytes: 16,
|
||||
maxEventBytes: 8,
|
||||
maxIncompleteBufferBytes: 16,
|
||||
maxRetryMs: 100,
|
||||
});
|
||||
expect(largeEvent.push(encoder.encode("data:abc\n"))).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "EVENT_TOO_LARGE",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const excessiveBatch = createIncrementalSseParser({
|
||||
maxItemsPerChunk: 2,
|
||||
});
|
||||
expect(excessiveBatch.push(encoder.encode(":\n:\n:\n"))).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "QUEUE_OVERFLOW",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
|
||||
const oversizedChunk = createIncrementalSseParser({
|
||||
maxChunkBytes: 65_536,
|
||||
});
|
||||
expect(
|
||||
oversizedChunk.push(new Uint8Array(65_537)),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "EVENT_TOO_LARGE",
|
||||
operation: "DECODE",
|
||||
retryable: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,975 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
ExternalRealtimeEventContext,
|
||||
RealtimeEventAuthority,
|
||||
RealtimeObservation,
|
||||
RealtimeRecoveryCommit,
|
||||
RealtimeRecoveryRequest,
|
||||
} from "../../../src/application/ports/realtime/event-authority.ts";
|
||||
import type { RealtimeResult } from "../../../src/application/ports/realtime/shared.ts";
|
||||
import {
|
||||
createRealtimeStreamCoordinator,
|
||||
} from "../../../src/adapters/realtime/stream-coordinator.ts";
|
||||
import {
|
||||
realtimeFailure,
|
||||
realtimeSuccess,
|
||||
} from "../../../src/adapters/realtime/result.ts";
|
||||
import type { ValidatedRealtimeEventDto } from "../../../src/adapters/realtime/event-codec.ts";
|
||||
import type { RealtimePolicyRegistry } from "../../../src/contracts/realtime-streams.ts";
|
||||
import {
|
||||
STREAM_ID,
|
||||
TEST_LIMITS,
|
||||
TEST_MAPPER,
|
||||
TEST_MAPPERS,
|
||||
createTestRealtimeCodec,
|
||||
createTestRealtimeRegistry,
|
||||
realtimeEventJson,
|
||||
type EventOverrides,
|
||||
} from "./fixture.ts";
|
||||
|
||||
describe("transport-independent realtime stream coordinator", () => {
|
||||
it("applies one stream sequentially and commits each cursor after its effect", async () => {
|
||||
const first = deferred<RealtimeResult<void>>();
|
||||
const applied: string[] = [];
|
||||
const harness = createHarness({
|
||||
async apply(_profile, value) {
|
||||
const selected = (value as Readonly<{ value: string }>).value;
|
||||
applied.push(selected);
|
||||
return selected === "first"
|
||||
? first.promise
|
||||
: realtimeSuccess(undefined);
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
const firstResult = harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-00000001",
|
||||
sequence: "1",
|
||||
resumeCursor: "cursor-00000001",
|
||||
payload: { value: "first" },
|
||||
}),
|
||||
);
|
||||
const secondResult = harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-00000002",
|
||||
sequence: "2",
|
||||
resumeCursor: "cursor-00000002",
|
||||
payload: { value: "second" },
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(applied).toEqual(["first"]));
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toMatchObject({
|
||||
lastAppliedSequence: "0",
|
||||
resumeCursor: "cursor-snapshot-0",
|
||||
});
|
||||
|
||||
first.resolve(realtimeSuccess(undefined));
|
||||
await expect(firstResult).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { outcome: "APPLIED" },
|
||||
});
|
||||
await expect(secondResult).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { outcome: "APPLIED" },
|
||||
});
|
||||
expect(applied).toEqual(["first", "second"]);
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toEqual({
|
||||
recoveryMode: "CURSOR",
|
||||
streamEpoch: "stream-epoch-0001",
|
||||
lastAppliedSequence: "2",
|
||||
resumeCursor: "cursor-00000002",
|
||||
});
|
||||
});
|
||||
|
||||
it("drops exact duplicates and stale events without replaying effects", async () => {
|
||||
const harness = createHarness();
|
||||
await harness.initialize();
|
||||
const event = harness.event();
|
||||
|
||||
await harness.coordinator.accept(event);
|
||||
await expect(harness.coordinator.accept(event)).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "DROPPED",
|
||||
reason: "DUPLICATE_EVENT",
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-stale-0001",
|
||||
sequence: "0",
|
||||
resumeCursor: "cursor-stale-0001",
|
||||
}),
|
||||
),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "DROPPED",
|
||||
reason: "STALE_EVENT",
|
||||
},
|
||||
});
|
||||
expect(harness.effects).toHaveBeenCalledTimes(1);
|
||||
expect(harness.coordinator.inspect(STREAM_ID).dedupeEntries).toBe(1);
|
||||
});
|
||||
|
||||
it("recovers without applying a conflicting ID, sequence gap or epoch", async () => {
|
||||
for (const [overrides, expectedReason] of [
|
||||
[
|
||||
{
|
||||
eventId: "event-00000001",
|
||||
sequence: "1",
|
||||
payload: { value: "conflict" },
|
||||
},
|
||||
"EVENT_CONFLICT",
|
||||
],
|
||||
[
|
||||
{
|
||||
eventId: "event-gap-000001",
|
||||
sequence: "3",
|
||||
resumeCursor: "cursor-gap-000001",
|
||||
},
|
||||
"SEQUENCE_GAP",
|
||||
],
|
||||
[
|
||||
{
|
||||
eventId: "event-epoch-0001",
|
||||
sequence: "2",
|
||||
streamEpoch: "stream-epoch-0002",
|
||||
resumeCursor: "cursor-epoch-0001",
|
||||
},
|
||||
"STREAM_EPOCH_CHANGED",
|
||||
],
|
||||
] as const) {
|
||||
const harness = createHarness();
|
||||
await harness.initialize();
|
||||
await harness.coordinator.accept(harness.event());
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(harness.event(overrides)),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "RECOVERED",
|
||||
reason: expectedReason,
|
||||
},
|
||||
});
|
||||
expect(harness.recoveryReasons).toEqual([
|
||||
"INITIALIZE",
|
||||
expectedReason,
|
||||
]);
|
||||
expect(harness.effects).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not apply or advance when mapping fails", async () => {
|
||||
const harness = createHarness({
|
||||
mappers: {
|
||||
ReferenceRealtimeMapper: {
|
||||
...TEST_MAPPER,
|
||||
map: () => ({
|
||||
ok: false as const,
|
||||
code: "MAPPING_INVARIANT_REJECTED" as const,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(harness.event()),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "RECOVERED",
|
||||
reason: "MAPPING_CONTRACT_VIOLATION",
|
||||
},
|
||||
});
|
||||
expect(harness.effects).not.toHaveBeenCalled();
|
||||
expect(harness.recoveryReasons).toEqual([
|
||||
"INITIALIZE",
|
||||
"MAPPING_CONTRACT_VIOLATION",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the prior checkpoint when an effect and its recovery fail", async () => {
|
||||
let recoveryCount = 0;
|
||||
const harness = createHarness({
|
||||
apply: async () => realtimeFailure("APPLY_FAILED", "APPLY"),
|
||||
recover: async () => {
|
||||
recoveryCount += 1;
|
||||
return recoveryCount === 1
|
||||
? realtimeSuccess(snapshotCommit("0"))
|
||||
: realtimeFailure("PROVIDER_UNAVAILABLE", "RECOVER");
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(harness.event()),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "PROVIDER_UNAVAILABLE",
|
||||
operation: "RECOVER",
|
||||
},
|
||||
});
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toMatchObject({
|
||||
lastAppliedSequence: "0",
|
||||
resumeCursor: "cursor-snapshot-0",
|
||||
});
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe(
|
||||
"UNKNOWN",
|
||||
);
|
||||
await harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-00000002",
|
||||
sequence: "2",
|
||||
resumeCursor: "cursor-00000002",
|
||||
}),
|
||||
);
|
||||
expect(harness.effects).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects malformed authority results without advancing a checkpoint", async () => {
|
||||
let recoveryCount = 0;
|
||||
const harness = createHarness({
|
||||
apply: async () =>
|
||||
({
|
||||
ok: true,
|
||||
value: "not-void",
|
||||
}) as unknown as RealtimeResult<void>,
|
||||
recover: async () => {
|
||||
recoveryCount += 1;
|
||||
return recoveryCount === 1
|
||||
? realtimeSuccess(snapshotCommit("0"))
|
||||
: ({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "FORBIDDEN",
|
||||
operation: "APPLY",
|
||||
retryable: false,
|
||||
},
|
||||
} as RealtimeResult<RealtimeRecoveryCommit>);
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(harness.event()),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("PROTOCOL_MISMATCH", "RECOVER"),
|
||||
);
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toMatchObject({
|
||||
lastAppliedSequence: "0",
|
||||
});
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe(
|
||||
"UNKNOWN",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects recovery accessors without invoking them", async () => {
|
||||
let checkpointReads = 0;
|
||||
const accessorCommit = Object.defineProperties(
|
||||
{},
|
||||
{
|
||||
checkpoint: {
|
||||
enumerable: true,
|
||||
get() {
|
||||
checkpointReads += 1;
|
||||
return snapshotCommit("0");
|
||||
},
|
||||
},
|
||||
kind: {
|
||||
enumerable: true,
|
||||
value: "SNAPSHOT_RESET",
|
||||
},
|
||||
},
|
||||
) as RealtimeRecoveryCommit;
|
||||
const harness = createHarness({
|
||||
recover: async () => realtimeSuccess(accessorCommit),
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.coordinator.recover(STREAM_ID, "INITIALIZE"),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("PROTOCOL_MISMATCH", "RECOVER"),
|
||||
);
|
||||
expect(checkpointReads).toBe(0);
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toBeNull();
|
||||
});
|
||||
|
||||
it("commits only the one-shot descriptor snapshot of a recovery proxy", async () => {
|
||||
const descriptorCommit = snapshotCommit("0");
|
||||
const propertyReads: PropertyKey[] = [];
|
||||
const proxyCommit = new Proxy(descriptorCommit, {
|
||||
get(_target, key) {
|
||||
propertyReads.push(key);
|
||||
if (key === "kind") return "SESSION_REBUILD";
|
||||
if (key === "checkpoint") return snapshotCommit("99");
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const harness = createHarness({
|
||||
recover: async () => realtimeSuccess(proxyCommit),
|
||||
});
|
||||
|
||||
const recovered = await harness.coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
);
|
||||
|
||||
expect(recovered).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
lastAppliedSequence: "0",
|
||||
resumeCursor: "cursor-snapshot-0",
|
||||
},
|
||||
});
|
||||
expect(propertyReads).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
Object.freeze({
|
||||
...snapshotCommit("0"),
|
||||
unexpected: true,
|
||||
}),
|
||||
Object.assign(
|
||||
Object.create({ inherited: true }) as Record<string, unknown>,
|
||||
snapshotCommit("0"),
|
||||
),
|
||||
])(
|
||||
"rejects extra and inherited recovery commit shapes",
|
||||
async (commit) => {
|
||||
const harness = createHarness({
|
||||
recover: async () =>
|
||||
realtimeSuccess(commit as RealtimeRecoveryCommit),
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.coordinator.recover(STREAM_ID, "INITIALIZE"),
|
||||
).resolves.toEqual(
|
||||
realtimeFailure("PROTOCOL_MISMATCH", "RECOVER"),
|
||||
);
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("expires effect and recovery commit leases when callbacks settle", async () => {
|
||||
let effectContext: ExternalRealtimeEventContext | undefined;
|
||||
let recoveryRequest: RealtimeRecoveryRequest | undefined;
|
||||
const harness = createHarness({
|
||||
async apply(_profile, _value, context) {
|
||||
effectContext = context;
|
||||
expect(context.isCurrent()).toBe(true);
|
||||
return realtimeSuccess(undefined);
|
||||
},
|
||||
async recover(request) {
|
||||
recoveryRequest = request;
|
||||
expect(request.isCurrent()).toBe(true);
|
||||
return realtimeSuccess(snapshotCommit("0"));
|
||||
},
|
||||
});
|
||||
|
||||
await harness.initialize();
|
||||
expect(recoveryRequest?.isCurrent()).toBe(false);
|
||||
await harness.coordinator.accept(harness.event());
|
||||
expect(effectContext?.isCurrent()).toBe(false);
|
||||
});
|
||||
|
||||
it("fences an in-flight successful effect before cursor commit", async () => {
|
||||
const pending = deferred<RealtimeResult<void>>();
|
||||
let effectContext: ExternalRealtimeEventContext | undefined;
|
||||
const harness = createHarness({
|
||||
apply: async (_profile, _value, context) => {
|
||||
effectContext = context;
|
||||
return pending.promise;
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
const accepted = harness.coordinator.accept(harness.event());
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.effects).toHaveBeenCalledOnce(),
|
||||
);
|
||||
harness.setCurrent(false);
|
||||
expect(effectContext?.isCurrent()).toBe(false);
|
||||
pending.resolve(realtimeSuccess(undefined));
|
||||
|
||||
await expect(accepted).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "DROPPED",
|
||||
reason: "SCOPE_FENCED",
|
||||
},
|
||||
});
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toBeNull();
|
||||
expect(harness.coordinator.inspect(STREAM_ID)).toMatchObject({
|
||||
freshness: "UNKNOWN",
|
||||
dedupeEntries: 0,
|
||||
queuedEvents: 0,
|
||||
awaitingTransportBarrier: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts and quiesces the active effect before queue-overflow recovery", async () => {
|
||||
const pending = deferred<RealtimeResult<void>>();
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
const registry = createTestRealtimeRegistry({
|
||||
limits: {
|
||||
...TEST_LIMITS,
|
||||
maxQueueEvents: 1,
|
||||
},
|
||||
});
|
||||
const harness = createHarness({
|
||||
registry,
|
||||
async apply(_profile, _value, _context, signal) {
|
||||
capturedSignal = signal;
|
||||
return pending.promise;
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
|
||||
const first = harness.coordinator.accept(harness.event());
|
||||
await vi.waitFor(() =>
|
||||
expect(harness.effects).toHaveBeenCalledOnce(),
|
||||
);
|
||||
const overflow = harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-00000002",
|
||||
sequence: "2",
|
||||
resumeCursor: "cursor-00000002",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedSignal?.aborted).toBe(true);
|
||||
expect(harness.recoveryReasons).toEqual(["INITIALIZE"]);
|
||||
pending.resolve(realtimeSuccess(undefined));
|
||||
await expect(first).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { outcome: "DROPPED" },
|
||||
});
|
||||
await expect(overflow).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "RECOVERED",
|
||||
reason: "QUEUE_OVERFLOW",
|
||||
},
|
||||
});
|
||||
expect(harness.recoveryReasons).toEqual([
|
||||
"INITIALIZE",
|
||||
"QUEUE_OVERFLOW",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a current scope mismatch as a protocol violation, not a stale callback", async () => {
|
||||
const harness = createHarness();
|
||||
await harness.initialize();
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(
|
||||
harness.event({ scopeBinding: "other-scope-binding" }),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "RECOVERED",
|
||||
reason: "SCOPE_PROTOCOL_VIOLATION",
|
||||
},
|
||||
});
|
||||
expect(harness.effects).not.toHaveBeenCalled();
|
||||
expect(harness.recoveryReasons).toEqual([
|
||||
"INITIALIZE",
|
||||
"SCOPE_PROTOCOL_VIOLATION",
|
||||
]);
|
||||
});
|
||||
|
||||
it("never claims CURRENT for a SNAPSHOT_ONLY stream without a barrier", async () => {
|
||||
const registry = createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SNAPSHOT_ONLY",
|
||||
snapshotOperationId: "GET_REFERENCE_REALTIME_SNAPSHOT",
|
||||
checkpointCodecId: "ReferenceRealtimeCheckpoint",
|
||||
barrier: "NONE",
|
||||
},
|
||||
delivery: "INVALIDATION_HINT",
|
||||
stateBearing: false,
|
||||
});
|
||||
const harness = createHarness({
|
||||
registry,
|
||||
recover: async () =>
|
||||
realtimeSuccess({
|
||||
kind: "SNAPSHOT_RESET",
|
||||
checkpoint: {
|
||||
recoveryMode: "SNAPSHOT_ONLY",
|
||||
streamEpoch: "stream-epoch-0001",
|
||||
lastAppliedSequence: "0",
|
||||
resumeCursor: null,
|
||||
snapshotRevision: "snapshot-revision-0",
|
||||
},
|
||||
}),
|
||||
});
|
||||
await harness.initialize();
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe("STALE");
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(
|
||||
harness.event({
|
||||
recoveryMode: "SNAPSHOT_ONLY",
|
||||
resumeCursor: null,
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { outcome: "APPLIED" },
|
||||
});
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe("STALE");
|
||||
});
|
||||
|
||||
it("requires an exact transport barrier before promoting a recovered stream to CURRENT", async () => {
|
||||
const harness = createHarness();
|
||||
const recovered = await harness.coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
);
|
||||
expect(recovered.ok).toBe(true);
|
||||
if (!recovered.ok) return;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(harness.coordinator.inspect(STREAM_ID)).toMatchObject({
|
||||
freshness: "STALE",
|
||||
awaitingTransportBarrier: true,
|
||||
});
|
||||
expect(
|
||||
harness.coordinator.confirmTransportBarrier(
|
||||
STREAM_ID,
|
||||
Object.freeze({
|
||||
...recovered.value,
|
||||
}) as typeof recovered.value,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
const publicResumeState =
|
||||
harness.coordinator.getResumeState(STREAM_ID);
|
||||
expect(publicResumeState).not.toBe(recovered.value);
|
||||
expect(
|
||||
harness.coordinator.confirmTransportBarrier(
|
||||
STREAM_ID,
|
||||
publicResumeState as typeof recovered.value,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
expect(
|
||||
harness.coordinator.confirmTransportBarrier(
|
||||
STREAM_ID,
|
||||
recovered.value,
|
||||
),
|
||||
).toEqual(realtimeSuccess(undefined));
|
||||
expect(harness.coordinator.inspect(STREAM_ID)).toMatchObject({
|
||||
freshness: "CURRENT",
|
||||
awaitingTransportBarrier: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks a successful invalidation hint stale until authoritative refresh", async () => {
|
||||
const registry = createTestRealtimeRegistry({
|
||||
recovery: {
|
||||
mode: "SNAPSHOT_ONLY",
|
||||
snapshotOperationId: "GET_REFERENCE_REALTIME_SNAPSHOT",
|
||||
checkpointCodecId: "ReferenceRealtimeCheckpoint",
|
||||
barrier: "CONNECT_BUFFER",
|
||||
},
|
||||
delivery: "INVALIDATION_HINT",
|
||||
stateBearing: false,
|
||||
});
|
||||
const harness = createHarness({
|
||||
registry,
|
||||
recover: async () =>
|
||||
realtimeSuccess({
|
||||
kind: "SNAPSHOT_RESET",
|
||||
checkpoint: {
|
||||
recoveryMode: "SNAPSHOT_ONLY",
|
||||
streamEpoch: "stream-epoch-0001",
|
||||
lastAppliedSequence: "0",
|
||||
resumeCursor: null,
|
||||
snapshotRevision: "snapshot-revision-0",
|
||||
},
|
||||
}),
|
||||
});
|
||||
await harness.initialize();
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe(
|
||||
"CURRENT",
|
||||
);
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(
|
||||
harness.event({
|
||||
recoveryMode: "SNAPSHOT_ONLY",
|
||||
resumeCursor: null,
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { outcome: "APPLIED" },
|
||||
});
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe("STALE");
|
||||
});
|
||||
|
||||
it("recovers instead of evicting and continuing after dedupe capacity", async () => {
|
||||
const harness = createHarness({
|
||||
registry: createTestRealtimeRegistry({
|
||||
limits: {
|
||||
...TEST_LIMITS,
|
||||
maxDedupeEntries: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
await harness.initialize();
|
||||
await harness.coordinator.accept(harness.event());
|
||||
|
||||
await expect(
|
||||
harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "event-00000002",
|
||||
sequence: "2",
|
||||
resumeCursor: "cursor-00000002",
|
||||
}),
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
outcome: "RECOVERED",
|
||||
reason: "DEDUPE_OVERFLOW",
|
||||
},
|
||||
});
|
||||
expect(harness.effects).toHaveBeenCalledTimes(1);
|
||||
expect(harness.coordinator.inspect(STREAM_ID).dedupeEntries).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a regressing same-epoch recovery checkpoint atomically", async () => {
|
||||
let checkpoint = "5";
|
||||
const harness = createHarness({
|
||||
recover: async () => realtimeSuccess(snapshotCommit(checkpoint)),
|
||||
});
|
||||
await harness.initialize();
|
||||
checkpoint = "4";
|
||||
|
||||
await expect(
|
||||
harness.coordinator.recover(STREAM_ID, "CURSOR_EXPIRED"),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toMatchObject({
|
||||
lastAppliedSequence: "5",
|
||||
});
|
||||
});
|
||||
|
||||
it("coalesces recovery, closes idempotently and rejects forged DTOs", async () => {
|
||||
const pending = deferred<RealtimeResult<RealtimeRecoveryCommit>>();
|
||||
const recover = vi.fn(async () => pending.promise);
|
||||
const harness = createHarness({ recover });
|
||||
|
||||
const first = harness.coordinator.recover(STREAM_ID, "INITIALIZE");
|
||||
const second = harness.coordinator.recover(STREAM_ID, "SEQUENCE_GAP");
|
||||
await vi.waitFor(() => expect(recover).toHaveBeenCalledOnce());
|
||||
pending.resolve(realtimeSuccess(snapshotCommit("0")));
|
||||
await expect(first).resolves.toMatchObject({ ok: true });
|
||||
await expect(second).resolves.toMatchObject({ ok: true });
|
||||
|
||||
harness.coordinator.close();
|
||||
harness.coordinator.close();
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toBeNull();
|
||||
await expect(
|
||||
harness.coordinator.accept(
|
||||
Object.freeze({}) as ValidatedRealtimeEventDto,
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "MALFORMED_EVENT" },
|
||||
});
|
||||
await expect(
|
||||
harness.coordinator.accept(harness.event()),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: { outcome: "DROPPED", reason: "CLOSED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the exact shared checkpoint to an event waiting on active recovery", async () => {
|
||||
const pending = deferred<RealtimeResult<RealtimeRecoveryCommit>>();
|
||||
const recover = vi.fn(async () => pending.promise);
|
||||
const harness = createHarness({ recover });
|
||||
|
||||
const recovering = harness.coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
);
|
||||
await vi.waitFor(() => expect(recover).toHaveBeenCalledOnce());
|
||||
const accepting = harness.coordinator.accept(harness.event());
|
||||
pending.resolve(realtimeSuccess(snapshotCommit("0")));
|
||||
|
||||
const recovered = await recovering;
|
||||
const accepted = await accepting;
|
||||
expect(recovered.ok).toBe(true);
|
||||
expect(accepted.ok).toBe(true);
|
||||
if (!recovered.ok || !accepted.ok) return;
|
||||
expect(accepted.value).toMatchObject({
|
||||
outcome: "RECOVERED",
|
||||
reason: "INITIALIZE",
|
||||
});
|
||||
if (accepted.value.outcome !== "RECOVERED") return;
|
||||
expect(accepted.value.resumeState).toBe(recovered.value);
|
||||
});
|
||||
|
||||
it("aborts only an event waiter without cancelling shared recovery", async () => {
|
||||
const pending = deferred<RealtimeResult<RealtimeRecoveryCommit>>();
|
||||
let recoverySignal: AbortSignal | undefined;
|
||||
const harness = createHarness({
|
||||
recover: async (request) => {
|
||||
recoverySignal = request.signal;
|
||||
return pending.promise;
|
||||
},
|
||||
});
|
||||
const recovering = harness.coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
);
|
||||
await vi.waitFor(() => expect(recoverySignal).toBeDefined());
|
||||
const controller = new AbortController();
|
||||
const accepting = harness.coordinator.accept(
|
||||
harness.event(),
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
await expect(accepting).resolves.toEqual(
|
||||
realtimeFailure("ABORTED", "RECEIVE"),
|
||||
);
|
||||
expect(recoverySignal?.aborted).toBe(false);
|
||||
|
||||
pending.resolve(realtimeSuccess(snapshotCommit("0")));
|
||||
await expect(recovering).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("aborts the active recovery authority when closed", async () => {
|
||||
let recoverySignal: AbortSignal | undefined;
|
||||
const harness = createHarness({
|
||||
recover: async (request) => {
|
||||
recoverySignal = request.signal;
|
||||
return new Promise((resolve) => {
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => resolve(realtimeFailure("ABORTED", "RECOVER")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const recovering = harness.coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
);
|
||||
await vi.waitFor(() => expect(recoverySignal).toBeDefined());
|
||||
harness.coordinator.close();
|
||||
|
||||
expect(recoverySignal?.aborted).toBe(true);
|
||||
await expect(recovering).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "CLOSED", operation: "RECOVER" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not commit a non-cooperative recovery after caller cancellation", async () => {
|
||||
const pending = deferred<RealtimeResult<RealtimeRecoveryCommit>>();
|
||||
let recoveryRequest: RealtimeRecoveryRequest | undefined;
|
||||
const harness = createHarness({
|
||||
recover: async (request) => {
|
||||
recoveryRequest = request;
|
||||
return pending.promise;
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const recovering = harness.coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
controller.signal,
|
||||
);
|
||||
await vi.waitFor(() => expect(recoveryRequest).toBeDefined());
|
||||
|
||||
controller.abort();
|
||||
expect(recoveryRequest?.isCurrent()).toBe(false);
|
||||
pending.resolve(realtimeSuccess(snapshotCommit("0")));
|
||||
|
||||
await expect(recovering).resolves.toEqual(
|
||||
realtimeFailure("ABORTED", "RECOVER"),
|
||||
);
|
||||
expect(recoveryRequest?.signal.aborted).toBe(true);
|
||||
expect(recoveryRequest?.isCurrent()).toBe(false);
|
||||
expect(harness.coordinator.getResumeState(STREAM_ID)).toBeNull();
|
||||
expect(harness.coordinator.inspect(STREAM_ID).freshness).toBe(
|
||||
"UNKNOWN",
|
||||
);
|
||||
});
|
||||
|
||||
it("emits only closed redacted observations and ignores sink failure", async () => {
|
||||
const observations: RealtimeObservation[] = [];
|
||||
const harness = createHarness({
|
||||
observe(observation) {
|
||||
observations.push(observation);
|
||||
throw new Error("diagnostic sink unavailable");
|
||||
},
|
||||
});
|
||||
await harness.initialize();
|
||||
await harness.coordinator.accept(
|
||||
harness.event({
|
||||
eventId: "sensitive-event-id",
|
||||
resumeCursor: "sensitive-cursor",
|
||||
payload: { value: "sensitive-payload" },
|
||||
}),
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(observations);
|
||||
expect(serialized).not.toContain("sensitive-event-id");
|
||||
expect(serialized).not.toContain("sensitive-cursor");
|
||||
expect(serialized).not.toContain("sensitive-payload");
|
||||
expect(serialized).not.toContain("scope-binding-0001");
|
||||
expect(observations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
operation: "RECOVER",
|
||||
outcome: "RECOVERED",
|
||||
reason: "INITIALIZE",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
operation: "APPLY",
|
||||
outcome: "APPLIED",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
type HarnessOptions = Readonly<{
|
||||
registry?: RealtimePolicyRegistry;
|
||||
mappers?: typeof TEST_MAPPERS | Readonly<Record<string, typeof TEST_MAPPER>>;
|
||||
apply?: RealtimeEventAuthority["effects"]["apply"];
|
||||
recover?: (
|
||||
request: RealtimeRecoveryRequest,
|
||||
) => Promise<RealtimeResult<RealtimeRecoveryCommit>>;
|
||||
observe?: (observation: RealtimeObservation) => void;
|
||||
}>;
|
||||
|
||||
function createHarness(options: HarnessOptions = {}) {
|
||||
const registry = options.registry ?? createTestRealtimeRegistry();
|
||||
const codec = createTestRealtimeCodec(registry);
|
||||
let current = true;
|
||||
const recoveryReasons: string[] = [];
|
||||
let defaultRecoveryCount = 0;
|
||||
const effects = vi.fn(
|
||||
options.apply ??
|
||||
(async () => realtimeSuccess(undefined)),
|
||||
);
|
||||
const recover = vi.fn(
|
||||
options.recover ??
|
||||
(async (request: RealtimeRecoveryRequest) => {
|
||||
recoveryReasons.push(request.reason);
|
||||
defaultRecoveryCount += 1;
|
||||
return realtimeSuccess(
|
||||
snapshotCommit(
|
||||
"0",
|
||||
`stream-epoch-${String(defaultRecoveryCount).padStart(4, "0")}`,
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
const authority: RealtimeEventAuthority = {
|
||||
effects: { apply: effects },
|
||||
recovery: {
|
||||
async recover(request) {
|
||||
if (options.recover) recoveryReasons.push(request.reason);
|
||||
return recover(request);
|
||||
},
|
||||
},
|
||||
};
|
||||
const coordinator = createRealtimeStreamCoordinator({
|
||||
registry,
|
||||
mappers: options.mappers ?? TEST_MAPPERS,
|
||||
authority,
|
||||
scope: {
|
||||
generation: 7,
|
||||
scopeBinding: "scope-binding-0001",
|
||||
isCurrent: () => current,
|
||||
},
|
||||
now: () => 10_000,
|
||||
observe: options.observe,
|
||||
});
|
||||
|
||||
return {
|
||||
coordinator,
|
||||
effects,
|
||||
recoveryReasons,
|
||||
event(overrides: EventOverrides = {}) {
|
||||
const decoded = codec.decode(realtimeEventJson(overrides));
|
||||
if (!decoded.ok) {
|
||||
throw new Error(`test event failed: ${decoded.error.kind}`);
|
||||
}
|
||||
return decoded.value;
|
||||
},
|
||||
async initialize() {
|
||||
const result = await coordinator.recover(
|
||||
STREAM_ID,
|
||||
"INITIALIZE",
|
||||
);
|
||||
await Promise.resolve();
|
||||
if (
|
||||
result.ok &&
|
||||
coordinator.inspect(STREAM_ID).awaitingTransportBarrier
|
||||
) {
|
||||
const confirmed = coordinator.confirmTransportBarrier(
|
||||
STREAM_ID,
|
||||
result.value,
|
||||
);
|
||||
if (!confirmed.ok) {
|
||||
throw new Error("test transport barrier failed");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
setCurrent(value: boolean) {
|
||||
current = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotCommit(
|
||||
sequence: string,
|
||||
streamEpoch = "stream-epoch-0001",
|
||||
): RealtimeRecoveryCommit {
|
||||
return Object.freeze({
|
||||
kind: "SNAPSHOT_RESET",
|
||||
checkpoint: Object.freeze({
|
||||
recoveryMode: "CURSOR",
|
||||
streamEpoch,
|
||||
lastAppliedSequence: sequence,
|
||||
resumeCursor: `cursor-snapshot-${sequence}`,
|
||||
snapshotRevision: `snapshot-revision-${sequence}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function deferred<Value>() {
|
||||
let resolve!: (value: Value) => void;
|
||||
const promise = new Promise<Value>((next) => {
|
||||
resolve = next;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,266 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
REALTIME_WEBSOCKET_PROTOCOL,
|
||||
decodeWebSocketServerFrame,
|
||||
encodeWebSocketClientFrame,
|
||||
nextUnsignedSequence,
|
||||
type WebSocketAdvertisedLimits,
|
||||
type WebSocketSubscribeFrame,
|
||||
} from "../../../src/adapters/realtime/websocket/websocket-protocol.ts";
|
||||
|
||||
const LIMITS: WebSocketAdvertisedLimits = Object.freeze({
|
||||
maxFrameBytes: 65_536,
|
||||
maxSubscriptions: 32,
|
||||
maxInboundQueueCount: 256,
|
||||
maxInboundQueueBytes: 4 * 1_024 * 1_024,
|
||||
maxOutboundQueueCount: 128,
|
||||
maxOutboundQueueBytes: 256 * 1_024,
|
||||
maxBufferedAmountBytes: 256 * 1_024,
|
||||
maxEventsPerSecond: 128,
|
||||
});
|
||||
|
||||
function encode(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
describe("realtime WebSocket protocol", () => {
|
||||
it("decodes and freezes an exact WELCOME frame", () => {
|
||||
const result = decodeWebSocketServerFrame(
|
||||
encode({
|
||||
type: "WELCOME",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
connectionId: "connection.0001",
|
||||
heartbeatMs: 15_000,
|
||||
heartbeatAckTimeoutMs: 5_000,
|
||||
limits: LIMITS,
|
||||
}),
|
||||
65_536,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
type: "WELCOME",
|
||||
limits: { maxSubscriptions: 32 },
|
||||
},
|
||||
});
|
||||
if (!result.ok) throw new Error("Expected WELCOME to decode.");
|
||||
if (result.value.type !== "WELCOME") {
|
||||
throw new Error("Expected the WELCOME discriminant.");
|
||||
}
|
||||
expect(Object.isFrozen(result.value)).toBe(true);
|
||||
expect(Object.isFrozen(result.value.limits)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects extra keys, unknown frames, wrong versions and binary data", () => {
|
||||
const welcome = {
|
||||
type: "WELCOME",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
connectionId: "connection.0001",
|
||||
heartbeatMs: 15_000,
|
||||
heartbeatAckTimeoutMs: 5_000,
|
||||
limits: LIMITS,
|
||||
};
|
||||
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({ ...welcome, credential: "must-not-cross" }),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({ ...welcome, protocol: "realtime.v2" }),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "PROTOCOL_MISMATCH" },
|
||||
});
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({
|
||||
type: "COMMAND",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
}),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "UNKNOWN_FRAME" },
|
||||
});
|
||||
expect(
|
||||
decodeWebSocketServerFrame(new Uint8Array([1, 2, 3]), 65_536),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "BINARY_FRAME" },
|
||||
});
|
||||
|
||||
const duplicateTopLevel = encode(welcome).replace(
|
||||
'{"type":"WELCOME",',
|
||||
'{"\\u0074ype":"WELCOME","type":"WELCOME",',
|
||||
);
|
||||
expect(
|
||||
decodeWebSocketServerFrame(duplicateTopLevel, 65_536),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
const duplicateNested = encode({
|
||||
type: "EVENT",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
subscriptionId: "subscription.0001",
|
||||
envelope: { streamId: "orders.v1" },
|
||||
}).replace(
|
||||
'"streamId":"orders.v1"',
|
||||
'"streamId":"orders.v1","\\u0073treamId":"shadowed"',
|
||||
);
|
||||
expect(
|
||||
decodeWebSocketServerFrame(duplicateNested, 65_536),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
});
|
||||
|
||||
it("enforces frame bytes and canonical uint64 sequences", () => {
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({
|
||||
type: "SUBSCRIBED",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
subscriptionId: "subscription.0001",
|
||||
streamEpoch: "stream-epoch.0001",
|
||||
acceptedCursor: "cursor.0001",
|
||||
nextExpectedSequence: "01",
|
||||
}),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({
|
||||
type: "HEARTBEAT_ACK",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
nonce: "nonce.0001",
|
||||
}),
|
||||
8,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "FRAME_TOO_LARGE" },
|
||||
});
|
||||
expect(nextUnsignedSequence("0")).toBe("1");
|
||||
expect(nextUnsignedSequence("18446744073709551614")).toBe(
|
||||
"18446744073709551615",
|
||||
);
|
||||
expect(nextUnsignedSequence("18446744073709551615")).toBeNull();
|
||||
expect(nextUnsignedSequence("01")).toBeNull();
|
||||
});
|
||||
|
||||
it("decodes only an exact UNSUBSCRIBED acknowledgement", () => {
|
||||
const acknowledgement = {
|
||||
type: "UNSUBSCRIBED",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
subscriptionId: "subscription.0001",
|
||||
};
|
||||
const decoded = decodeWebSocketServerFrame(
|
||||
encode(acknowledgement),
|
||||
65_536,
|
||||
);
|
||||
|
||||
expect(decoded).toMatchObject({
|
||||
ok: true,
|
||||
value: acknowledgement,
|
||||
});
|
||||
if (!decoded.ok) {
|
||||
throw new Error("Expected UNSUBSCRIBED to decode.");
|
||||
}
|
||||
expect(Object.isFrozen(decoded.value)).toBe(true);
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({ ...acknowledgement, released: true }),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
encode({ ...acknowledgement, subscriptionId: "" }),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects deeply nested or structurally excessive event envelopes without recursion", () => {
|
||||
let nested: unknown = "leaf";
|
||||
for (let depth = 0; depth < 40; depth += 1) {
|
||||
nested = [nested];
|
||||
}
|
||||
const event = (envelope: unknown) =>
|
||||
encode({
|
||||
type: "EVENT",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
subscriptionId: "subscription.0001",
|
||||
envelope,
|
||||
});
|
||||
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
event({ payload: nested }),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
expect(
|
||||
decodeWebSocketServerFrame(
|
||||
event({
|
||||
payload: Array.from({ length: 4_097 }, () => ({})),
|
||||
}),
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
});
|
||||
|
||||
it("encodes only closed client frames without leaking arbitrary commands", () => {
|
||||
const frame: WebSocketSubscribeFrame = {
|
||||
type: "SUBSCRIBE",
|
||||
protocol: REALTIME_WEBSOCKET_PROTOCOL,
|
||||
subscriptionId: "subscription.0001",
|
||||
streamId: "orders.v1",
|
||||
cursor: "cursor.0001",
|
||||
scopeBinding: "scope-binding.0001",
|
||||
};
|
||||
const result = encodeWebSocketClientFrame(frame, 65_536);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw new Error("Expected SUBSCRIBE to encode.");
|
||||
expect(JSON.parse(result.value)).toEqual(frame);
|
||||
expect(
|
||||
encodeWebSocketClientFrame(
|
||||
{ ...frame, payload: "arbitrary" } as WebSocketSubscribeFrame,
|
||||
65_536,
|
||||
),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
error: { code: "MALFORMED_FRAME" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
registrySnapshotDigest,
|
||||
validateBreakingEvidence,
|
||||
verifyRegistryBaselineApproval,
|
||||
} from "../../scripts/lib/registry-compatibility.mjs";
|
||||
} from "../../scripts/lib/registry-compatibility.ts";
|
||||
|
||||
function snapshot(
|
||||
rows: Readonly<Record<string, Readonly<Record<string, unknown>>>>,
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type RegistryDefinition = Readonly<{
|
||||
registryId: string;
|
||||
owner: string;
|
||||
requiredFields: string[];
|
||||
fieldTypes: Record<string, string>;
|
||||
}>;
|
||||
|
||||
describe("registry governance manifest", () => {
|
||||
it("declares ten typed, single-owner executable registries", async () => {
|
||||
const governance = JSON.parse(
|
||||
await readFile("config/contracts/registry-governance.json", "utf8"),
|
||||
);
|
||||
const registries =
|
||||
/** @type {Array<{
|
||||
* registryId: string,
|
||||
* owner: string,
|
||||
* requiredFields: string[],
|
||||
* fieldTypes: Record<string, string>
|
||||
* }>} */ (
|
||||
governance.registries
|
||||
);
|
||||
const registries = governance.registries as RegistryDefinition[];
|
||||
expect(governance.registries).toHaveLength(10);
|
||||
expect(new Set(registries.map((entry) => entry.registryId)).size).toBe(
|
||||
10,
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
compareReleaseToRuntime,
|
||||
RELEASE_TOKEN_REGISTRY,
|
||||
} from "../../src/contracts/release-tokens.js";
|
||||
} from "../../src/contracts/release-tokens.ts";
|
||||
|
||||
describe("release coherence", () => {
|
||||
it("owns all eight release tokens and keeps builtAt diagnostic-only", () => {
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildRequestTarget } from "../../src/adapters/http/request-builder.js";
|
||||
import type { ApiOperation } from "../../src/contracts/api-operations.js";
|
||||
import { buildRequestTarget } from "../../src/adapters/http/request-builder.ts";
|
||||
import type { ApiOperation } from "../../src/contracts/api-operations.ts";
|
||||
|
||||
const operation: ApiOperation = {
|
||||
method: "GET",
|
||||
@@ -34,7 +34,7 @@ describe("deterministic HTTP request target", () => {
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) return;
|
||||
expect(result.url.href).toBe(
|
||||
"https://api.test/api/resources/folder%2Fitem?cursor=next+page&limit=20&tags=beta&tags=alpha",
|
||||
"https://api.test/base/api/resources/folder%2Fitem?cursor=next+page&limit=20&tags=beta&tags=alpha",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -50,5 +50,16 @@ describe("deterministic HTTP request target", () => {
|
||||
{ nested: { secret: true } },
|
||||
),
|
||||
).toEqual({ success: false, code: "SEARCH_PARAMETER_INVALID" });
|
||||
expect(
|
||||
buildRequestTarget(
|
||||
"https://api.test",
|
||||
operation,
|
||||
{ resourceId: "one", unexpected: "two" },
|
||||
{},
|
||||
),
|
||||
).toEqual({ success: false, code: "PATH_PARAMETER_UNEXPECTED" });
|
||||
expect(
|
||||
buildRequestTarget("http://api.test", operation, { resourceId: "one" }),
|
||||
).toEqual({ success: false, code: "BASE_URL_INVALID" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createRestProviderProfile,
|
||||
resolveRestSecurityProfiles,
|
||||
validateRestProfileBindings,
|
||||
} from "../../src/contracts/rest-profiles.ts";
|
||||
import { REFERENCE_FEATURE_CONTRACT } from "../../src/features/reference-feature/contracts/reference-feature-contract.ts";
|
||||
|
||||
describe("REST provider/auth/CSRF profiles", () => {
|
||||
it("preserves the provider prefix and rejects unsafe endpoint forms", () => {
|
||||
expect(
|
||||
createRestProviderProfile(
|
||||
"PRIMARY_API",
|
||||
"https://api.test/base/",
|
||||
["omit"],
|
||||
),
|
||||
).toMatchObject({
|
||||
providerId: "PRIMARY_API",
|
||||
baseUrl: "https://api.test/base/",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
});
|
||||
expect(() =>
|
||||
createRestProviderProfile(
|
||||
"PRIMARY_API",
|
||||
"https://user:password@api.test/base/",
|
||||
),
|
||||
).toThrow("Invalid REST provider profile");
|
||||
expect(() =>
|
||||
createRestProviderProfile("PRIMARY_API", "http://api.test/base/"),
|
||||
).toThrow("Invalid REST provider profile");
|
||||
});
|
||||
|
||||
it("resolves bearer auth to omit credentials and no CSRF", () => {
|
||||
const operation =
|
||||
REFERENCE_FEATURE_CONTRACT.apiOperations.CREATE_REFERENCE_RESOURCE;
|
||||
const resolved = resolveRestSecurityProfiles(
|
||||
operation,
|
||||
createRestProviderProfile("PRIMARY_API", "https://api.test", ["omit"]),
|
||||
);
|
||||
expect(resolved).toMatchObject({
|
||||
auth: {
|
||||
transport: "BEARER_HEADER",
|
||||
credentials: "omit",
|
||||
allowedCredentialHeaders: ["authorization"],
|
||||
},
|
||||
csrf: { mode: "NONE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("validates every installed reference profile binding as a set", () => {
|
||||
expect(
|
||||
validateRestProfileBindings(
|
||||
REFERENCE_FEATURE_CONTRACT.apiOperations,
|
||||
{ PRIMARY_API: ["omit"] },
|
||||
),
|
||||
).toBe(true);
|
||||
expect(() =>
|
||||
validateRestProfileBindings(
|
||||
REFERENCE_FEATURE_CONTRACT.apiOperations,
|
||||
{},
|
||||
),
|
||||
).toThrow("Unregistered REST provider binding");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ResumableUploadCheckpoint } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
||||
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
||||
import {
|
||||
createIndexedDbResumableUploadCheckpointRuntime,
|
||||
createIndexedDbResumableUploadCheckpointStore,
|
||||
uploadCheckpointDatabaseName,
|
||||
} from "../../src/adapters/browser-transfer/resumable-upload/indexeddb-checkpoint-store.ts";
|
||||
import { MemoryIndexedDbFactory } from "../helpers/memory-indexeddb.ts";
|
||||
|
||||
const scope = Object.freeze({
|
||||
authorityToken: "authority_token_01",
|
||||
namespaceToken: "namespace_token_01",
|
||||
partitionToken: "partition_token_01",
|
||||
});
|
||||
|
||||
function checkpoint(
|
||||
revision: number,
|
||||
overrides: Partial<ResumableUploadCheckpoint> = {},
|
||||
): ResumableUploadCheckpoint {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
revision,
|
||||
state: "ACTIVE",
|
||||
uploadKey: "upload_key_01",
|
||||
requestBindingSha256: "a".repeat(64),
|
||||
fingerprint: {
|
||||
algorithm: "SHA-256-PARTS-V1",
|
||||
digestHex: "b".repeat(64),
|
||||
byteLength: 4,
|
||||
partSizeBytes: 4,
|
||||
partCount: 1,
|
||||
},
|
||||
sessionId: "session_01",
|
||||
sessionExpiresAtEpochMs: 5_000,
|
||||
sessionMaxConcurrency: 1,
|
||||
acceptedParts: [],
|
||||
updatedAtEpochMs: 1_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deletingFactory(
|
||||
memory: MemoryIndexedDbFactory,
|
||||
mode: "SUCCESS" | "BLOCKED",
|
||||
): IDBFactory {
|
||||
return {
|
||||
open: memory.factory.open.bind(memory.factory),
|
||||
cmp: memory.factory.cmp.bind(memory.factory),
|
||||
deleteDatabase: () => {
|
||||
const request = {
|
||||
result: undefined,
|
||||
error: null,
|
||||
transaction: null,
|
||||
source: null,
|
||||
readyState: "pending",
|
||||
onsuccess: null,
|
||||
onerror: null,
|
||||
onblocked: null,
|
||||
onupgradeneeded: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
dispatchEvent: () => true,
|
||||
} as unknown as IDBOpenDBRequest;
|
||||
queueMicrotask(() => {
|
||||
if (mode === "SUCCESS") {
|
||||
request.onsuccess?.(new Event("success"));
|
||||
} else {
|
||||
request.onblocked?.({
|
||||
oldVersion: 1,
|
||||
newVersion: null,
|
||||
} as IDBVersionChangeEvent);
|
||||
}
|
||||
});
|
||||
return request;
|
||||
},
|
||||
databases: async () => [],
|
||||
} as IDBFactory;
|
||||
}
|
||||
|
||||
describe("IndexedDB resumable upload checkpoint", () => {
|
||||
it("length-prefixes scope tuples so delimiter placement cannot collide", () => {
|
||||
const first = uploadCheckpointDatabaseName({
|
||||
authorityToken: "aaaaaaaa-bbbbbbbb",
|
||||
namespaceToken: "cccccccc",
|
||||
partitionToken: "dddddddd",
|
||||
});
|
||||
const second = uploadCheckpointDatabaseName({
|
||||
authorityToken: "aaaaaaaa",
|
||||
namespaceToken: "bbbbbbbb-cccccccc",
|
||||
partitionToken: "dddddddd",
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first).toContain("17:aaaaaaaa-bbbbbbbb");
|
||||
expect(second).toContain("8:aaaaaaaa");
|
||||
});
|
||||
|
||||
it("commits CAS only at transaction completion and rejects stale revisions", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const store = createIndexedDbResumableUploadCheckpointStore({
|
||||
scope,
|
||||
factory: memory.factory,
|
||||
});
|
||||
const first = checkpoint(1);
|
||||
expect(
|
||||
await store.compareAndSwap({
|
||||
expectedRevision: null,
|
||||
checkpoint: first,
|
||||
}),
|
||||
).toEqual({ ok: true, value: first });
|
||||
expect(await store.read(first.uploadKey)).toEqual({
|
||||
ok: true,
|
||||
value: first,
|
||||
});
|
||||
|
||||
memory.failNextWriteCommit(
|
||||
new DOMException("commit failed", "UnknownError"),
|
||||
);
|
||||
const failed = await store.compareAndSwap({
|
||||
expectedRevision: 1,
|
||||
checkpoint: checkpoint(2, { state: "ABORT_PENDING" }),
|
||||
});
|
||||
expect(failed).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE" },
|
||||
});
|
||||
expect(await store.read(first.uploadKey)).toEqual({
|
||||
ok: true,
|
||||
value: first,
|
||||
});
|
||||
|
||||
expect(
|
||||
await store.compareAndSwap({
|
||||
expectedRevision: 2,
|
||||
checkpoint: checkpoint(3),
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "CONFLICT", recovery: "RECONCILE" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unknown persisted fields so URLs and credentials cannot enter a checkpoint", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const store = createIndexedDbResumableUploadCheckpointStore({
|
||||
scope,
|
||||
factory: memory.factory,
|
||||
});
|
||||
const smuggled = {
|
||||
...checkpoint(1),
|
||||
signedUrl: "https://object.invalid/secret?signature=value",
|
||||
} as unknown as ResumableUploadCheckpoint;
|
||||
expect(
|
||||
await store.compareAndSwap({
|
||||
expectedRevision: null,
|
||||
checkpoint: smuggled,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
expect(await store.read("upload_key_01")).toEqual({
|
||||
ok: true,
|
||||
value: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the bound partition before successful lifecycle deletion", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
||||
scope,
|
||||
factory: deletingFactory(memory, "SUCCESS"),
|
||||
blockedTimeoutMs: 10,
|
||||
});
|
||||
expect(
|
||||
await runtime.store.compareAndSwap({
|
||||
expectedRevision: null,
|
||||
checkpoint: checkpoint(1),
|
||||
}),
|
||||
).toMatchObject({ ok: true });
|
||||
expect(await runtime.admin.deletePartition()).toEqual({
|
||||
ok: true,
|
||||
value: { state: "DELETED" },
|
||||
});
|
||||
expect(await runtime.store.read("upload_key_01")).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", recovery: "RESUME" },
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds a partition deletion blocked by another browser context", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
||||
scope,
|
||||
factory: deletingFactory(memory, "BLOCKED"),
|
||||
blockedTimeoutMs: 1,
|
||||
});
|
||||
expect(await runtime.admin.deletePartition()).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "BLOCKED",
|
||||
retryable: true,
|
||||
recovery: "RELOAD_OTHER_CONTEXTS",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("never reports a false abort after irreversible deleteDatabase dispatch", async () => {
|
||||
const memory = new MemoryIndexedDbFactory();
|
||||
const runtime = createIndexedDbResumableUploadCheckpointRuntime({
|
||||
scope,
|
||||
factory: deletingFactory(memory, "SUCCESS"),
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const deletion = runtime.admin.deletePartition(controller.signal);
|
||||
controller.abort();
|
||||
expect(await deletion).toEqual({
|
||||
ok: true,
|
||||
value: { state: "DELETED" },
|
||||
});
|
||||
|
||||
const preAborted = new AbortController();
|
||||
preAborted.abort();
|
||||
const second = createIndexedDbResumableUploadCheckpointRuntime({
|
||||
scope: { ...scope, partitionToken: "partition_token_02" },
|
||||
factory: deletingFactory(new MemoryIndexedDbFactory(), "SUCCESS"),
|
||||
});
|
||||
expect(
|
||||
await second.admin.deletePartition(preAborted.signal),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createResumableUploadFetchJsonTransport,
|
||||
type ResumableUploadFetchTransportDependencies,
|
||||
} from "../../src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts";
|
||||
import type {
|
||||
ResumableUploadControlOperation,
|
||||
ResumableUploadJsonTransport,
|
||||
} from "../../src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts";
|
||||
import { createResumableUploadWebLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
|
||||
|
||||
const API_ORIGIN = "https://api.example";
|
||||
const ENDPOINTS = Object.freeze({
|
||||
CREATE_SESSION: `${API_ORIGIN}/uploads/create`,
|
||||
GET_STATUS: `${API_ORIGIN}/uploads/status`,
|
||||
COMPLETE: `${API_ORIGIN}/uploads/complete`,
|
||||
ABORT: `${API_ORIGIN}/uploads/abort`,
|
||||
});
|
||||
const activeSignal = new AbortController().signal;
|
||||
|
||||
function responseAt(
|
||||
url: string,
|
||||
body: BodyInit | null,
|
||||
init: ResponseInit,
|
||||
): Response {
|
||||
const response = new Response(body, init);
|
||||
Object.defineProperty(response, "url", {
|
||||
configurable: true,
|
||||
value: url,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
function jsonResponseAt(
|
||||
url: string,
|
||||
value: unknown,
|
||||
status = 200,
|
||||
headers: Readonly<Record<string, string>> = {},
|
||||
): Response {
|
||||
const body = JSON.stringify(value);
|
||||
return responseAt(url, body, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": String(new TextEncoder().encode(body).byteLength),
|
||||
...headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createTransport(
|
||||
fetcher: typeof fetch,
|
||||
overrides: Partial<ResumableUploadFetchTransportDependencies> = {},
|
||||
): ResumableUploadJsonTransport {
|
||||
return createResumableUploadFetchJsonTransport({
|
||||
endpoints: ENDPOINTS,
|
||||
allowedOrigins: [API_ORIGIN],
|
||||
credentials: "same-origin",
|
||||
fetcher,
|
||||
timeoutMs: 100,
|
||||
maxRequestBytes: 4_096,
|
||||
maxResponseBytes: 4_096,
|
||||
maxRetryAfterMs: 3_000,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resumable upload fetch transport", () => {
|
||||
it("uses a closed operation map and fixed production fetch policy", async () => {
|
||||
let receivedUrl = "";
|
||||
let receivedInit: RequestInit | undefined;
|
||||
const fetcher = vi.fn(
|
||||
async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
receivedUrl = String(input);
|
||||
receivedInit = init;
|
||||
return jsonResponseAt(ENDPOINTS.GET_STATUS, { state: "ok" });
|
||||
},
|
||||
) as unknown as typeof fetch;
|
||||
const transport = createTransport(fetcher, {
|
||||
requestHeaders: [{ name: "x-runtime-version", value: "v1" }],
|
||||
});
|
||||
|
||||
const result = await transport.execute({
|
||||
operation: "GET_STATUS",
|
||||
body: { sessionId: "session_01" },
|
||||
signal: activeSignal,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: { state: "ok" },
|
||||
});
|
||||
expect(receivedUrl).toBe(ENDPOINTS.GET_STATUS);
|
||||
expect(receivedInit).toMatchObject({
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
cache: "no-store",
|
||||
mode: "cors",
|
||||
});
|
||||
const headers = new Headers(receivedInit?.headers);
|
||||
expect(headers.get("accept")).toBe("application/json");
|
||||
expect(headers.get("content-type")).toBe(
|
||||
"application/json; charset=utf-8",
|
||||
);
|
||||
expect(headers.get("x-runtime-version")).toBe("v1");
|
||||
});
|
||||
|
||||
it("rejects a runtime operation outside the allowlist before fetch", async () => {
|
||||
const fetcher = vi.fn();
|
||||
const transport = createTransport(
|
||||
fetcher as unknown as typeof fetch,
|
||||
);
|
||||
const execute = transport.execute as (
|
||||
input: Readonly<{
|
||||
operation: string;
|
||||
body: Readonly<Record<string, unknown>>;
|
||||
signal: AbortSignal;
|
||||
}>,
|
||||
) => ReturnType<ResumableUploadJsonTransport["execute"]>;
|
||||
|
||||
const result = await execute({
|
||||
operation: "DELETE_EVERYTHING",
|
||||
body: {},
|
||||
signal: activeSignal,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps timeout active while a response body is stalled and cancels it", async () => {
|
||||
const cancel = vi.fn();
|
||||
const stalled = new ReadableStream<Uint8Array>({
|
||||
cancel,
|
||||
});
|
||||
const fetcher = vi.fn(async () =>
|
||||
responseAt(ENDPOINTS.GET_STATUS, stalled, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
const transport = createTransport(fetcher, { timeoutMs: 5 });
|
||||
|
||||
const result = await transport.execute({
|
||||
operation: "GET_STATUS",
|
||||
body: {},
|
||||
signal: activeSignal,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAVAILABLE",
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
},
|
||||
});
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates parent abort during a stalled body and cancels promptly", async () => {
|
||||
const cancel = vi.fn();
|
||||
const stalled = new ReadableStream<Uint8Array>({
|
||||
cancel,
|
||||
});
|
||||
const fetcher = vi.fn(async () =>
|
||||
responseAt(ENDPOINTS.GET_STATUS, stalled, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
const transport = createTransport(fetcher, { timeoutMs: 1_000 });
|
||||
const controller = new AbortController();
|
||||
|
||||
const pending = transport.execute({
|
||||
operation: "GET_STATUS",
|
||||
body: {},
|
||||
signal: controller.signal,
|
||||
});
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
const result = await pending;
|
||||
await Promise.resolve();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED", retryable: false },
|
||||
});
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("requires Content-Length to match the bytes actually consumed", async () => {
|
||||
const body = JSON.stringify({ state: "ok" });
|
||||
const fetcher = vi.fn(async () =>
|
||||
responseAt(ENDPOINTS.GET_STATUS, body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": String(body.length + 1),
|
||||
},
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const result = await createTransport(fetcher).execute({
|
||||
operation: "GET_STATUS",
|
||||
body: {},
|
||||
signal: activeSignal,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INTEGRITY_FAILED",
|
||||
recovery: "RECONCILE",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds Retry-After and cancels an unconsumed error body", async () => {
|
||||
const cancel = vi.fn();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1]));
|
||||
},
|
||||
cancel,
|
||||
});
|
||||
const fetcher = vi.fn(async () =>
|
||||
responseAt(ENDPOINTS.GET_STATUS, body, {
|
||||
status: 429,
|
||||
headers: { "retry-after": "2" },
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const result = await createTransport(fetcher).execute({
|
||||
operation: "GET_STATUS",
|
||||
body: {},
|
||||
signal: activeSignal,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAVAILABLE",
|
||||
retryable: true,
|
||||
retryAfterMs: 2_000,
|
||||
},
|
||||
});
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([500, 599])(
|
||||
"classifies HTTP %s as retryable; runtime attempt limits remain authoritative",
|
||||
async (status) => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
jsonResponseAt(
|
||||
ENDPOINTS.GET_STATUS,
|
||||
{ error: "closed" },
|
||||
status,
|
||||
),
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const result = await createTransport(fetcher).execute({
|
||||
operation: "GET_STATUS",
|
||||
body: {},
|
||||
signal: activeSignal,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAVAILABLE",
|
||||
retryable: true,
|
||||
recovery: "RESUME",
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("cancels bodies rejected by URL and content-type policy", async () => {
|
||||
const urlCancel = vi.fn();
|
||||
const typeCancel = vi.fn();
|
||||
const responses = [
|
||||
responseAt(
|
||||
`${API_ORIGIN}/unexpected`,
|
||||
new ReadableStream<Uint8Array>({ cancel: urlCancel }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
),
|
||||
responseAt(
|
||||
ENDPOINTS.GET_STATUS,
|
||||
new ReadableStream<Uint8Array>({ cancel: typeCancel }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html" },
|
||||
},
|
||||
),
|
||||
];
|
||||
const fetcher = vi.fn(async () => responses.shift()!) as unknown as typeof fetch;
|
||||
const transport = createTransport(fetcher);
|
||||
|
||||
const wrongUrl = await transport.execute({
|
||||
operation: "GET_STATUS",
|
||||
body: {},
|
||||
signal: activeSignal,
|
||||
});
|
||||
const wrongType = await transport.execute({
|
||||
operation: "GET_STATUS",
|
||||
body: {},
|
||||
signal: activeSignal,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(wrongUrl).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "POLICY_REJECTED" },
|
||||
});
|
||||
expect(wrongType).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "CORRUPT_DATA" },
|
||||
});
|
||||
expect(urlCancel).toHaveBeenCalledTimes(1);
|
||||
expect(typeCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"set-cookie",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
])("rejects composition headers that can alter authority: %s", (name) => {
|
||||
const fetcher = vi.fn() as unknown as typeof fetch;
|
||||
expect(() =>
|
||||
createTransport(fetcher, {
|
||||
requestHeaders: [{ name, value: "forbidden" }],
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
|
||||
it("rejects unknown expected-success operation keys", () => {
|
||||
const fetcher = vi.fn() as unknown as typeof fetch;
|
||||
expect(() =>
|
||||
createTransport(fetcher, {
|
||||
expectedSuccessStatuses: {
|
||||
DELETE_EVERYTHING: 204,
|
||||
} as unknown as Partial<
|
||||
Readonly<Record<ResumableUploadControlOperation, number>>
|
||||
>,
|
||||
}),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumable upload Web Lock", () => {
|
||||
it("uses one exclusive opaque lock name and serializes mutations", async () => {
|
||||
const requests: Array<
|
||||
Readonly<{
|
||||
name: string;
|
||||
options: Readonly<{
|
||||
mode: "exclusive";
|
||||
signal?: AbortSignal;
|
||||
}>;
|
||||
}>
|
||||
> = [];
|
||||
let queue = Promise.resolve();
|
||||
const manager = {
|
||||
request<Value>(
|
||||
name: string,
|
||||
options: Readonly<{
|
||||
mode: "exclusive";
|
||||
signal?: AbortSignal;
|
||||
}>,
|
||||
callback: (lock: unknown) => Promise<Value>,
|
||||
): Promise<Value> {
|
||||
requests.push({ name, options });
|
||||
const result = queue.then(async () => await callback({ name }));
|
||||
queue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
},
|
||||
} as unknown as LockManager;
|
||||
const lock = createResumableUploadWebLock(
|
||||
manager,
|
||||
"upload-runtime-v1",
|
||||
);
|
||||
const events: string[] = [];
|
||||
let releaseFirst!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
});
|
||||
|
||||
const first = lock.run(
|
||||
"upload_key_lock",
|
||||
activeSignal,
|
||||
async () => {
|
||||
events.push("first:start");
|
||||
await gate;
|
||||
events.push("first:end");
|
||||
return 1;
|
||||
},
|
||||
);
|
||||
const second = lock.run(
|
||||
"upload_key_lock",
|
||||
activeSignal,
|
||||
async () => {
|
||||
events.push("second:start");
|
||||
return 2;
|
||||
},
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(events).toEqual(["first:start"]);
|
||||
releaseFirst();
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([1, 2]);
|
||||
expect(events).toEqual([
|
||||
"first:start",
|
||||
"first:end",
|
||||
"second:start",
|
||||
]);
|
||||
expect(requests).toEqual([
|
||||
{
|
||||
name: "upload-runtime-v1:upload_key_lock",
|
||||
options: { mode: "exclusive", signal: activeSignal },
|
||||
},
|
||||
{
|
||||
name: "upload-runtime-v1:upload_key_lock",
|
||||
options: { mode: "exclusive", signal: activeSignal },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
PresignedUploadPartCapabilityProvider,
|
||||
} from "../../src/application/ports/browser-transfer/presigned-transfer.ts";
|
||||
import type {
|
||||
ResumableUploadCheckpoint,
|
||||
ResumableUploadCheckpointStore,
|
||||
UploadFileFingerprint,
|
||||
UploadPartReceipt,
|
||||
UploadSession,
|
||||
} from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
||||
import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts";
|
||||
import type { BrowserDataResult } from "../../src/application/ports/browser-file-storage/shared.ts";
|
||||
import {
|
||||
browserDataFailure,
|
||||
browserDataSuccess,
|
||||
} from "../../src/adapters/browser-file-storage/result.ts";
|
||||
import {
|
||||
createPresignedCapabilityVault,
|
||||
createSingleUsePresignedReplayGuard,
|
||||
} from "../../src/adapters/browser-transfer/presigned/presigned-capability-vault.ts";
|
||||
import { createPresignedCapabilityHttpProvider } from "../../src/adapters/browser-transfer/presigned/presigned-capability-http-provider.ts";
|
||||
import { createPresignedTransferExecutor } from "../../src/adapters/browser-transfer/presigned/presigned-transfer-executor.ts";
|
||||
import { createResumableUploadFetchJsonTransport } from "../../src/adapters/browser-transfer/resumable-upload/fetch-json-transport.ts";
|
||||
import {
|
||||
createResumableUploadHttpControlPlane,
|
||||
type ResumableUploadJsonTransport,
|
||||
} from "../../src/adapters/browser-transfer/resumable-upload/http-control-plane-adapter.ts";
|
||||
import { createPresignedUploadPartExecutor } from "../../src/adapters/browser-transfer/resumable-upload/presigned-upload-part-executor.ts";
|
||||
import { createResumableUploadRuntime } from "../../src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts";
|
||||
import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts";
|
||||
|
||||
const NOW = 1_000_000;
|
||||
const API_ORIGIN = "https://api.example";
|
||||
const OBJECT_ORIGIN = "https://objects.example";
|
||||
const CAPABILITY_ENDPOINT = `${API_ORIGIN}/capabilities`;
|
||||
const CONTROL_ENDPOINTS = Object.freeze({
|
||||
CREATE_SESSION: `${API_ORIGIN}/uploads/create`,
|
||||
GET_STATUS: `${API_ORIGIN}/uploads/status`,
|
||||
COMPLETE: `${API_ORIGIN}/uploads/complete`,
|
||||
ABORT: `${API_ORIGIN}/uploads/abort`,
|
||||
});
|
||||
const CHECKSUM_HEADER = "x-checksum-sha256";
|
||||
const POLICY_HEADER = "x-policy-version";
|
||||
const activeSignal = new AbortController().signal;
|
||||
|
||||
const noContentionLock: UploadMutationLock = Object.freeze({
|
||||
async run<Value>(
|
||||
_uploadKey: string,
|
||||
_signal: AbortSignal,
|
||||
task: () => Promise<Value>,
|
||||
): Promise<Value> {
|
||||
return await task();
|
||||
},
|
||||
});
|
||||
|
||||
class MemoryCheckpointStore implements ResumableUploadCheckpointStore {
|
||||
readonly rows = new Map<string, ResumableUploadCheckpoint>();
|
||||
|
||||
async read(
|
||||
uploadKey: string,
|
||||
): Promise<BrowserDataResult<ResumableUploadCheckpoint | null>> {
|
||||
return browserDataSuccess(
|
||||
structuredClone(this.rows.get(uploadKey) ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
async compareAndSwap(
|
||||
input: Parameters<
|
||||
ResumableUploadCheckpointStore["compareAndSwap"]
|
||||
>[0],
|
||||
): Promise<BrowserDataResult<ResumableUploadCheckpoint>> {
|
||||
const current = this.rows.get(input.checkpoint.uploadKey);
|
||||
if (
|
||||
(input.expectedRevision === null && current) ||
|
||||
(input.expectedRevision !== null &&
|
||||
current?.revision !== input.expectedRevision)
|
||||
) {
|
||||
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
||||
recovery: "RECONCILE",
|
||||
});
|
||||
}
|
||||
const snapshot = structuredClone(input.checkpoint);
|
||||
this.rows.set(snapshot.uploadKey, snapshot);
|
||||
return browserDataSuccess(snapshot);
|
||||
}
|
||||
|
||||
async remove(
|
||||
input: Parameters<
|
||||
ResumableUploadCheckpointStore["remove"]
|
||||
>[0],
|
||||
): Promise<BrowserDataResult<void>> {
|
||||
const current = this.rows.get(input.uploadKey);
|
||||
if (current?.revision !== input.expectedRevision) {
|
||||
return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", {
|
||||
recovery: "RECONCILE",
|
||||
});
|
||||
}
|
||||
this.rows.delete(input.uploadKey);
|
||||
return browserDataSuccess(undefined);
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
|
||||
function responseAt(
|
||||
url: string,
|
||||
body: BodyInit | null,
|
||||
init: ResponseInit,
|
||||
): Response {
|
||||
const response = new Response(body, init);
|
||||
Object.defineProperty(response, "url", {
|
||||
configurable: true,
|
||||
value: url,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
function jsonResponseAt(
|
||||
url: string,
|
||||
value: unknown,
|
||||
status = 200,
|
||||
): Response {
|
||||
const body = JSON.stringify(value);
|
||||
return responseAt(url, body, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": String(new TextEncoder().encode(body).byteLength),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function rangeSource(bytes: Uint8Array) {
|
||||
return Object.freeze({
|
||||
kind: "RANGE_READER" as const,
|
||||
reader: Object.freeze({
|
||||
byteLength: bytes.byteLength,
|
||||
async readRange(input: Readonly<{
|
||||
offset: number;
|
||||
length: number;
|
||||
signal: AbortSignal;
|
||||
}>) {
|
||||
return input.signal.aborted
|
||||
? browserDataFailure("ABORTED", "FILE_READ")
|
||||
: browserDataSuccess(
|
||||
bytes.slice(
|
||||
input.offset,
|
||||
input.offset + input.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("resumable upload HTTP control plane", () => {
|
||||
it("rejects unknown response fields so URLs cannot cross the DTO boundary", async () => {
|
||||
const fingerprint: UploadFileFingerprint = Object.freeze({
|
||||
algorithm: "SHA-256-PARTS-V1",
|
||||
digestHex: "a".repeat(64),
|
||||
byteLength: 4,
|
||||
partSizeBytes: 4,
|
||||
partCount: 1,
|
||||
});
|
||||
const transport: ResumableUploadJsonTransport = {
|
||||
async execute() {
|
||||
return browserDataSuccess({
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId: "session_01",
|
||||
requestBindingSha256: "b".repeat(64),
|
||||
fingerprint,
|
||||
partSizeBytes: 4,
|
||||
partCount: 1,
|
||||
maxConcurrency: 1,
|
||||
expiresAtEpochMs: NOW + 10_000,
|
||||
signedUrl: "https://objects.example/secret?signature=leak",
|
||||
});
|
||||
},
|
||||
};
|
||||
const partCapabilities: PresignedUploadPartCapabilityProvider = {
|
||||
issueUploadPart: vi.fn(),
|
||||
};
|
||||
const control = createResumableUploadHttpControlPlane({
|
||||
transport,
|
||||
partCapabilities,
|
||||
});
|
||||
|
||||
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).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "CORRUPT_DATA",
|
||||
operation: "UPLOAD_SESSION",
|
||||
recovery: "RECONCILE",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(result)).not.toContain("signature=leak");
|
||||
});
|
||||
|
||||
it("runs create, list-parts, session-bound capability PUT and ordered complete through actual fetch adapters", async () => {
|
||||
const checkpoints = new MemoryCheckpointStore();
|
||||
const accepted = new Map<number, UploadPartReceipt>();
|
||||
const issuedBindings: Array<Readonly<Record<string, unknown>>> = [];
|
||||
const objectPutOptions: RequestInit[] = [];
|
||||
const completedParts: UploadPartReceipt[][] = [];
|
||||
const partByHref = new Map<
|
||||
string,
|
||||
Readonly<{
|
||||
partNumber: number;
|
||||
offset: number;
|
||||
byteLength: number;
|
||||
checksumSha256: string;
|
||||
}>
|
||||
>();
|
||||
let session: UploadSession | null = null;
|
||||
let completed = false;
|
||||
let capabilitySequence = 0;
|
||||
|
||||
const fetcher = vi.fn(
|
||||
async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const url = String(input);
|
||||
if (url === CAPABILITY_ENDPOINT) {
|
||||
const request = JSON.parse(String(init?.body)) as Readonly<{
|
||||
method: string;
|
||||
binding: Readonly<{
|
||||
kind: string;
|
||||
sessionId: string;
|
||||
requestBindingSha256: string;
|
||||
uploadBindingSha256: string;
|
||||
partNumber: number;
|
||||
offset: number;
|
||||
idempotencyKey: string;
|
||||
}>;
|
||||
mediaType: string;
|
||||
byteLength: number;
|
||||
expectedSha256: string;
|
||||
}>;
|
||||
issuedBindings.push(
|
||||
Object.freeze({ ...request.binding }),
|
||||
);
|
||||
capabilitySequence += 1;
|
||||
const path =
|
||||
`/multipart/${request.binding.sessionId}` +
|
||||
`/part-${request.binding.partNumber}`;
|
||||
const href =
|
||||
`${OBJECT_ORIGIN}${path}` +
|
||||
`?signature=opaque-${capabilitySequence}`;
|
||||
partByHref.set(
|
||||
href,
|
||||
Object.freeze({
|
||||
partNumber: request.binding.partNumber,
|
||||
offset: request.binding.offset,
|
||||
byteLength: request.byteLength,
|
||||
checksumSha256: request.expectedSha256,
|
||||
}),
|
||||
);
|
||||
return jsonResponseAt(CAPABILITY_ENDPOINT, {
|
||||
capabilityReceipt: `capability-upload-${capabilitySequence}`,
|
||||
method: "PUT",
|
||||
binding: request.binding,
|
||||
href,
|
||||
origin: OBJECT_ORIGIN,
|
||||
path,
|
||||
allowedQueryParameters: ["signature"],
|
||||
requestHeaders: [
|
||||
{
|
||||
name: "content-type",
|
||||
value: request.mediaType,
|
||||
},
|
||||
{
|
||||
name: CHECKSUM_HEADER,
|
||||
value: request.expectedSha256,
|
||||
},
|
||||
],
|
||||
requiredResponseHeaders: [
|
||||
{ name: POLICY_HEADER, value: "v1" },
|
||||
],
|
||||
digestRequestHeader: CHECKSUM_HEADER,
|
||||
digestResponseHeader: null,
|
||||
receiptResponseHeader: "etag",
|
||||
expectedStatus: 200,
|
||||
expectedResponseByteLength: 0,
|
||||
mediaType: request.mediaType,
|
||||
byteLength: request.byteLength,
|
||||
maxBytes: request.byteLength,
|
||||
expectedSha256: request.expectedSha256,
|
||||
expiresAtEpochMs: NOW + 30_000,
|
||||
singleUse: true,
|
||||
});
|
||||
}
|
||||
const part = partByHref.get(url);
|
||||
if (part) {
|
||||
objectPutOptions.push(init ?? {});
|
||||
const receiptToken = `etag-part-${part.partNumber}`;
|
||||
accepted.set(
|
||||
part.partNumber,
|
||||
Object.freeze({
|
||||
...part,
|
||||
receiptToken,
|
||||
}),
|
||||
);
|
||||
return responseAt(url, null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-length": "0",
|
||||
[POLICY_HEADER]: "v1",
|
||||
etag: receiptToken,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const request = JSON.parse(String(init?.body)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (url === CONTROL_ENDPOINTS.CREATE_SESSION) {
|
||||
const fingerprint =
|
||||
request.fingerprint as UploadFileFingerprint;
|
||||
session = Object.freeze({
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId: "session_integration_01",
|
||||
requestBindingSha256: String(
|
||||
request.requestBindingSha256,
|
||||
),
|
||||
fingerprint,
|
||||
partSizeBytes: Number(
|
||||
request.requestedPartSizeBytes,
|
||||
),
|
||||
partCount: fingerprint.partCount,
|
||||
maxConcurrency: 2,
|
||||
expiresAtEpochMs: NOW + 60_000,
|
||||
});
|
||||
return jsonResponseAt(url, session, 201);
|
||||
}
|
||||
if (url === CONTROL_ENDPOINTS.GET_STATUS && session) {
|
||||
return completed
|
||||
? jsonResponseAt(url, {
|
||||
state: "QUARANTINED",
|
||||
session,
|
||||
resourceId: "resource_integration_01",
|
||||
})
|
||||
: jsonResponseAt(url, {
|
||||
state: "ACTIVE",
|
||||
session,
|
||||
acceptedParts: [...accepted.values()].sort(
|
||||
(left, right) =>
|
||||
left.partNumber - right.partNumber,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (url === CONTROL_ENDPOINTS.COMPLETE && session) {
|
||||
completedParts.push(
|
||||
request.orderedParts as UploadPartReceipt[],
|
||||
);
|
||||
completed = true;
|
||||
return jsonResponseAt(url, {
|
||||
state: "QUARANTINED",
|
||||
protocol: RESUMABLE_UPLOAD_PROTOCOL,
|
||||
sessionId: session.sessionId,
|
||||
requestBindingSha256:
|
||||
session.requestBindingSha256,
|
||||
fingerprint: session.fingerprint,
|
||||
resourceId: "resource_integration_01",
|
||||
});
|
||||
}
|
||||
if (url === CONTROL_ENDPOINTS.ABORT) {
|
||||
return jsonResponseAt(url, { state: "ABORTED" });
|
||||
}
|
||||
throw new TypeError("Unexpected test endpoint.");
|
||||
},
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
const vault = createPresignedCapabilityVault({
|
||||
maxActiveCapabilities: 16,
|
||||
now: () => NOW,
|
||||
});
|
||||
const capabilityProvider =
|
||||
createPresignedCapabilityHttpProvider({
|
||||
endpoint: CAPABILITY_ENDPOINT,
|
||||
vault,
|
||||
allowedDataOrigins: [OBJECT_ORIGIN],
|
||||
allowedDataPathPrefixes: ["/multipart/"],
|
||||
allowedQueryParameters: ["signature"],
|
||||
allowedRequestHeaders: [
|
||||
"content-type",
|
||||
CHECKSUM_HEADER,
|
||||
],
|
||||
allowedResponseHeaders: [POLICY_HEADER, "etag"],
|
||||
hardMaxTransferBytes: 64,
|
||||
hardMaxUploadResponseBytes: 16,
|
||||
maxCapabilityTtlMs: 60_000,
|
||||
minimumRemainingLifetimeMs: 100,
|
||||
timeoutMs: 5_000,
|
||||
fetcher,
|
||||
now: () => NOW,
|
||||
});
|
||||
const presignedExecutor = createPresignedTransferExecutor({
|
||||
vault,
|
||||
replayGuard: createSingleUsePresignedReplayGuard(),
|
||||
hardMaxTransferBytes: 64,
|
||||
hardMaxChunkBytes: 4,
|
||||
hardMaxUploadResponseBytes: 16,
|
||||
minimumRemainingLifetimeMs: 100,
|
||||
timeoutMs: 5_000,
|
||||
fetcher,
|
||||
now: () => NOW,
|
||||
});
|
||||
const transport = createResumableUploadFetchJsonTransport({
|
||||
endpoints: CONTROL_ENDPOINTS,
|
||||
allowedOrigins: [API_ORIGIN],
|
||||
credentials: "same-origin",
|
||||
fetcher,
|
||||
timeoutMs: 5_000,
|
||||
maxRequestBytes: 64 * 1024,
|
||||
maxResponseBytes: 64 * 1024,
|
||||
});
|
||||
const controlPlane = createResumableUploadHttpControlPlane({
|
||||
transport,
|
||||
partCapabilities: capabilityProvider,
|
||||
});
|
||||
const runtime = createResumableUploadRuntime({
|
||||
controlPlane,
|
||||
partExecutor: createPresignedUploadPartExecutor(
|
||||
presignedExecutor.uploadParts,
|
||||
() => NOW,
|
||||
),
|
||||
checkpoints,
|
||||
mutationLock: noContentionLock,
|
||||
crypto,
|
||||
policy: {
|
||||
partSizeBytes: 4,
|
||||
maxFileBytes: 64,
|
||||
maxPartCount: 16,
|
||||
maxConcurrency: 2,
|
||||
maxInFlightBytes: 32,
|
||||
partBufferCopyFactor: 4,
|
||||
maxSourceChunkBytes: 4,
|
||||
maxRetries: 1,
|
||||
retryBaseDelayMs: 1,
|
||||
retryMaxDelayMs: 10,
|
||||
maxRetryAfterMs: 100,
|
||||
capabilityRefreshSkewMs: 100,
|
||||
maxSessionLifetimeMs: 120_000,
|
||||
providerAttemptTimeoutMs: 5_000,
|
||||
},
|
||||
now: () => NOW,
|
||||
random: () => 0,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const result = await runtime.upload({
|
||||
uploadKey: "upload_key_integration",
|
||||
purpose: "attachment",
|
||||
mediaType: "application/octet-stream",
|
||||
source: rangeSource(
|
||||
new Uint8Array([1, 2, 3, 4, 5, 6]),
|
||||
),
|
||||
signal: activeSignal,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "QUARANTINED",
|
||||
resourceId: "resource_integration_01",
|
||||
byteLength: 6,
|
||||
replayed: false,
|
||||
},
|
||||
});
|
||||
expect(issuedBindings).toHaveLength(2);
|
||||
expect(
|
||||
issuedBindings.every(
|
||||
(binding) =>
|
||||
binding.protocol === RESUMABLE_UPLOAD_PROTOCOL &&
|
||||
binding.sessionId === "session_integration_01" &&
|
||||
binding.requestBindingSha256 ===
|
||||
session?.requestBindingSha256,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(objectPutOptions).toHaveLength(2);
|
||||
expect(
|
||||
objectPutOptions.every(
|
||||
(options) =>
|
||||
options.method === "PUT" &&
|
||||
options.credentials === "omit" &&
|
||||
options.redirect === "error",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
completedParts[0]?.map((part) => part.partNumber),
|
||||
).toEqual([1, 2]);
|
||||
expect(checkpoints.rows.size).toBe(0);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import {
|
||||
parseRetryAfter,
|
||||
retryDelay,
|
||||
shouldRetry,
|
||||
} from "../../src/adapters/http/retry-policy.js";
|
||||
} from "../../src/adapters/http/retry-policy.ts";
|
||||
|
||||
describe("HTTP retry policy", () => {
|
||||
it("uses capped exponential full jitter", () => {
|
||||
@@ -3,9 +3,9 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ROUTE_REGISTRY,
|
||||
ROUTE_RUNTIME_CONTRACT,
|
||||
} from "../../src/features/installed-feature-contracts.js";
|
||||
import { ROUTE_RUNTIME } from "../../src/features/installed-feature-runtimes.js";
|
||||
import { parseRouteInput } from "../../src/presentation/routes/route-codecs.js";
|
||||
} from "../../src/features/installed-feature-contracts.ts";
|
||||
import { ROUTE_RUNTIME } from "../../src/features/installed-feature-runtimes.tsx";
|
||||
import { parseRouteInput } from "../../src/presentation/routes/route-codecs.ts";
|
||||
|
||||
describe("typed installed route catalog", () => {
|
||||
it("keeps contract and executable runtime contributions complete", () => {
|
||||
|
||||
@@ -2,10 +2,24 @@ import { readFileSync } from "node:fs";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type RunbookSpecification = Readonly<{
|
||||
triggerKinds: string[];
|
||||
containment: string;
|
||||
window: string;
|
||||
escalation: string[];
|
||||
recoveryEvidence: string[];
|
||||
negativeFixture: string;
|
||||
gateId: string;
|
||||
}>;
|
||||
|
||||
type RunbookDocument = Readonly<{
|
||||
runbooks: Record<string, RunbookSpecification>;
|
||||
}>;
|
||||
|
||||
describe("operational runbook contract", () => {
|
||||
const document = JSON.parse(
|
||||
readFileSync("config/runbooks/runbooks.json", "utf8"),
|
||||
);
|
||||
) as RunbookDocument;
|
||||
|
||||
it("defines all five runbooks with four machine-checkable contract axes", () => {
|
||||
expect(Object.keys(document.runbooks)).toEqual([
|
||||
@@ -3,10 +3,13 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createRuntimeAdapters,
|
||||
createRuntimeHttpClient,
|
||||
} from "../../src/bootstrap/runtime-adapters.js";
|
||||
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.js";
|
||||
} from "../../src/bootstrap/runtime-adapters.ts";
|
||||
import { TEST_HTTP_CONTRACT } from "../helpers/http-contract-fixture.ts";
|
||||
|
||||
const runtime = {
|
||||
type Runtime = Parameters<typeof createRuntimeAdapters>[0]["runtime"];
|
||||
type Release = Parameters<typeof createRuntimeAdapters>[0]["release"];
|
||||
|
||||
const runtime: Runtime = {
|
||||
config: {
|
||||
APP_ENV: "local",
|
||||
API_BASE_URL: "http://localhost:8080",
|
||||
@@ -15,9 +18,18 @@ const runtime = {
|
||||
REQUEST_TIMEOUT_MS: 4321,
|
||||
MAX_RETRY_ATTEMPTS: 0,
|
||||
RELEASE_MANIFEST_URL: "/release-manifest.json",
|
||||
CONFIG_SCHEMA_VERSION: "1",
|
||||
API_CONTRACT_VERSION: "1",
|
||||
},
|
||||
build: {
|
||||
buildId: "build-a",
|
||||
commitSha: "abc123",
|
||||
routerBasePath: "/",
|
||||
runtimeConfigUrl: "/config.json",
|
||||
},
|
||||
validationDurationMs: 0,
|
||||
};
|
||||
const release = /** @type {const} */ ({
|
||||
const release: Release = {
|
||||
schemaVersion: 1,
|
||||
appVersion: "0.1.0",
|
||||
buildId: "build-a",
|
||||
@@ -28,15 +40,12 @@ const release = /** @type {const} */ ({
|
||||
releaseId: "release-a",
|
||||
builtAt: "2026-07-25T00:00:00Z",
|
||||
routeChunks: { "route-home": "assets/home.js" },
|
||||
});
|
||||
};
|
||||
|
||||
describe("runtime adapter composition", () => {
|
||||
it("constructs the local demo seam and infrastructure adapters", async () => {
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime:
|
||||
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ (
|
||||
runtime
|
||||
),
|
||||
runtime,
|
||||
release,
|
||||
host: {},
|
||||
});
|
||||
@@ -46,19 +55,53 @@ describe("runtime adapter composition", () => {
|
||||
releaseId: "release-a",
|
||||
});
|
||||
expect(adapters.infrastructure.queryClient).toBeDefined();
|
||||
expect(adapters.infrastructure.queryInvalidation).toBeDefined();
|
||||
expect(
|
||||
adapters.infrastructure.crossContextInvalidationStatus(),
|
||||
).toBe("DEGRADED_LOCAL_ONLY");
|
||||
expect(adapters.outputPorts.diagnostics.record).toEqual(expect.any(Function));
|
||||
expect(adapters.outputPorts.telemetry.emit).toEqual(expect.any(Function));
|
||||
expect(adapters.outputPorts.telemetry.pendingCount()).toBe(0);
|
||||
expect(adapters).not.toHaveProperty("http");
|
||||
expect(adapters).not.toHaveProperty("storage");
|
||||
adapters.infrastructure.dispose();
|
||||
});
|
||||
|
||||
it("does not fail boot when Web Storage capability getters throw", async () => {
|
||||
const host: Record<string, unknown> = {};
|
||||
Object.defineProperties(host, {
|
||||
localStorage: {
|
||||
get() {
|
||||
throw new DOMException("denied", "SecurityError");
|
||||
},
|
||||
},
|
||||
sessionStorage: {
|
||||
get() {
|
||||
throw new DOMException("denied", "SecurityError");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime,
|
||||
release,
|
||||
host,
|
||||
});
|
||||
|
||||
expect(adapters.infrastructure.queryClient).toBeDefined();
|
||||
expect(adapters.outputPorts.preferences.read("COLOR_SCHEME")).toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
});
|
||||
adapters.infrastructure.dispose();
|
||||
});
|
||||
|
||||
it("fails closed when an external auth owner was not installed", async () => {
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime:
|
||||
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ ({
|
||||
config: { ...runtime.config, AUTH_MODE: "external" },
|
||||
}),
|
||||
runtime: {
|
||||
...runtime,
|
||||
config: { ...runtime.config, AUTH_MODE: "external" },
|
||||
},
|
||||
release,
|
||||
host: {},
|
||||
});
|
||||
@@ -74,10 +117,7 @@ describe("runtime adapter composition", () => {
|
||||
};
|
||||
const fetcher = vi.fn(async () => Response.json(activeRelease));
|
||||
const adapters = await createRuntimeAdapters({
|
||||
runtime:
|
||||
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ (
|
||||
runtime
|
||||
),
|
||||
runtime,
|
||||
release,
|
||||
fetcher,
|
||||
host: {},
|
||||
@@ -94,10 +134,9 @@ describe("runtime adapter composition", () => {
|
||||
});
|
||||
|
||||
it("injects runtime timeout and max-attempt policy into HTTP execution", async () => {
|
||||
const scheduled =
|
||||
/** @type {Array<{callback: () => void, milliseconds: number}>} */ ([]);
|
||||
const scheduled: Array<{ callback: () => void; milliseconds: number }> = [];
|
||||
const scheduler = {
|
||||
setTimeout: vi.fn((callback, milliseconds) => {
|
||||
setTimeout: vi.fn((callback: () => void, milliseconds: number) => {
|
||||
scheduled.push({ callback, milliseconds });
|
||||
return scheduled.length;
|
||||
}),
|
||||
@@ -115,23 +154,14 @@ describe("runtime adapter composition", () => {
|
||||
);
|
||||
const authSession =
|
||||
(await createRuntimeAdapters({
|
||||
runtime:
|
||||
/** @type {Parameters<typeof createRuntimeAdapters>[0]["runtime"]} */ (
|
||||
runtime
|
||||
),
|
||||
runtime,
|
||||
release,
|
||||
host: {},
|
||||
})).outputPorts.session;
|
||||
const client = createRuntimeHttpClient(
|
||||
{
|
||||
runtime:
|
||||
/** @type {Parameters<typeof createRuntimeHttpClient>[0]["runtime"]} */ (
|
||||
runtime
|
||||
),
|
||||
authSession:
|
||||
/** @type {import("../../src/application/ports/auth-session-port.js").AuthSessionPort} */ (
|
||||
authSession
|
||||
),
|
||||
runtime,
|
||||
authSession,
|
||||
fetcher,
|
||||
clock: { now: () => 0, sleep: async () => {} },
|
||||
scheduler,
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createServerStateScopeRuntime } from "../../src/adapters/query-cache/server-state-scope-runtime.ts";
|
||||
|
||||
describe("server-state session generation runtime", () => {
|
||||
it("fences the old generation before reset and publishes the new scope after reset", async () => {
|
||||
let sessionListener: () => void = () => {};
|
||||
let completeReset: () => void = () => {};
|
||||
const reset = new Promise<void>((resolve) => {
|
||||
completeReset = resolve;
|
||||
});
|
||||
const resetLocal = vi.fn(() => reset);
|
||||
let tokenSequence = 0;
|
||||
const runtime = createServerStateScopeRuntime({
|
||||
session: {
|
||||
subscribe(listener) {
|
||||
sessionListener = listener;
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
queryInvalidation: {
|
||||
invalidate: async () => {},
|
||||
beginMutation: () => ({ release: async () => {} }),
|
||||
resetLocal,
|
||||
dispose() {},
|
||||
},
|
||||
tokenFactory: () => `scope-token-${String(tokenSequence++).padStart(8, "0")}`,
|
||||
});
|
||||
const changed = vi.fn();
|
||||
runtime.subscribe(changed);
|
||||
const before = runtime.getSnapshot();
|
||||
const identity = before.identities.intern({ id: "private" });
|
||||
identity.acquire();
|
||||
|
||||
sessionListener();
|
||||
expect(before.isCurrent()).toBe(false);
|
||||
expect(runtime.getSnapshot()).toBe(before);
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
await vi.waitFor(() => expect(resetLocal).toHaveBeenCalledOnce());
|
||||
|
||||
completeReset();
|
||||
await vi.waitFor(() => expect(changed).toHaveBeenCalledOnce());
|
||||
const after = runtime.getSnapshot();
|
||||
expect(after.generation).toBe(before.generation + 1);
|
||||
expect(after.fingerprint).not.toBe(before.fingerprint);
|
||||
expect(after.isCurrent()).toBe(true);
|
||||
expect(before.identities.inspect().closed).toBe(true);
|
||||
|
||||
runtime.dispose();
|
||||
expect(after.identities.inspect().closed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createStorageDurabilityAdapter } from "../../src/adapters/browser-file-storage/storage-manager-adapter.ts";
|
||||
|
||||
describe("storage manager adapter", () => {
|
||||
it("classifies rough origin pressure without treating it as a reservation", async () => {
|
||||
const adapter = createStorageDurabilityAdapter({
|
||||
estimate: async () => ({ usage: 75, quota: 100 }),
|
||||
persisted: async () => false,
|
||||
persist: async () => false,
|
||||
});
|
||||
|
||||
expect(await adapter.inspect()).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
usageBytes: 75,
|
||||
quotaBytes: 100,
|
||||
persisted: false,
|
||||
pressure: "PRESSURE",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("snapshots pressure thresholds when the adapter is composed", async () => {
|
||||
const policy = {
|
||||
pressureRatio: 0.7,
|
||||
criticalRatio: 0.85,
|
||||
};
|
||||
const adapter = createStorageDurabilityAdapter(
|
||||
{
|
||||
estimate: async () => ({ usage: 50, quota: 100 }),
|
||||
},
|
||||
policy,
|
||||
);
|
||||
|
||||
policy.pressureRatio = 0.1;
|
||||
policy.criticalRatio = 0.2;
|
||||
|
||||
await expect(adapter.inspect()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { pressure: "NORMAL" },
|
||||
});
|
||||
});
|
||||
|
||||
it("captures StorageManager methods when the adapter is composed", async () => {
|
||||
const manager = {
|
||||
estimate: async () => ({ usage: 10, quota: 100 }),
|
||||
persisted: async () => false,
|
||||
};
|
||||
const adapter = createStorageDurabilityAdapter(manager);
|
||||
|
||||
manager.estimate = async () => ({ usage: 100, quota: 100 });
|
||||
manager.persisted = async () => true;
|
||||
|
||||
await expect(adapter.inspect()).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
usageBytes: 10,
|
||||
quotaBytes: 100,
|
||||
persisted: false,
|
||||
pressure: "NORMAL",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("settles an aborted inspection while native reads remain pending", async () => {
|
||||
const never = new Promise<never>(() => undefined);
|
||||
const estimate = vi.fn(() => never);
|
||||
const persisted = vi.fn(() => never);
|
||||
const adapter = createStorageDurabilityAdapter({
|
||||
estimate,
|
||||
persisted,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
|
||||
const inspection = adapter.inspect(controller.signal);
|
||||
controller.abort();
|
||||
|
||||
await expect(inspection).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED", operation: "STORAGE_ESTIMATE" },
|
||||
});
|
||||
expect(estimate).toHaveBeenCalledOnce();
|
||||
expect(persisted).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("consumes a late native rejection when abort wins during invocation", async () => {
|
||||
const controller = new AbortController();
|
||||
let rejectEstimate: ((reason: unknown) => void) | undefined;
|
||||
const estimateResult = new Promise<StorageEstimate>(
|
||||
(_resolve, reject) => {
|
||||
rejectEstimate = reject;
|
||||
},
|
||||
);
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown): void => {
|
||||
unhandled.push(reason);
|
||||
};
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
|
||||
try {
|
||||
const adapter = createStorageDurabilityAdapter({
|
||||
estimate: () => {
|
||||
controller.abort();
|
||||
return estimateResult;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(adapter.inspect(controller.signal)).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED", operation: "STORAGE_ESTIMATE" },
|
||||
});
|
||||
rejectEstimate?.(new Error("late estimate rejection"));
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
}
|
||||
});
|
||||
|
||||
it("requests persistence only through the explicit user-initiated port", async () => {
|
||||
const persist = vi.fn(async () => true);
|
||||
const adapter = createStorageDurabilityAdapter({
|
||||
estimate: async () => ({}),
|
||||
persist,
|
||||
}, undefined, { isActive: true });
|
||||
|
||||
expect(
|
||||
await adapter.requestPersistence({
|
||||
reason: "PROTECT_UNSYNCED_USER_DATA",
|
||||
userInitiated: true,
|
||||
}),
|
||||
).toEqual({ ok: true, value: "GRANTED" });
|
||||
expect(persist).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("requires real transient user activation before invoking persist", async () => {
|
||||
const persist = vi.fn(async () => true);
|
||||
const adapter = createStorageDurabilityAdapter(
|
||||
{ estimate: async () => ({}), persist },
|
||||
undefined,
|
||||
{ isActive: false },
|
||||
);
|
||||
|
||||
await expect(
|
||||
adapter.requestPersistence({
|
||||
reason: "PROTECT_UNSYNCED_USER_DATA",
|
||||
userInitiated: true,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
expect(persist).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports irreversible persist truth when abort wins after invocation", async () => {
|
||||
const controller = new AbortController();
|
||||
const adapter = createStorageDurabilityAdapter(
|
||||
{
|
||||
estimate: async () => ({}),
|
||||
persist: async () => {
|
||||
controller.abort();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
await expect(
|
||||
adapter.requestPersistence({
|
||||
reason: "PROTECT_UNSYNCED_USER_DATA",
|
||||
userInitiated: true,
|
||||
signal: controller.signal,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true, value: "GRANTED" });
|
||||
});
|
||||
|
||||
it("keeps unsupported persistence state distinct from false", async () => {
|
||||
const adapter = createStorageDurabilityAdapter({
|
||||
estimate: async () => ({ usage: 1, quota: 10 }),
|
||||
});
|
||||
|
||||
await expect(adapter.inspect()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: { persisted: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns unsupported rather than inventing durable memory storage", async () => {
|
||||
const adapter = createStorageDurabilityAdapter(undefined);
|
||||
|
||||
expect(await adapter.inspect()).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNSUPPORTED",
|
||||
recovery: "ONLINE_ONLY",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createBrowserStorageAdapter } from "../../src/adapters/storage/browser-storage-adapter.js";
|
||||
import {
|
||||
STORAGE_REGISTRY,
|
||||
buildPhysicalKey,
|
||||
defineStorageKey,
|
||||
} from "../../src/contracts/storage-keys.js";
|
||||
|
||||
/**
|
||||
* @param {{quota?: boolean}} [options]
|
||||
* @returns {Storage}
|
||||
*/
|
||||
function createStorage({ quota = false } = {}) {
|
||||
const values = /** @type {Map<string, string>} */ (new Map());
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => {
|
||||
if (quota) throw new DOMException("full", "QuotaExceededError");
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key) => values.delete(key),
|
||||
clear: () => values.clear(),
|
||||
key: () => null,
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("storage registry", () => {
|
||||
it("builds namespace and schema-versioned physical keys", () => {
|
||||
expect(buildPhysicalKey("preference", 2, "theme")).toBe(
|
||||
"ca-frontend:preference:v2:theme",
|
||||
);
|
||||
expect(STORAGE_REGISTRY.COLOR_SCHEME.physicalKey).toContain(":v1:");
|
||||
});
|
||||
|
||||
it("rejects token or secret persistence registrations", () => {
|
||||
expect(() =>
|
||||
defineStorageKey({
|
||||
logicalName: "TOKEN",
|
||||
scope: "auth",
|
||||
name: "token",
|
||||
backend: "localStorage",
|
||||
classification: "sensitive-forbidden",
|
||||
schemaVersion: 1,
|
||||
ttl: null,
|
||||
migration: "discard",
|
||||
quotaFallback: "feature-disable",
|
||||
}),
|
||||
).toThrow("Sensitive client storage registration is forbidden");
|
||||
});
|
||||
|
||||
it("round-trips public preferences through the adapter", () => {
|
||||
const localStorage = createStorage();
|
||||
const adapter = createBrowserStorageAdapter({ localStorage });
|
||||
expect(adapter.write("COLOR_SCHEME", "dark")).toEqual({ ok: true });
|
||||
expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark" });
|
||||
});
|
||||
|
||||
it("falls back to memory when preference storage quota is exceeded", () => {
|
||||
const localStorage = createStorage({ quota: true });
|
||||
const record = vi.fn();
|
||||
const adapter = createBrowserStorageAdapter({
|
||||
localStorage,
|
||||
diagnostics: { record },
|
||||
});
|
||||
expect(adapter.write("COLOR_SCHEME", "dark")).toMatchObject({
|
||||
ok: false,
|
||||
fallback: "memory",
|
||||
error: { kind: "STORAGE_QUOTA_EXCEEDED" },
|
||||
});
|
||||
expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark" });
|
||||
expect(record).toHaveBeenCalledOnce();
|
||||
expect(record).toHaveBeenCalledWith({
|
||||
level: "warn",
|
||||
eventId: "storage.operation.failed",
|
||||
context: {
|
||||
operation: "write:COLOR_SCHEME",
|
||||
error_kind: "STORAGE_QUOTA_EXCEEDED",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(record.mock.calls)).not.toContain("dark");
|
||||
});
|
||||
|
||||
it("discards data from a previous schema version", () => {
|
||||
const localStorage = createStorage();
|
||||
localStorage.setItem(
|
||||
STORAGE_REGISTRY.COLOR_SCHEME.physicalKey,
|
||||
JSON.stringify({ schemaVersion: 0, value: "dark" }),
|
||||
);
|
||||
const adapter = createBrowserStorageAdapter({ localStorage });
|
||||
expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: undefined });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,508 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createBrowserStorageAdapter } from "../../src/adapters/storage/browser-storage-adapter.ts";
|
||||
import {
|
||||
STORAGE_REGISTRY,
|
||||
buildPhysicalKey,
|
||||
defineStorageKey,
|
||||
getStorageDefinition,
|
||||
isStorageValueAllowed,
|
||||
type StorageDefinition,
|
||||
type StorageKeyInput,
|
||||
} from "../../src/contracts/storage-keys.ts";
|
||||
|
||||
type StorageFailureMode = "none" | "quota" | "security";
|
||||
|
||||
function createStorage(options: {
|
||||
initial?: Readonly<Record<string, string>>;
|
||||
writeFailure?: StorageFailureMode;
|
||||
readFailure?: StorageFailureMode;
|
||||
removeFailure?: StorageFailureMode;
|
||||
} = {}) {
|
||||
const values = new Map<string, string>(Object.entries(options.initial ?? {}));
|
||||
const state = {
|
||||
writeFailure: options.writeFailure ?? "none",
|
||||
readFailure: options.readFailure ?? "none",
|
||||
removeFailure: options.removeFailure ?? "none",
|
||||
};
|
||||
const getItem = vi.fn((key: string) => {
|
||||
throwFor(state.readFailure);
|
||||
return values.get(key) ?? null;
|
||||
});
|
||||
const setItem = vi.fn((key: string, value: string) => {
|
||||
throwFor(state.writeFailure);
|
||||
values.set(key, value);
|
||||
});
|
||||
const removeItem = vi.fn((key: string) => {
|
||||
throwFor(state.removeFailure);
|
||||
values.delete(key);
|
||||
});
|
||||
const storage: Storage = {
|
||||
getItem,
|
||||
setItem,
|
||||
removeItem,
|
||||
clear: () => values.clear(),
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
};
|
||||
return { storage, values, state, getItem, setItem, removeItem };
|
||||
}
|
||||
|
||||
function throwFor(mode: StorageFailureMode): void {
|
||||
if (mode === "quota") {
|
||||
throw new DOMException("private quota detail", "QuotaExceededError");
|
||||
}
|
||||
if (mode === "security") {
|
||||
throw new DOMException("private security detail", "SecurityError");
|
||||
}
|
||||
}
|
||||
|
||||
function serializedEnvelope(
|
||||
value: unknown,
|
||||
overrides: Readonly<{
|
||||
schemaVersion?: number;
|
||||
expiresAt?: number | null;
|
||||
}> = {},
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
schemaVersion: overrides.schemaVersion ?? 1,
|
||||
expiresAt: overrides.expiresAt ?? null,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function definitionResolver(
|
||||
extra: Readonly<Record<string, StorageDefinition>>,
|
||||
) {
|
||||
return (logicalName: string): StorageDefinition =>
|
||||
extra[logicalName] ?? getStorageDefinition(logicalName);
|
||||
}
|
||||
|
||||
describe("storage registry", () => {
|
||||
it("builds namespace and schema-versioned physical keys", () => {
|
||||
expect(buildPhysicalKey("preference", 2, "theme")).toBe(
|
||||
"ca-frontend:preference:v2:theme",
|
||||
);
|
||||
expect(STORAGE_REGISTRY.COLOR_SCHEME.physicalKey).toContain(":v1:");
|
||||
});
|
||||
|
||||
it("rejects token or secret persistence registrations", () => {
|
||||
expect(() =>
|
||||
defineStorageKey({
|
||||
logicalName: "TOKEN",
|
||||
scope: "auth",
|
||||
name: "token",
|
||||
backend: "localStorage",
|
||||
classification: "sensitive-forbidden",
|
||||
schemaVersion: 1,
|
||||
valueCodec: "none",
|
||||
ttl: null,
|
||||
migration: "discard",
|
||||
quotaFallback: "feature-disable",
|
||||
}),
|
||||
).toThrow("Sensitive client storage registration is forbidden");
|
||||
});
|
||||
|
||||
it("enforces the registry-owned typed value codec contract", () => {
|
||||
const colorScheme = STORAGE_REGISTRY.COLOR_SCHEME;
|
||||
expect(
|
||||
["system", "light", "dark"].every((value) =>
|
||||
isStorageValueAllowed(colorScheme, value),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
[
|
||||
"",
|
||||
"LIGHT",
|
||||
"private-custom-theme",
|
||||
1,
|
||||
null,
|
||||
{ mode: "dark" },
|
||||
].some((value) => isStorageValueAllowed(colorScheme, value)),
|
||||
).toBe(false);
|
||||
|
||||
const opaqueString = STORAGE_REGISTRY.CHUNK_RELOAD_GUARD;
|
||||
expect(isStorageValueAllowed(opaqueString, "a")).toBe(true);
|
||||
expect(isStorageValueAllowed(opaqueString, "x".repeat(2_048))).toBe(true);
|
||||
expect(isStorageValueAllowed(opaqueString, "")).toBe(false);
|
||||
expect(isStorageValueAllowed(opaqueString, "x".repeat(2_049))).toBe(false);
|
||||
expect(isStorageValueAllowed(opaqueString, { value: "opaque" })).toBe(false);
|
||||
|
||||
expect(
|
||||
isStorageValueAllowed(
|
||||
STORAGE_REGISTRY.QUERY_PERSISTENCE,
|
||||
"must-never-persist",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unregistered codecs and executable migration policies", () => {
|
||||
const definition = {
|
||||
logicalName: "UNSAFE_POLICY",
|
||||
scope: "preference",
|
||||
name: "unsafe-policy",
|
||||
backend: "localStorage",
|
||||
classification: "public-preference",
|
||||
schemaVersion: 1,
|
||||
valueCodec: "unregistered-codec",
|
||||
ttl: null,
|
||||
migration: "discard",
|
||||
quotaFallback: "no-persist",
|
||||
} as unknown as StorageKeyInput;
|
||||
expect(() => defineStorageKey(definition)).toThrow(
|
||||
"Unknown client storage value codec",
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
defineStorageKey({
|
||||
...definition,
|
||||
valueCodec: "opaque-string-v1",
|
||||
migration: (() => "unsafe") as unknown as "discard",
|
||||
}),
|
||||
).toThrow("Unsupported client storage migration policy");
|
||||
});
|
||||
|
||||
it("round-trips public preferences through the adapter", () => {
|
||||
const { storage: localStorage } = createStorage();
|
||||
const adapter = createBrowserStorageAdapter({ localStorage });
|
||||
expect(adapter.write("COLOR_SCHEME", "dark")).toEqual({ ok: true });
|
||||
expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark" });
|
||||
});
|
||||
|
||||
it("keeps local and session registry entries on their declared backends", () => {
|
||||
const local = createStorage();
|
||||
const session = createStorage();
|
||||
const adapter = createBrowserStorageAdapter({
|
||||
localStorage: local.storage,
|
||||
sessionStorage: session.storage,
|
||||
});
|
||||
|
||||
expect(adapter.write("COLOR_SCHEME", "light")).toEqual({ ok: true });
|
||||
expect(adapter.write("CHUNK_RELOAD_GUARD", "release-a->release-b")).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(
|
||||
local.values.has(STORAGE_REGISTRY.COLOR_SCHEME.physicalKey),
|
||||
).toBe(true);
|
||||
expect(
|
||||
local.values.has(STORAGE_REGISTRY.CHUNK_RELOAD_GUARD.physicalKey),
|
||||
).toBe(false);
|
||||
expect(
|
||||
session.values.has(STORAGE_REGISTRY.CHUNK_RELOAD_GUARD.physicalKey),
|
||||
).toBe(true);
|
||||
expect(adapter.read("CHUNK_RELOAD_GUARD")).toEqual({
|
||||
ok: true,
|
||||
value: "release-a->release-b",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to memory when preference storage quota is exceeded", () => {
|
||||
const { storage: localStorage } = createStorage({
|
||||
writeFailure: "quota",
|
||||
});
|
||||
const record = vi.fn();
|
||||
const adapter = createBrowserStorageAdapter({
|
||||
localStorage,
|
||||
diagnostics: { record },
|
||||
});
|
||||
expect(adapter.write("COLOR_SCHEME", "dark")).toMatchObject({
|
||||
ok: false,
|
||||
fallback: "memory",
|
||||
error: { kind: "STORAGE_QUOTA_EXCEEDED" },
|
||||
});
|
||||
expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: "dark" });
|
||||
expect(record).toHaveBeenCalledOnce();
|
||||
expect(record).toHaveBeenCalledWith({
|
||||
level: "warn",
|
||||
eventId: "storage.operation.failed",
|
||||
context: {
|
||||
operation: "write:COLOR_SCHEME",
|
||||
error_kind: "STORAGE_QUOTA_EXCEEDED",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(record.mock.calls)).not.toContain("dark");
|
||||
expect(JSON.stringify(record.mock.calls)).not.toContain(
|
||||
"private quota detail",
|
||||
);
|
||||
});
|
||||
|
||||
it("discards data from a previous schema version", () => {
|
||||
const { storage: localStorage, values, removeItem } = createStorage({
|
||||
initial: {
|
||||
[STORAGE_REGISTRY.COLOR_SCHEME.physicalKey]: serializedEnvelope(
|
||||
"dark",
|
||||
{ schemaVersion: 2 },
|
||||
),
|
||||
},
|
||||
});
|
||||
const adapter = createBrowserStorageAdapter({ localStorage });
|
||||
expect(adapter.read("COLOR_SCHEME")).toEqual({ ok: true, value: undefined });
|
||||
expect(removeItem).toHaveBeenCalledOnce();
|
||||
expect(values.has(STORAGE_REGISTRY.COLOR_SCHEME.physicalKey)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects values outside the selected key codec before persistence", () => {
|
||||
const { storage: localStorage, setItem } = createStorage();
|
||||
const adapter = createBrowserStorageAdapter({ localStorage });
|
||||
|
||||
expect(adapter.write("COLOR_SCHEME", { mode: "dark" })).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "STORAGE_UNAVAILABLE",
|
||||
code: "COLOR_SCHEME_WRITE_VALUE_REJECTED",
|
||||
},
|
||||
});
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["malformed JSON", "{private malformed value"],
|
||||
[
|
||||
"invalid envelope",
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
expiresAt: null,
|
||||
value: "dark",
|
||||
unexpected: "private",
|
||||
}),
|
||||
],
|
||||
["oversized record", "x".repeat(512)],
|
||||
])(
|
||||
"removes a %s once and suppresses it when native cleanup fails",
|
||||
(_label, raw) => {
|
||||
const physicalKey = STORAGE_REGISTRY.COLOR_SCHEME.physicalKey;
|
||||
const { storage, getItem, removeItem } = createStorage({
|
||||
initial: { [physicalKey]: raw },
|
||||
removeFailure: "security",
|
||||
});
|
||||
const record = vi.fn();
|
||||
const adapter = createBrowserStorageAdapter({
|
||||
localStorage: storage,
|
||||
diagnostics: { record },
|
||||
maxSerializedBytes: 128,
|
||||
});
|
||||
|
||||
expect(adapter.read("COLOR_SCHEME")).toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
});
|
||||
expect(adapter.read("COLOR_SCHEME")).toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
});
|
||||
expect(getItem).toHaveBeenCalledOnce();
|
||||
expect(removeItem).toHaveBeenCalledOnce();
|
||||
expect(JSON.stringify(record.mock.calls)).not.toMatch(
|
||||
/private|malformed|unexpected/,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("removes expired persistent records and does not return their value", () => {
|
||||
const expiring = defineStorageKey({
|
||||
logicalName: "EXPIRING_PREFERENCE",
|
||||
scope: "preference",
|
||||
name: "expiring",
|
||||
backend: "localStorage",
|
||||
classification: "public-preference",
|
||||
schemaVersion: 1,
|
||||
valueCodec: "opaque-string-v1",
|
||||
ttl: 10,
|
||||
migration: "discard",
|
||||
quotaFallback: "memory",
|
||||
});
|
||||
const { storage, removeItem } = createStorage({
|
||||
initial: {
|
||||
[expiring.physicalKey]: serializedEnvelope("private-expired", {
|
||||
expiresAt: 99,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const adapter = createBrowserStorageAdapter({
|
||||
localStorage: storage,
|
||||
now: () => 100,
|
||||
resolveDefinition: definitionResolver({
|
||||
EXPIRING_PREFERENCE: expiring,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(adapter.read("EXPIRING_PREFERENCE")).toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
});
|
||||
expect(removeItem).toHaveBeenCalledWith(expiring.physicalKey);
|
||||
});
|
||||
|
||||
it("prioritizes a failed-write memory envelope over stale persistent data and expires it", () => {
|
||||
let timestamp = 100;
|
||||
const expiring = defineStorageKey({
|
||||
logicalName: "EXPIRING_PREFERENCE",
|
||||
scope: "preference",
|
||||
name: "expiring",
|
||||
backend: "localStorage",
|
||||
classification: "public-preference",
|
||||
schemaVersion: 1,
|
||||
valueCodec: "opaque-string-v1",
|
||||
ttl: 10,
|
||||
migration: "discard",
|
||||
quotaFallback: "memory",
|
||||
});
|
||||
const { storage, state } = createStorage({
|
||||
initial: {
|
||||
[expiring.physicalKey]: serializedEnvelope("stale", {
|
||||
expiresAt: 1_000,
|
||||
}),
|
||||
},
|
||||
writeFailure: "quota",
|
||||
});
|
||||
const adapter = createBrowserStorageAdapter({
|
||||
localStorage: storage,
|
||||
now: () => timestamp,
|
||||
resolveDefinition: definitionResolver({
|
||||
EXPIRING_PREFERENCE: expiring,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(adapter.write("EXPIRING_PREFERENCE", "fresh")).toMatchObject({
|
||||
ok: false,
|
||||
fallback: "memory",
|
||||
});
|
||||
expect(adapter.read("EXPIRING_PREFERENCE")).toEqual({
|
||||
ok: true,
|
||||
value: "fresh",
|
||||
});
|
||||
|
||||
state.writeFailure = "none";
|
||||
timestamp = 111;
|
||||
expect(adapter.read("EXPIRING_PREFERENCE")).toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears a failed-write overlay after a later persistent write succeeds", () => {
|
||||
const physicalKey = STORAGE_REGISTRY.COLOR_SCHEME.physicalKey;
|
||||
const { storage, state, values } = createStorage({
|
||||
writeFailure: "security",
|
||||
});
|
||||
const adapter = createBrowserStorageAdapter({ localStorage: storage });
|
||||
|
||||
expect(adapter.write("COLOR_SCHEME", "dark")).toMatchObject({
|
||||
ok: false,
|
||||
fallback: "memory",
|
||||
error: { kind: "STORAGE_UNAVAILABLE" },
|
||||
});
|
||||
expect(adapter.read("COLOR_SCHEME")).toEqual({
|
||||
ok: true,
|
||||
value: "dark",
|
||||
});
|
||||
|
||||
state.writeFailure = "none";
|
||||
expect(adapter.write("COLOR_SCHEME", "light")).toEqual({ ok: true });
|
||||
values.set(physicalKey, serializedEnvelope("system"));
|
||||
expect(adapter.read("COLOR_SCHEME")).toEqual({
|
||||
ok: true,
|
||||
value: "system",
|
||||
});
|
||||
});
|
||||
|
||||
it("purges the memory overlay even when persistent remove throws", () => {
|
||||
const { storage, state } = createStorage({
|
||||
writeFailure: "quota",
|
||||
removeFailure: "security",
|
||||
});
|
||||
const adapter = createBrowserStorageAdapter({ localStorage: storage });
|
||||
expect(adapter.write("COLOR_SCHEME", "dark")).toMatchObject({
|
||||
fallback: "memory",
|
||||
});
|
||||
|
||||
state.writeFailure = "none";
|
||||
expect(adapter.remove("COLOR_SCHEME")).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "STORAGE_UNAVAILABLE" },
|
||||
});
|
||||
expect(adapter.read("COLOR_SCHEME")).toEqual({
|
||||
ok: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes SecurityError reads and never exposes native details", () => {
|
||||
const { storage } = createStorage({ readFailure: "security" });
|
||||
const record = vi.fn();
|
||||
const adapter = createBrowserStorageAdapter({
|
||||
localStorage: storage,
|
||||
diagnostics: { record },
|
||||
});
|
||||
|
||||
const result = adapter.read("COLOR_SCHEME");
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "STORAGE_UNAVAILABLE",
|
||||
code: "COLOR_SCHEME_READ_UNAVAILABLE",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify({ result, calls: record.mock.calls })).not.toContain(
|
||||
"private security detail",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["cyclic", (() => {
|
||||
const value: { self?: unknown; secret: string } = {
|
||||
secret: "private-cyclic",
|
||||
};
|
||||
value.self = value;
|
||||
return value;
|
||||
})()],
|
||||
["BigInt", { secret: "private-bigint", value: 1n }],
|
||||
["exotic", new Date(0)],
|
||||
])("rejects %s values before calling the backend", (_label, value) => {
|
||||
const { storage, setItem } = createStorage();
|
||||
const record = vi.fn();
|
||||
const adapter = createBrowserStorageAdapter({
|
||||
localStorage: storage,
|
||||
diagnostics: { record },
|
||||
});
|
||||
|
||||
const result = adapter.write("COLOR_SCHEME", value);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "STORAGE_UNAVAILABLE",
|
||||
code: "COLOR_SCHEME_WRITE_VALUE_REJECTED",
|
||||
},
|
||||
});
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify({ result, calls: record.mock.calls })).not.toMatch(
|
||||
/private-cyclic|private-bigint/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized serialized values before persistence and redacts them", () => {
|
||||
const secret = `private-oversize-${"x".repeat(512)}`;
|
||||
const { storage, setItem } = createStorage();
|
||||
const record = vi.fn();
|
||||
const adapter = createBrowserStorageAdapter({
|
||||
sessionStorage: storage,
|
||||
diagnostics: { record },
|
||||
maxSerializedBytes: 128,
|
||||
});
|
||||
|
||||
const result = adapter.write("CHUNK_RELOAD_GUARD", secret);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "STORAGE_UNAVAILABLE",
|
||||
code: "CHUNK_RELOAD_GUARD_WRITE_SIZE_LIMIT_EXCEEDED",
|
||||
},
|
||||
});
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify({ result, calls: record.mock.calls })).not.toContain(
|
||||
secret,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
supplyChainDigest,
|
||||
validateDependencyReview,
|
||||
validateLicensePolicy,
|
||||
} from "../../scripts/lib/supply-chain.mjs";
|
||||
} from "../../scripts/lib/supply-chain.ts";
|
||||
|
||||
const integrity = `sha512-${Buffer.alloc(64, 7).toString("base64")}`;
|
||||
const dependency = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { systemClock } from "../../src/adapters/platform/system-clock.js";
|
||||
import { systemClock } from "../../src/adapters/platform/system-clock.ts";
|
||||
|
||||
describe("systemClock", () => {
|
||||
it("resolves after the requested duration", async () => {
|
||||
@@ -0,0 +1,236 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type {
|
||||
BrowserCrossContextInvalidation,
|
||||
CrossContextInvalidationDelivery,
|
||||
} from "../../src/adapters/cross-context-invalidation/index.ts";
|
||||
import { createTanStackCacheCoordinator } from "../../src/adapters/query-cache/tanstack-cache-coordinator.ts";
|
||||
import { defineQueryInvalidationTopic } from "../../src/contracts/query-invalidation.ts";
|
||||
|
||||
const TOPIC_A = defineQueryInvalidationTopic("qinv.topic-a");
|
||||
const TOPIC_B = defineQueryInvalidationTopic("qinv.topic-b");
|
||||
|
||||
function queryRegistry() {
|
||||
return Object.freeze({
|
||||
A: Object.freeze({
|
||||
namespace: Object.freeze(["resource-a", 1] as const),
|
||||
invalidationTopic: TOPIC_A,
|
||||
crossContext: "invalidate-only" as const,
|
||||
version: 1,
|
||||
persistence: "disabled" as const,
|
||||
}),
|
||||
B: Object.freeze({
|
||||
namespace: Object.freeze(["resource-b", 1] as const),
|
||||
invalidationTopic: TOPIC_B,
|
||||
crossContext: "invalidate-only" as const,
|
||||
version: 1,
|
||||
persistence: "disabled" as const,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function crossContextHarness() {
|
||||
let listener:
|
||||
| ((delivery: CrossContextInvalidationDelivery) => void)
|
||||
| undefined;
|
||||
const publish = vi.fn(() => ({
|
||||
ok: true as const,
|
||||
transport: "BROADCAST" as const,
|
||||
}));
|
||||
const close = vi.fn();
|
||||
const transport: BrowserCrossContextInvalidation = {
|
||||
getStatus: () => "ACTIVE_BROADCAST",
|
||||
publish,
|
||||
subscribe(next) {
|
||||
listener = next;
|
||||
return () => {
|
||||
listener = undefined;
|
||||
};
|
||||
},
|
||||
close,
|
||||
};
|
||||
return Object.freeze({
|
||||
transport,
|
||||
publish,
|
||||
close,
|
||||
deliver(topic: string, ordering: "NEXT" | "GAP" = "NEXT") {
|
||||
listener?.({
|
||||
ordering,
|
||||
transport: "BROADCAST",
|
||||
event: {
|
||||
protocolVersion: 1,
|
||||
eventId: `event-${topic}-${ordering}`,
|
||||
sourceId: "remote-source",
|
||||
sourceEpoch: "remote-epoch",
|
||||
sequence: 1,
|
||||
cacheEpoch: "cache-epoch",
|
||||
topic,
|
||||
topicVersion: 1,
|
||||
emittedAt: 1,
|
||||
expiresAt: 60_001,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: Infinity, gcTime: Infinity },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("TanStack cross-context cache coordinator", () => {
|
||||
it("maps a local opaque topic to one namespace and publishes no query key", async () => {
|
||||
const client = createClient();
|
||||
const harness = crossContextHarness();
|
||||
client.setQueryData(["resource-a", 1, "list"], ["a"]);
|
||||
client.setQueryData(["resource-b", 1, "list"], ["b"]);
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
|
||||
await coordinator.invalidate([TOPIC_A]);
|
||||
|
||||
expect(
|
||||
client.getQueryState(["resource-a", 1, "list"])?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(
|
||||
client.getQueryState(["resource-b", 1, "list"])?.isInvalidated,
|
||||
).toBe(false);
|
||||
expect(harness.publish).toHaveBeenCalledWith({
|
||||
topic: TOPIC_A,
|
||||
topicVersion: 1,
|
||||
});
|
||||
expect(JSON.stringify(harness.publish.mock.calls)).not.toContain(
|
||||
"resource-a",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies a remote hint without publishing an echo", async () => {
|
||||
const client = createClient();
|
||||
const harness = crossContextHarness();
|
||||
client.setQueryData(["resource-a", 1, "detail", "opaque"], {
|
||||
value: true,
|
||||
});
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
|
||||
harness.deliver(TOPIC_A);
|
||||
await vi.waitFor(() =>
|
||||
expect(
|
||||
client.getQueryState([
|
||||
"resource-a",
|
||||
1,
|
||||
"detail",
|
||||
"opaque",
|
||||
])?.isInvalidated,
|
||||
).toBe(true),
|
||||
);
|
||||
expect(harness.publish).not.toHaveBeenCalled();
|
||||
|
||||
coordinator.dispose();
|
||||
expect(harness.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("coalesces remote hints while a local mutation lease is held", async () => {
|
||||
const client = createClient();
|
||||
const harness = crossContextHarness();
|
||||
client.setQueryData(["resource-a", 1, "list"], ["a"]);
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
const lease = coordinator.beginMutation([TOPIC_A]);
|
||||
|
||||
harness.deliver(TOPIC_A);
|
||||
harness.deliver(TOPIC_A);
|
||||
await Promise.resolve();
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
|
||||
await lease.release();
|
||||
expect(invalidate).toHaveBeenCalledTimes(1);
|
||||
expect(harness.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reconciles every registered namespace when a source sequence has a gap", async () => {
|
||||
const client = createClient();
|
||||
const harness = crossContextHarness();
|
||||
client.setQueryData(["resource-a", 1, "list"], ["a"]);
|
||||
client.setQueryData(["resource-b", 1, "list"], ["b"]);
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
|
||||
harness.deliver(TOPIC_A, "GAP");
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
client.getQueryState(["resource-a", 1, "list"])?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(
|
||||
client.getQueryState(["resource-b", 1, "list"])?.isInvalidated,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("fences remote delivery until a local reset has cancelled and cleared the cache", async () => {
|
||||
const client = createClient();
|
||||
const harness = crossContextHarness();
|
||||
client.setQueryData(["resource-a", 1, "list"], ["private-old-scope"]);
|
||||
let finishCancellation: (() => void) | undefined;
|
||||
const cancelQueries = vi
|
||||
.spyOn(client, "cancelQueries")
|
||||
.mockImplementation(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishCancellation = resolve;
|
||||
}),
|
||||
);
|
||||
const invalidate = vi.spyOn(client, "invalidateQueries");
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: client,
|
||||
queryRegistry: queryRegistry(),
|
||||
crossContext: harness.transport,
|
||||
});
|
||||
|
||||
const reset = coordinator.resetLocal();
|
||||
await vi.waitFor(() => expect(cancelQueries).toHaveBeenCalledOnce());
|
||||
harness.deliver(TOPIC_A);
|
||||
await Promise.resolve();
|
||||
expect(invalidate).not.toHaveBeenCalled();
|
||||
|
||||
finishCancellation?.();
|
||||
await reset;
|
||||
expect(client.getQueryData(["resource-a", 1, "list"])).toBeUndefined();
|
||||
await vi.waitFor(() => expect(invalidate).toHaveBeenCalledOnce());
|
||||
expect(harness.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an unregistered topic before opening a mutation lease", () => {
|
||||
const coordinator = createTanStackCacheCoordinator({
|
||||
queryClient: createClient(),
|
||||
queryRegistry: queryRegistry(),
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
coordinator.beginMutation([
|
||||
defineQueryInvalidationTopic("unknown-topic"),
|
||||
]),
|
||||
).toThrow(
|
||||
"Unregistered query invalidation topic",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,11 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createTelemetryAdapter,
|
||||
safeTraceparent,
|
||||
} from "../../src/adapters/telemetry/best-effort-telemetry.js";
|
||||
} from "../../src/adapters/telemetry/best-effort-telemetry.ts";
|
||||
import {
|
||||
TELEMETRY_REGISTRY,
|
||||
projectTelemetryEvent,
|
||||
} from "../../src/contracts/telemetry.js";
|
||||
} from "../../src/contracts/telemetry.ts";
|
||||
|
||||
const validAttributes = {
|
||||
error_kind: "SERVER_FAILURE",
|
||||
@@ -67,12 +67,12 @@ describe("telemetry registry and redaction", () => {
|
||||
|
||||
describe("best-effort telemetry adapter", () => {
|
||||
it("bounds the queue using oldest-drop without blocking callers", () => {
|
||||
const scheduled = /** @type {Array<() => void>} */ ([]);
|
||||
const scheduled: Array<() => void> = [];
|
||||
const adapter = createTelemetryAdapter({
|
||||
enabled: true,
|
||||
endpoint: "https://telemetry.test/events",
|
||||
maxQueue: 2,
|
||||
schedule: (callback) => scheduled.push(callback),
|
||||
schedule: (callback: () => void) => scheduled.push(callback),
|
||||
fetcher: vi.fn(),
|
||||
});
|
||||
|
||||
@@ -111,13 +111,70 @@ describe("best-effort telemetry adapter", () => {
|
||||
expect(adapter.pendingCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("reschedules events whose flush callback runs during an active delivery", async () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const deliveredRouteIds: unknown[][] = [];
|
||||
let releaseFirstDelivery: () => void = () => {};
|
||||
const firstDelivery = new Promise<void>((resolve) => {
|
||||
releaseFirstDelivery = resolve;
|
||||
});
|
||||
let deliveryCount = 0;
|
||||
const fetcher = vi.fn(async (_endpoint: RequestInfo | URL, init?: RequestInit) => {
|
||||
deliveryCount += 1;
|
||||
const body = JSON.parse(String(init?.body));
|
||||
deliveredRouteIds.push(
|
||||
(body.events as Array<{ attributes: { route_id: unknown } }>).map(
|
||||
(event) => event.attributes.route_id,
|
||||
),
|
||||
);
|
||||
if (deliveryCount === 1) {
|
||||
await firstDelivery;
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
const adapter = createTelemetryAdapter({
|
||||
enabled: true,
|
||||
endpoint: "https://telemetry.test/events",
|
||||
schedule: (callback: () => void) => scheduled.push(callback),
|
||||
fetcher,
|
||||
});
|
||||
const runScheduled = () => {
|
||||
const callback = scheduled.shift();
|
||||
if (!callback) throw new Error("Expected a scheduled telemetry flush");
|
||||
callback();
|
||||
};
|
||||
|
||||
adapter.emit("api.request.failed", {
|
||||
...validAttributes,
|
||||
route_id: "FIRST_ROUTE",
|
||||
});
|
||||
runScheduled();
|
||||
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledOnce());
|
||||
|
||||
adapter.emit("api.request.failed", {
|
||||
...validAttributes,
|
||||
route_id: "SECOND_ROUTE",
|
||||
});
|
||||
runScheduled();
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
expect(adapter.pendingCount()).toBe(1);
|
||||
|
||||
releaseFirstDelivery();
|
||||
await vi.waitFor(() => expect(scheduled).toHaveLength(1));
|
||||
runScheduled();
|
||||
await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
|
||||
|
||||
expect(adapter.pendingCount()).toBe(0);
|
||||
expect(deliveredRouteIds).toEqual([["FIRST_ROUTE"], ["SECOND_ROUTE"]]);
|
||||
});
|
||||
|
||||
it("never serializes circular or unbounded event context", () => {
|
||||
const adapter = createTelemetryAdapter({
|
||||
enabled: true,
|
||||
endpoint: "https://telemetry.test/events",
|
||||
schedule: () => {},
|
||||
});
|
||||
const circular = {};
|
||||
const circular: Record<string, unknown> = {};
|
||||
circular.self = circular;
|
||||
const hostile = new Proxy(
|
||||
{},
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createBrowserUploadCancellationChannel,
|
||||
type UploadCancellationBroadcastFacade,
|
||||
} from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts";
|
||||
|
||||
type MessageListener = (
|
||||
event: Readonly<{ data: unknown }>,
|
||||
) => void;
|
||||
|
||||
function createBroadcastHarness() {
|
||||
const listeners: Array<Set<MessageListener>> = [];
|
||||
const closes: Array<ReturnType<typeof vi.fn>> = [];
|
||||
|
||||
function createChannel(): UploadCancellationBroadcastFacade {
|
||||
const ownIndex = listeners.length;
|
||||
const ownListeners = new Set<MessageListener>();
|
||||
const close = vi.fn();
|
||||
listeners.push(ownListeners);
|
||||
closes.push(close);
|
||||
return {
|
||||
postMessage(message) {
|
||||
for (const [index, peerListeners] of listeners.entries()) {
|
||||
if (index === ownIndex) continue;
|
||||
for (const listener of [...peerListeners]) {
|
||||
listener({ data: structuredClone(message) });
|
||||
}
|
||||
}
|
||||
},
|
||||
addEventListener(_type, listener) {
|
||||
ownListeners.add(listener);
|
||||
},
|
||||
removeEventListener(_type, listener) {
|
||||
ownListeners.delete(listener);
|
||||
},
|
||||
close,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
createChannel,
|
||||
emit(index: number, data: unknown) {
|
||||
for (const listener of [...(listeners[index] ?? [])]) {
|
||||
listener({ data });
|
||||
}
|
||||
},
|
||||
listenerCount(index: number) {
|
||||
return listeners[index]?.size ?? 0;
|
||||
},
|
||||
closes,
|
||||
};
|
||||
}
|
||||
|
||||
describe("browser upload cancellation channel", () => {
|
||||
it("delivers only the closed v1 wire message to another context", () => {
|
||||
const harness = createBroadcastHarness();
|
||||
const first = createBrowserUploadCancellationChannel({
|
||||
channelName: "product-upload-cancel-v1",
|
||||
createChannel: harness.createChannel,
|
||||
});
|
||||
const second = createBrowserUploadCancellationChannel({
|
||||
channelName: "product-upload-cancel-v1",
|
||||
createChannel: harness.createChannel,
|
||||
});
|
||||
expect(first).toBeDefined();
|
||||
expect(second).toBeDefined();
|
||||
if (!first || !second) return;
|
||||
|
||||
const received = vi.fn();
|
||||
const release = second.subscribe(received);
|
||||
expect(first.publish("upload_key_cross_context")).toBe(true);
|
||||
expect(received).toHaveBeenCalledOnce();
|
||||
expect(received).toHaveBeenCalledWith(
|
||||
"upload_key_cross_context",
|
||||
);
|
||||
|
||||
for (const malformed of [
|
||||
null,
|
||||
{
|
||||
protocol: "RESUMABLE_UPLOAD_CANCEL_V0",
|
||||
uploadKey: "upload_key_cross_context",
|
||||
},
|
||||
{
|
||||
protocol: "RESUMABLE_UPLOAD_CANCEL_V1",
|
||||
uploadKey: "short",
|
||||
},
|
||||
{
|
||||
protocol: "RESUMABLE_UPLOAD_CANCEL_V1",
|
||||
uploadKey: "upload_key_cross_context",
|
||||
extra: true,
|
||||
},
|
||||
]) {
|
||||
harness.emit(1, malformed);
|
||||
}
|
||||
expect(received).toHaveBeenCalledTimes(1);
|
||||
|
||||
release();
|
||||
second.close();
|
||||
expect(harness.listenerCount(1)).toBe(0);
|
||||
expect(harness.closes[1]).toHaveBeenCalledOnce();
|
||||
expect(second.publish("upload_key_cross_context")).toBe(false);
|
||||
first.close();
|
||||
});
|
||||
|
||||
it("fails closed when the host is unsupported and rejects invalid namespaces", () => {
|
||||
expect(
|
||||
createBrowserUploadCancellationChannel({ host: {} }),
|
||||
).toBeUndefined();
|
||||
expect(() =>
|
||||
createBrowserUploadCancellationChannel({
|
||||
channelName: "invalid channel name",
|
||||
host: {},
|
||||
}),
|
||||
).toThrow(/channel name/u);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
WEB_PUSH_PROTOCOLS,
|
||||
webPushSuccess,
|
||||
} from "../../src/contracts/web-push.ts";
|
||||
import {
|
||||
clickDataFromHint,
|
||||
decodeNotificationClickData,
|
||||
decodeWebPushHint,
|
||||
} from "../../src/adapters/web-push/push-codec.ts";
|
||||
import {
|
||||
createAssociationNotificationTag,
|
||||
createWebPushNotificationRegistry,
|
||||
} from "../../src/adapters/web-push/notification-registry.ts";
|
||||
import {
|
||||
WEB_PUSH_REGISTRATION_OPERATIONS,
|
||||
createWebPushRegistrationGateway,
|
||||
type WebPushRegistrationExecutor,
|
||||
} from "../../src/adapters/web-push/push-registration-gateway.ts";
|
||||
|
||||
const now = Date.parse("2026-07-28T00:00:00.000Z");
|
||||
|
||||
function hint(overrides: Readonly<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
protocol: WEB_PUSH_PROTOCOLS.hint,
|
||||
notificationType: "INBOX_ACTIVITY",
|
||||
notificationId: "notification_01",
|
||||
associationEpoch: "association_01",
|
||||
releaseEpoch: "release_01",
|
||||
issuedAt: "2026-07-27T23:59:00.000Z",
|
||||
expiresAt: "2026-07-28T01:00:00.000Z",
|
||||
routeIntent: "OPEN_INBOX",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function encoded(value: unknown): Uint8Array {
|
||||
return new TextEncoder().encode(JSON.stringify(value));
|
||||
}
|
||||
|
||||
describe("Web Push contracts", () => {
|
||||
it("decodes an exact bounded hint and persists only typed click data", () => {
|
||||
const decoded = decodeWebPushHint(encoded(hint()), now);
|
||||
expect(decoded).toEqual({ ok: true, value: hint() });
|
||||
if (!decoded.ok) throw new Error("expected a decoded hint");
|
||||
|
||||
const click = clickDataFromHint(decoded.value);
|
||||
expect(decodeNotificationClickData(click, now)).toEqual({
|
||||
ok: true,
|
||||
value: click,
|
||||
});
|
||||
expect(click).not.toHaveProperty("notificationType");
|
||||
expect(click).not.toHaveProperty("issuedAt");
|
||||
});
|
||||
|
||||
it("fails closed for declarative, unknown, expired, and oversized payloads", () => {
|
||||
expect(
|
||||
decodeWebPushHint(encoded({ web_push: 8030 }), now),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DECLARATIVE_PUSH_FORBIDDEN" },
|
||||
});
|
||||
expect(
|
||||
decodeWebPushHint(encoded(hint({ unexpected: true })), now),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "CONTRACT_REJECTED" },
|
||||
});
|
||||
expect(
|
||||
decodeWebPushHint(
|
||||
encoded(
|
||||
hint({
|
||||
issuedAt: "2026-07-27T22:00:00.000Z",
|
||||
expiresAt: "2026-07-27T23:00:00.000Z",
|
||||
}),
|
||||
),
|
||||
now,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "EXPIRED" },
|
||||
});
|
||||
expect(
|
||||
decodeWebPushHint(new Uint8Array(3 * 1024 + 1), now),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "LIMIT_EXCEEDED" },
|
||||
});
|
||||
expect(
|
||||
decodeWebPushHint(
|
||||
encoded(
|
||||
hint({
|
||||
issuedAt: "2026-07-28T00:06:00.000Z",
|
||||
expiresAt: "2026-07-28T01:00:00.000Z",
|
||||
}),
|
||||
),
|
||||
now,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "CONTRACT_REJECTED" },
|
||||
});
|
||||
expect(
|
||||
decodeWebPushHint(
|
||||
encoded(
|
||||
hint({
|
||||
issuedAt: "2026-07-28T00:00:00.000Z",
|
||||
expiresAt: "2026-07-29T00:00:00.001Z",
|
||||
}),
|
||||
),
|
||||
now,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "CONTRACT_REJECTED" },
|
||||
});
|
||||
const duplicateNotificationId = JSON.stringify(hint()).replace(
|
||||
'"notificationId":"notification_01"',
|
||||
'"notificationId":"notification_old","notificationId":"notification_01"',
|
||||
);
|
||||
expect(
|
||||
decodeWebPushHint(
|
||||
new TextEncoder().encode(duplicateNotificationId),
|
||||
now,
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "CONTRACT_REJECTED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an injected closed notification and route registry", async () => {
|
||||
const registry = createWebPushNotificationRegistry([
|
||||
{
|
||||
notificationType: "INBOX_ACTIVITY",
|
||||
routeIntent: "OPEN_INBOX",
|
||||
title: "새 알림이 있습니다",
|
||||
body: "앱을 열어 최신 내용을 확인하세요.",
|
||||
path: "/inbox",
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
registry.resolve("INBOX_ACTIVITY", "OPEN_INBOX"),
|
||||
).toMatchObject({ path: "/inbox" });
|
||||
expect(registry.resolve("UNKNOWN", "OPEN_INBOX")).toBeNull();
|
||||
expect(registry.routePath("OPEN_INBOX")).toBe("/inbox");
|
||||
|
||||
const tag = await createAssociationNotificationTag(
|
||||
"association_01",
|
||||
"INBOX_ACTIVITY",
|
||||
async () => new Uint8Array(32).fill(7).buffer,
|
||||
);
|
||||
expect(tag).toMatch(/^ca-push-v1-[0-9a-f]{24}-inbox_activity$/u);
|
||||
expect(tag).not.toContain("association_01");
|
||||
});
|
||||
|
||||
it("binds backend calls to fixed operations and strict response codecs", async () => {
|
||||
const calls: Array<Readonly<{ operationId: string; body: unknown }>> = [];
|
||||
const executor: WebPushRegistrationExecutor = {
|
||||
async execute(input) {
|
||||
calls.push(input);
|
||||
switch (input.operationId) {
|
||||
case WEB_PUSH_REGISTRATION_OPERATIONS.register:
|
||||
return webPushSuccess({
|
||||
protocol: WEB_PUSH_PROTOCOLS.registration,
|
||||
associationEpoch: "association_01",
|
||||
sessionBindingEpoch: "session_01",
|
||||
});
|
||||
case WEB_PUSH_REGISTRATION_OPERATIONS.reconcile:
|
||||
return webPushSuccess({
|
||||
protocol: WEB_PUSH_PROTOCOLS.reconciliation,
|
||||
state: "ABSENT",
|
||||
});
|
||||
case WEB_PUSH_REGISTRATION_OPERATIONS.revoke:
|
||||
return webPushSuccess({
|
||||
protocol: WEB_PUSH_PROTOCOLS.revoke,
|
||||
state: "ALREADY_GONE",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
const gateway = createWebPushRegistrationGateway(executor);
|
||||
const p256dh = new Uint8Array(65);
|
||||
p256dh[0] = 4;
|
||||
const material = {
|
||||
endpoint: "https://push.example.test/subscription/opaque",
|
||||
p256dh: Buffer.from(p256dh).toString("base64url"),
|
||||
auth: Buffer.from(new Uint8Array(16)).toString("base64url"),
|
||||
expirationTime: null,
|
||||
};
|
||||
const authority = {
|
||||
fenceGeneration: "fence_01",
|
||||
sessionBindingEpoch: "session_01",
|
||||
releaseEpoch: "release_01",
|
||||
};
|
||||
|
||||
expect(
|
||||
await gateway.register({
|
||||
material,
|
||||
authority,
|
||||
idempotencyKey: "idempotency_01",
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
associationEpoch: "association_01",
|
||||
sessionBindingEpoch: "session_01",
|
||||
},
|
||||
});
|
||||
expect(await gateway.reconcile({ material, authority })).toEqual({
|
||||
ok: true,
|
||||
value: { state: "ABSENT" },
|
||||
});
|
||||
expect(
|
||||
await gateway.revoke({ associationEpoch: "association_01" }),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: { state: "ALREADY_GONE" },
|
||||
});
|
||||
expect(calls.map((call) => call.operationId)).toEqual([
|
||||
WEB_PUSH_REGISTRATION_OPERATIONS.register,
|
||||
WEB_PUSH_REGISTRATION_OPERATIONS.reconcile,
|
||||
WEB_PUSH_REGISTRATION_OPERATIONS.revoke,
|
||||
]);
|
||||
expect(
|
||||
await gateway.register({
|
||||
material: {
|
||||
...material,
|
||||
p256dh: Buffer.from(new Uint8Array(65)).toString(
|
||||
"base64url",
|
||||
),
|
||||
},
|
||||
authority,
|
||||
idempotencyKey: "idempotency_02",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_INPUT" },
|
||||
});
|
||||
const throwing = createWebPushRegistrationGateway({
|
||||
async execute() {
|
||||
throw new Error("native transport detail must not escape");
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await throwing.revoke({
|
||||
associationEpoch: "association_01",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "NATIVE_FAILURE" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
WEB_PUSH_PROTOCOLS,
|
||||
} from "../../src/contracts/web-push.ts";
|
||||
import {
|
||||
createPushAssociationFenceStore,
|
||||
} from "../../src/adapters/web-push/push-association-fence-store.ts";
|
||||
import {
|
||||
createFakePushControlStoreDependencies,
|
||||
} from "../helpers/fake-push-control-repository.ts";
|
||||
|
||||
const firstAuthority = Object.freeze({
|
||||
fenceGeneration: "fence_01",
|
||||
sessionBindingEpoch: "session_01",
|
||||
releaseEpoch: "release_01",
|
||||
});
|
||||
const nextAuthority = Object.freeze({
|
||||
fenceGeneration: "fence_02",
|
||||
sessionBindingEpoch: "session_02",
|
||||
releaseEpoch: "release_01",
|
||||
});
|
||||
|
||||
function manualScheduler() {
|
||||
let sequence = 0;
|
||||
const callbacks = new Map<number, () => void>();
|
||||
return {
|
||||
scheduler: {
|
||||
setTimeout(callback: () => void) {
|
||||
sequence += 1;
|
||||
callbacks.set(sequence, callback);
|
||||
return sequence;
|
||||
},
|
||||
clearTimeout(handle: unknown) {
|
||||
if (typeof handle === "number") callbacks.delete(handle);
|
||||
},
|
||||
},
|
||||
expireAll() {
|
||||
for (const callback of [...callbacks.values()]) callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Web Push durable control fence", () => {
|
||||
it("CASes UNASSOCIATED to ACTIVE and prevents tombstone resurrection", async () => {
|
||||
const store = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
);
|
||||
const prepared = await store.prepare({
|
||||
authority: firstAuthority,
|
||||
updatedAt: "2026-07-28T00:00:00.000Z",
|
||||
});
|
||||
expect(prepared).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
revision: 1,
|
||||
control: {
|
||||
association: { state: "UNASSOCIATED" },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!prepared.ok) throw new Error("expected prepared control");
|
||||
expect(prepared.value.control).not.toHaveProperty("revision");
|
||||
|
||||
const active = await store.activate({
|
||||
expectedRevision: prepared.value.revision,
|
||||
authority: firstAuthority,
|
||||
associationEpoch: "association_01",
|
||||
updatedAt: "2026-07-28T00:00:01.000Z",
|
||||
});
|
||||
expect(active).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
revision: 2,
|
||||
control: {
|
||||
association: {
|
||||
state: "ACTIVE",
|
||||
associationEpoch: "association_01",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!active.ok) throw new Error("expected active control");
|
||||
|
||||
const revoked = await store.markRevoked({
|
||||
expectedRevision: active.value.revision,
|
||||
authority: firstAuthority,
|
||||
updatedAt: "2026-07-28T00:00:02.000Z",
|
||||
});
|
||||
expect(revoked).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
control: { association: { state: "REVOKED" } },
|
||||
},
|
||||
});
|
||||
if (!revoked.ok) throw new Error("expected revoked control");
|
||||
|
||||
expect(
|
||||
await store.activate({
|
||||
expectedRevision: revoked.value.revision,
|
||||
authority: firstAuthority,
|
||||
associationEpoch: "association_01",
|
||||
updatedAt: "2026-07-28T00:00:03.000Z",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "TOMBSTONE_CONFLICT" },
|
||||
});
|
||||
expect(
|
||||
await store.activate({
|
||||
expectedRevision: revoked.value.revision,
|
||||
authority: firstAuthority,
|
||||
associationEpoch: "association_02",
|
||||
updatedAt: "2026-07-28T00:00:04.000Z",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
control: {
|
||||
association: {
|
||||
state: "ACTIVE",
|
||||
associationEpoch: "association_02",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lets logout generation rotation win an in-flight registration CAS", async () => {
|
||||
const dependencies = createFakePushControlStoreDependencies();
|
||||
const store = createPushAssociationFenceStore(dependencies);
|
||||
const prepared = await store.prepare({
|
||||
authority: firstAuthority,
|
||||
updatedAt: "2026-07-28T00:00:00.000Z",
|
||||
});
|
||||
if (!prepared.ok) throw new Error("expected prepared control");
|
||||
|
||||
const paused = dependencies.repository.pauseNextWrite(
|
||||
(control) => control.association.state === "ACTIVE",
|
||||
);
|
||||
const lateActivation = store.activate({
|
||||
expectedRevision: prepared.value.revision,
|
||||
authority: firstAuthority,
|
||||
associationEpoch: "association_late",
|
||||
updatedAt: "2026-07-28T00:00:02.000Z",
|
||||
});
|
||||
await paused.reached;
|
||||
|
||||
const fenced = await store.rotateAndRevoke({
|
||||
expectedRevision: prepared.value.revision,
|
||||
previousAuthority: firstAuthority,
|
||||
nextAuthority,
|
||||
updatedAt: "2026-07-28T00:00:01.000Z",
|
||||
});
|
||||
expect(fenced).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
revision: 2,
|
||||
control: {
|
||||
fenceGeneration: "fence_02",
|
||||
association: { state: "UNASSOCIATED" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
paused.release();
|
||||
expect(await lateActivation).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "STALE_REVISION" },
|
||||
});
|
||||
expect(await store.read()).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
revision: 2,
|
||||
control: {
|
||||
fenceGeneration: "fence_02",
|
||||
association: { state: "UNASSOCIATED" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("purges only the captured revoked association and cannot delete a newer owner", async () => {
|
||||
const dependencies = createFakePushControlStoreDependencies();
|
||||
const store = createPushAssociationFenceStore(dependencies);
|
||||
const prepared = await store.prepare({
|
||||
authority: firstAuthority,
|
||||
updatedAt: "2026-07-28T00:00:00.000Z",
|
||||
});
|
||||
if (!prepared.ok) throw new Error("expected prepared control");
|
||||
const active = await store.activate({
|
||||
expectedRevision: prepared.value.revision,
|
||||
authority: firstAuthority,
|
||||
associationEpoch: "association_old",
|
||||
updatedAt: "2026-07-28T00:00:01.000Z",
|
||||
});
|
||||
if (!active.ok) throw new Error("expected active control");
|
||||
const revoked = await store.markRevoked({
|
||||
expectedRevision: active.value.revision,
|
||||
authority: firstAuthority,
|
||||
updatedAt: "2026-07-28T00:00:02.000Z",
|
||||
});
|
||||
if (!revoked.ok) throw new Error("expected revoked control");
|
||||
|
||||
expect(
|
||||
await store.purge({
|
||||
expectedRevision: revoked.value.revision,
|
||||
authority: firstAuthority,
|
||||
associationEpoch: "association_wrong",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ASSOCIATION_MISMATCH" },
|
||||
});
|
||||
|
||||
const paused = dependencies.repository.pauseNextRemove();
|
||||
const latePurge = store.purge({
|
||||
expectedRevision: revoked.value.revision,
|
||||
authority: firstAuthority,
|
||||
associationEpoch: "association_old",
|
||||
});
|
||||
await paused.reached;
|
||||
const newer = await store.activate({
|
||||
expectedRevision: revoked.value.revision,
|
||||
authority: firstAuthority,
|
||||
associationEpoch: "association_new",
|
||||
updatedAt: "2026-07-28T00:00:03.000Z",
|
||||
});
|
||||
expect(newer).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
control: {
|
||||
association: {
|
||||
state: "ACTIVE",
|
||||
associationEpoch: "association_new",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
paused.release();
|
||||
await expect(latePurge).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "STALE_REVISION" },
|
||||
});
|
||||
expect(await store.read()).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
control: {
|
||||
association: {
|
||||
state: "ACTIVE",
|
||||
associationEpoch: "association_new",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the exact revoked tombstone with revision CAS", async () => {
|
||||
const store = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
);
|
||||
const prepared = await store.prepare({
|
||||
authority: firstAuthority,
|
||||
updatedAt: "2026-07-28T00:00:00.000Z",
|
||||
});
|
||||
if (!prepared.ok) throw new Error("expected prepared control");
|
||||
const active = await store.activate({
|
||||
expectedRevision: prepared.value.revision,
|
||||
authority: firstAuthority,
|
||||
associationEpoch: "association_01",
|
||||
updatedAt: "2026-07-28T00:00:01.000Z",
|
||||
});
|
||||
if (!active.ok) throw new Error("expected active control");
|
||||
const revoked = await store.markRevoked({
|
||||
expectedRevision: active.value.revision,
|
||||
authority: firstAuthority,
|
||||
updatedAt: "2026-07-28T00:00:02.000Z",
|
||||
});
|
||||
if (!revoked.ok) throw new Error("expected revoked control");
|
||||
|
||||
expect(
|
||||
await store.purge({
|
||||
expectedRevision: revoked.value.revision,
|
||||
authority: firstAuthority,
|
||||
associationEpoch: "association_01",
|
||||
}),
|
||||
).toEqual({ ok: true, value: undefined });
|
||||
expect(await store.read()).toEqual({ ok: true, value: null });
|
||||
});
|
||||
|
||||
it("fails closed when the injected repository returns a polluted record", async () => {
|
||||
const dependencies = createFakePushControlStoreDependencies();
|
||||
dependencies.repository.seedRaw(
|
||||
{
|
||||
protocol: WEB_PUSH_PROTOCOLS.control,
|
||||
...firstAuthority,
|
||||
updatedAt: "2026-07-28T00:00:00.000Z",
|
||||
association: { state: "UNASSOCIATED" },
|
||||
endpoint: "https://must-not-persist.invalid",
|
||||
},
|
||||
7,
|
||||
);
|
||||
const store = createPushAssociationFenceStore(dependencies);
|
||||
|
||||
expect(await store.read()).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "CONTROL_CORRUPT" },
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts a late repository CAS at the hard operation deadline", async () => {
|
||||
const dependencies = createFakePushControlStoreDependencies();
|
||||
const clock = manualScheduler();
|
||||
const store = createPushAssociationFenceStore({
|
||||
...dependencies,
|
||||
operationDeadlineMs: 1,
|
||||
scheduler: clock.scheduler,
|
||||
});
|
||||
const prepared = await store.prepare({
|
||||
authority: firstAuthority,
|
||||
updatedAt: "2026-07-28T00:00:00.000Z",
|
||||
});
|
||||
if (!prepared.ok) throw new Error("expected prepared control");
|
||||
const paused = dependencies.repository.pauseNextWrite(
|
||||
(control) => control.association.state === "ACTIVE",
|
||||
);
|
||||
const activation = store.activate({
|
||||
expectedRevision: prepared.value.revision,
|
||||
authority: firstAuthority,
|
||||
associationEpoch: "association_late",
|
||||
updatedAt: "2026-07-28T00:00:01.000Z",
|
||||
});
|
||||
await paused.reached;
|
||||
|
||||
clock.expireAll();
|
||||
expect(await activation).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEADLINE_EXCEEDED" },
|
||||
});
|
||||
paused.release();
|
||||
expect(await store.read()).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
revision: 1,
|
||||
control: { association: { state: "UNASSOCIATED" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
withAbortableDeadline,
|
||||
type TimeoutScheduler,
|
||||
} from "../../src/adapters/web-push/runtime-support.ts";
|
||||
import {
|
||||
webPushSuccess,
|
||||
} from "../../src/contracts/web-push.ts";
|
||||
|
||||
describe("Web Push runtime deadlines", () => {
|
||||
it("maps a scheduler setup failure without starting the task", async () => {
|
||||
const task = vi.fn(async () => webPushSuccess(undefined));
|
||||
const scheduler: TimeoutScheduler = {
|
||||
setTimeout() {
|
||||
throw new Error("scheduler unavailable");
|
||||
},
|
||||
clearTimeout: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
withAbortableDeadline(task, {
|
||||
deadlineMs: 10,
|
||||
operation: "PUSH_HANDLE",
|
||||
scheduler,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "NATIVE_FAILURE",
|
||||
operation: "PUSH_HANDLE",
|
||||
},
|
||||
});
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let timer cleanup replace a settled result", async () => {
|
||||
const scheduler: TimeoutScheduler = {
|
||||
setTimeout: () => Object.freeze({ handle: true }),
|
||||
clearTimeout() {
|
||||
throw new Error("cleanup unavailable");
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
withAbortableDeadline(
|
||||
async () => webPushSuccess("settled"),
|
||||
{
|
||||
deadlineMs: 10,
|
||||
operation: "PUSH_HANDLE",
|
||||
scheduler,
|
||||
},
|
||||
),
|
||||
).resolves.toEqual(webPushSuccess("settled"));
|
||||
});
|
||||
|
||||
it("rechecks caller cancellation after listener registration", async () => {
|
||||
let abortReads = 0;
|
||||
const signal = {
|
||||
get aborted() {
|
||||
abortReads += 1;
|
||||
return abortReads >= 2;
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as AbortSignal;
|
||||
const task = vi.fn(async () => webPushSuccess(undefined));
|
||||
const scheduler: TimeoutScheduler = {
|
||||
setTimeout: () => Object.freeze({ handle: true }),
|
||||
clearTimeout: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
withAbortableDeadline(task, {
|
||||
deadlineMs: 10,
|
||||
operation: "PUSH_HANDLE",
|
||||
scheduler,
|
||||
signal,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED", operation: "PUSH_HANDLE" },
|
||||
});
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,830 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { WEB_PUSH_PROTOCOLS, webPushSuccess } from "../../src/contracts/web-push.ts";
|
||||
import { createPushAssociationFenceStore } from "../../src/adapters/web-push/push-association-fence-store.ts";
|
||||
import {
|
||||
createWebPushSubscriptionAdapter,
|
||||
type WindowPushSubscriptionFacade,
|
||||
} from "../../src/adapters/web-push/push-subscription-adapter.ts";
|
||||
import type { WebPushRegistrationGateway } from "../../src/adapters/web-push/push-registration-gateway.ts";
|
||||
import type { TimeoutScheduler } from "../../src/adapters/web-push/runtime-support.ts";
|
||||
import {
|
||||
createFakePushControlStoreDependencies,
|
||||
} from "../helpers/fake-push-control-repository.ts";
|
||||
|
||||
const now = Date.parse("2026-07-28T00:00:00.000Z");
|
||||
const authority = Object.freeze({
|
||||
fenceGeneration: "fence_01",
|
||||
sessionBindingEpoch: "session_01",
|
||||
releaseEpoch: "release_01",
|
||||
});
|
||||
const nextAuthority = Object.freeze({
|
||||
fenceGeneration: "fence_02",
|
||||
sessionBindingEpoch: "session_02",
|
||||
releaseEpoch: "release_01",
|
||||
});
|
||||
|
||||
function bytes(length: number, first?: number): Uint8Array<ArrayBuffer> {
|
||||
const value = new Uint8Array(new ArrayBuffer(length));
|
||||
value.fill(7);
|
||||
if (first !== undefined) value[0] = first;
|
||||
return value;
|
||||
}
|
||||
|
||||
const applicationServerKey = bytes(65, 4);
|
||||
const vapidPublicKey = Buffer.from(applicationServerKey).toString("base64url");
|
||||
|
||||
function subscription(
|
||||
unsubscribe = vi.fn(async () => true),
|
||||
): WindowPushSubscriptionFacade {
|
||||
return {
|
||||
endpoint: "https://push.example.test/subscription/opaque",
|
||||
expirationTime: null,
|
||||
options: {
|
||||
applicationServerKey: applicationServerKey.slice().buffer,
|
||||
},
|
||||
getKey(name) {
|
||||
return name === "p256dh"
|
||||
? bytes(65).buffer
|
||||
: bytes(16).buffer;
|
||||
},
|
||||
unsubscribe,
|
||||
};
|
||||
}
|
||||
|
||||
function gateway(
|
||||
overrides: Partial<WebPushRegistrationGateway> = {},
|
||||
): WebPushRegistrationGateway {
|
||||
return {
|
||||
register: async () =>
|
||||
webPushSuccess({
|
||||
associationEpoch: "association_01",
|
||||
sessionBindingEpoch: "session_01",
|
||||
}),
|
||||
reconcile: async () =>
|
||||
webPushSuccess({
|
||||
state: "ACTIVE",
|
||||
associationEpoch: "association_01",
|
||||
sessionBindingEpoch: "session_01",
|
||||
}),
|
||||
revoke: async () => webPushSuccess({ state: "REVOKED" }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function activeFenceStore() {
|
||||
const store = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
);
|
||||
const prepared = await store.prepare({
|
||||
authority,
|
||||
updatedAt: "2026-07-28T00:00:00.000Z",
|
||||
});
|
||||
if (!prepared.ok) throw new Error("expected prepared control");
|
||||
const active = await store.activate({
|
||||
expectedRevision: prepared.value.revision,
|
||||
authority,
|
||||
associationEpoch: "association_01",
|
||||
updatedAt: "2026-07-28T00:00:01.000Z",
|
||||
});
|
||||
if (!active.ok) throw new Error("expected active control");
|
||||
return store;
|
||||
}
|
||||
|
||||
function manualScheduler(): Readonly<{
|
||||
scheduler: TimeoutScheduler;
|
||||
expireAll(): void;
|
||||
}> {
|
||||
let sequence = 0;
|
||||
const callbacks = new Map<number, () => void>();
|
||||
return {
|
||||
scheduler: {
|
||||
setTimeout(callback) {
|
||||
sequence += 1;
|
||||
callbacks.set(sequence, callback);
|
||||
return sequence;
|
||||
},
|
||||
clearTimeout(handle) {
|
||||
if (typeof handle === "number") callbacks.delete(handle);
|
||||
},
|
||||
},
|
||||
expireAll() {
|
||||
for (const callback of [...callbacks.values()]) callback();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Web Push window subscription adapter", () => {
|
||||
it("requests permission only from an explicit action, registers, fences, and inspects READY", async () => {
|
||||
const fenceStore = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
);
|
||||
const native = subscription();
|
||||
let current: WindowPushSubscriptionFacade | null = null;
|
||||
const subscribe = vi.fn(async (input) => {
|
||||
expect(input.userVisibleOnly).toBe(true);
|
||||
expect([...input.applicationServerKey]).toEqual([
|
||||
...applicationServerKey,
|
||||
]);
|
||||
current = native;
|
||||
return native;
|
||||
});
|
||||
let permission: "default" | "granted" = "default";
|
||||
const requestPermission = vi.fn(async () => {
|
||||
permission = "granted";
|
||||
return permission;
|
||||
});
|
||||
const register = vi.fn(gateway().register);
|
||||
const adapter = createWebPushSubscriptionAdapter({
|
||||
secureContext: true,
|
||||
userActivationIsActive: () => true,
|
||||
permission: {
|
||||
permission: () => permission,
|
||||
requestPermission,
|
||||
},
|
||||
registration: {
|
||||
active: true,
|
||||
pushManager: {
|
||||
getSubscription: async () => current,
|
||||
subscribe,
|
||||
},
|
||||
getNotifications: async () => [],
|
||||
},
|
||||
fenceStore,
|
||||
gateway: gateway({ register }),
|
||||
vapidPublicKey,
|
||||
now: () => now,
|
||||
idempotencyKeyFactory: () => "idempotency_01",
|
||||
});
|
||||
|
||||
expect(
|
||||
await adapter.enable({
|
||||
authority,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toEqual({ ok: true, value: { state: "PUSH_READY" } });
|
||||
expect(requestPermission).toHaveBeenCalledOnce();
|
||||
expect(subscribe).toHaveBeenCalledOnce();
|
||||
expect(register).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
await adapter.inspect({ authority }),
|
||||
).toEqual({ ok: true, value: { state: "PUSH_READY" } });
|
||||
const control = await fenceStore.read();
|
||||
expect(control).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
control: {
|
||||
association: {
|
||||
state: "ACTIVE",
|
||||
associationEpoch: "association_01",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps denied and absent-user-activation flows side-effect free", async () => {
|
||||
const fenceStore = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
);
|
||||
const subscribe = vi.fn(async () => subscription());
|
||||
const register = vi.fn(gateway().register);
|
||||
const adapter = createWebPushSubscriptionAdapter({
|
||||
secureContext: true,
|
||||
userActivationIsActive: () => false,
|
||||
permission: {
|
||||
permission: () => "default",
|
||||
requestPermission: async () => "denied",
|
||||
},
|
||||
registration: {
|
||||
active: true,
|
||||
pushManager: {
|
||||
getSubscription: async () => null,
|
||||
subscribe,
|
||||
},
|
||||
getNotifications: async () => [],
|
||||
},
|
||||
fenceStore,
|
||||
gateway: gateway({ register }),
|
||||
vapidPublicKey,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
expect(
|
||||
await adapter.enable({
|
||||
authority,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "PERMISSION_DENIED" },
|
||||
});
|
||||
expect(subscribe).not.toHaveBeenCalled();
|
||||
expect(register).not.toHaveBeenCalled();
|
||||
expect(await fenceStore.read()).toEqual({ ok: true, value: null });
|
||||
});
|
||||
|
||||
it("compensates a stale backend session binding without activating local authority", async () => {
|
||||
const fenceStore = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
);
|
||||
const unsubscribe = vi.fn(async () => true);
|
||||
const native = subscription(unsubscribe);
|
||||
const revoke = vi.fn(gateway().revoke);
|
||||
const adapter = createWebPushSubscriptionAdapter({
|
||||
secureContext: true,
|
||||
userActivationIsActive: () => true,
|
||||
permission: {
|
||||
permission: () => "granted",
|
||||
requestPermission: async () => "granted",
|
||||
},
|
||||
registration: {
|
||||
active: true,
|
||||
pushManager: {
|
||||
getSubscription: async () => native,
|
||||
subscribe: async () => native,
|
||||
},
|
||||
getNotifications: async () => [],
|
||||
},
|
||||
fenceStore,
|
||||
gateway: gateway({
|
||||
register: async () =>
|
||||
webPushSuccess({
|
||||
associationEpoch: "association_stale",
|
||||
sessionBindingEpoch: "session_old",
|
||||
}),
|
||||
revoke,
|
||||
}),
|
||||
vapidPublicKey,
|
||||
now: () => now,
|
||||
idempotencyKeyFactory: () => "idempotency_01",
|
||||
});
|
||||
|
||||
expect(
|
||||
await adapter.enable({
|
||||
authority,
|
||||
signal: new AbortController().signal,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "PUSH_UNAVAILABLE",
|
||||
reason: "SESSION_AUTHORITY_CHANGED",
|
||||
},
|
||||
});
|
||||
expect(revoke).toHaveBeenCalledWith({
|
||||
associationEpoch: "association_stale",
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
expect(await fenceStore.read()).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
control: { association: { state: "UNASSOCIATED" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("commits the logout fence first, then revokes backend/native state and closes owned notifications", async () => {
|
||||
const fenceStore = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
);
|
||||
const prepared = await fenceStore.prepare({
|
||||
authority,
|
||||
updatedAt: "2026-07-28T00:00:00.000Z",
|
||||
});
|
||||
if (!prepared.ok) throw new Error("expected prepared control");
|
||||
await fenceStore.activate({
|
||||
expectedRevision: prepared.value.revision,
|
||||
authority,
|
||||
associationEpoch: "association_01",
|
||||
updatedAt: "2026-07-28T00:00:01.000Z",
|
||||
});
|
||||
const unsubscribe = vi.fn(async () => true);
|
||||
const native = subscription(unsubscribe);
|
||||
const notificationClose = vi.fn();
|
||||
const revoke = vi.fn(async () => {
|
||||
const control = await fenceStore.read();
|
||||
expect(control).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
control: {
|
||||
fenceGeneration: "fence_02",
|
||||
association: { state: "REVOKED" },
|
||||
},
|
||||
},
|
||||
});
|
||||
return webPushSuccess({ state: "REVOKED" as const });
|
||||
});
|
||||
const adapter = createWebPushSubscriptionAdapter({
|
||||
secureContext: true,
|
||||
userActivationIsActive: () => true,
|
||||
permission: {
|
||||
permission: () => "granted",
|
||||
requestPermission: async () => "granted",
|
||||
},
|
||||
registration: {
|
||||
active: true,
|
||||
pushManager: {
|
||||
getSubscription: async () => native,
|
||||
subscribe: async () => native,
|
||||
},
|
||||
getNotifications: async () => [
|
||||
{
|
||||
data: {
|
||||
protocol: WEB_PUSH_PROTOCOLS.click,
|
||||
notificationId: "notification_01",
|
||||
routeIntent: "OPEN_INBOX",
|
||||
associationEpoch: "association_01",
|
||||
releaseEpoch: "release_01",
|
||||
expiresAt: "2026-07-28T01:00:00.000Z",
|
||||
},
|
||||
close: notificationClose,
|
||||
},
|
||||
],
|
||||
},
|
||||
fenceStore,
|
||||
gateway: gateway({ revoke }),
|
||||
vapidPublicKey,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
expect(
|
||||
await adapter.revoke({
|
||||
previousAuthority: authority,
|
||||
nextAuthority,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
value: { state: "PUSH_UNAVAILABLE", reason: "REVOKED" },
|
||||
});
|
||||
expect(revoke).toHaveBeenCalledWith({
|
||||
associationEpoch: "association_01",
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
expect(notificationClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not clean the current native subscription for a stale revoke authority", async () => {
|
||||
const fenceStore = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
);
|
||||
const prepared = await fenceStore.prepare({
|
||||
authority: nextAuthority,
|
||||
updatedAt: "2026-07-28T00:00:00.000Z",
|
||||
});
|
||||
if (!prepared.ok) throw new Error("expected prepared control");
|
||||
const activated = await fenceStore.activate({
|
||||
expectedRevision: prepared.value.revision,
|
||||
authority: nextAuthority,
|
||||
associationEpoch: "association_new",
|
||||
updatedAt: "2026-07-28T00:00:01.000Z",
|
||||
});
|
||||
if (!activated.ok) throw new Error("expected active control");
|
||||
const unsubscribe = vi.fn(async () => true);
|
||||
const getSubscription = vi.fn(async () =>
|
||||
subscription(unsubscribe),
|
||||
);
|
||||
const notificationClose = vi.fn();
|
||||
const adapter = createWebPushSubscriptionAdapter({
|
||||
secureContext: true,
|
||||
userActivationIsActive: () => true,
|
||||
permission: {
|
||||
permission: () => "granted",
|
||||
requestPermission: async () => "granted",
|
||||
},
|
||||
registration: {
|
||||
active: true,
|
||||
pushManager: {
|
||||
getSubscription,
|
||||
subscribe: async () => subscription(unsubscribe),
|
||||
},
|
||||
getNotifications: async () => [
|
||||
{
|
||||
data: {
|
||||
protocol: WEB_PUSH_PROTOCOLS.click,
|
||||
notificationId: "notification_new",
|
||||
routeIntent: "OPEN_INBOX",
|
||||
associationEpoch: "association_new",
|
||||
releaseEpoch: "release_01",
|
||||
expiresAt: "2026-07-28T01:00:00.000Z",
|
||||
},
|
||||
close: notificationClose,
|
||||
},
|
||||
],
|
||||
},
|
||||
fenceStore,
|
||||
gateway: gateway(),
|
||||
vapidPublicKey,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
await expect(
|
||||
adapter.revoke({
|
||||
previousAuthority: authority,
|
||||
nextAuthority,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "PUSH_UNAVAILABLE",
|
||||
reason: "LOCAL_FENCE_UNSAFE",
|
||||
},
|
||||
});
|
||||
expect(getSubscription).not.toHaveBeenCalled();
|
||||
expect(unsubscribe).not.toHaveBeenCalled();
|
||||
expect(notificationClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not clean a captured old subscription after a newer association commits", async () => {
|
||||
const fenceStore = await activeFenceStore();
|
||||
const unsubscribe = vi.fn(async () => true);
|
||||
const native = subscription(unsubscribe);
|
||||
const notificationClose = vi.fn();
|
||||
const revoke = vi.fn(async () => {
|
||||
const current = await fenceStore.read();
|
||||
if (!current.ok || !current.value) {
|
||||
throw new Error("expected rotated control");
|
||||
}
|
||||
const activated = await fenceStore.activate({
|
||||
expectedRevision: current.value.revision,
|
||||
authority: nextAuthority,
|
||||
associationEpoch: "association_new",
|
||||
updatedAt: "2026-07-28T00:00:02.000Z",
|
||||
});
|
||||
if (!activated.ok) throw new Error("expected new association");
|
||||
return webPushSuccess({ state: "REVOKED" as const });
|
||||
});
|
||||
const adapter = createWebPushSubscriptionAdapter({
|
||||
secureContext: true,
|
||||
userActivationIsActive: () => true,
|
||||
permission: {
|
||||
permission: () => "granted",
|
||||
requestPermission: async () => "granted",
|
||||
},
|
||||
registration: {
|
||||
active: true,
|
||||
pushManager: {
|
||||
getSubscription: async () => native,
|
||||
subscribe: async () => native,
|
||||
},
|
||||
getNotifications: async () => [
|
||||
{
|
||||
data: {
|
||||
protocol: WEB_PUSH_PROTOCOLS.click,
|
||||
notificationId: "notification_old",
|
||||
routeIntent: "OPEN_INBOX",
|
||||
associationEpoch: "association_01",
|
||||
releaseEpoch: "release_01",
|
||||
expiresAt: "2026-07-28T01:00:00.000Z",
|
||||
},
|
||||
close: notificationClose,
|
||||
},
|
||||
],
|
||||
},
|
||||
fenceStore,
|
||||
gateway: gateway({ revoke }),
|
||||
vapidPublicKey,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
await expect(
|
||||
adapter.revoke({
|
||||
previousAuthority: authority,
|
||||
nextAuthority,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "PUSH_UNAVAILABLE",
|
||||
reason: "NATIVE_UNSUBSCRIBE_AMBIGUOUS",
|
||||
},
|
||||
});
|
||||
expect(unsubscribe).not.toHaveBeenCalled();
|
||||
expect(notificationClose).not.toHaveBeenCalled();
|
||||
await expect(fenceStore.read()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
control: {
|
||||
association: {
|
||||
state: "ACTIVE",
|
||||
associationEpoch: "association_new",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fences an ACTIVE association when notification permission drifts", async () => {
|
||||
const fenceStore = await activeFenceStore();
|
||||
const unsubscribe = vi.fn(async () => true);
|
||||
const notificationClose = vi.fn();
|
||||
const revoke = vi.fn(async () =>
|
||||
webPushSuccess({ state: "REVOKED" as const }),
|
||||
);
|
||||
const adapter = createWebPushSubscriptionAdapter({
|
||||
secureContext: true,
|
||||
userActivationIsActive: () => false,
|
||||
permission: {
|
||||
permission: () => "denied",
|
||||
requestPermission: async () => "denied",
|
||||
},
|
||||
registration: {
|
||||
active: true,
|
||||
pushManager: {
|
||||
getSubscription: async () => subscription(unsubscribe),
|
||||
subscribe: async () => subscription(unsubscribe),
|
||||
},
|
||||
getNotifications: async () => [
|
||||
{
|
||||
data: {
|
||||
protocol: WEB_PUSH_PROTOCOLS.click,
|
||||
notificationId: "notification_expired",
|
||||
routeIntent: "OPEN_INBOX",
|
||||
associationEpoch: "association_01",
|
||||
releaseEpoch: "release_01",
|
||||
expiresAt: "2026-07-27T00:00:00.000Z",
|
||||
},
|
||||
close: notificationClose,
|
||||
},
|
||||
],
|
||||
},
|
||||
fenceStore,
|
||||
gateway: gateway({ revoke }),
|
||||
vapidPublicKey,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
expect(await adapter.reconcile({ authority })).toEqual({
|
||||
ok: true,
|
||||
value: { state: "PUSH_DENIED" },
|
||||
});
|
||||
expect(await fenceStore.read()).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
control: {
|
||||
association: {
|
||||
state: "REVOKED",
|
||||
associationEpoch: "association_01",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(revoke).toHaveBeenCalledOnce();
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
expect(notificationClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("revokes local, backend, and native state on VAPID key drift", async () => {
|
||||
const fenceStore = await activeFenceStore();
|
||||
const unsubscribe = vi.fn(async () => true);
|
||||
const native = subscription(unsubscribe);
|
||||
const wrongApplicationServerKey = applicationServerKey.slice();
|
||||
wrongApplicationServerKey[1] = 99;
|
||||
const mismatched: WindowPushSubscriptionFacade = {
|
||||
...native,
|
||||
options: {
|
||||
applicationServerKey: wrongApplicationServerKey.buffer,
|
||||
},
|
||||
};
|
||||
const revoke = vi.fn(async () =>
|
||||
webPushSuccess({ state: "REVOKED" as const }),
|
||||
);
|
||||
const adapter = createWebPushSubscriptionAdapter({
|
||||
secureContext: true,
|
||||
userActivationIsActive: () => true,
|
||||
permission: {
|
||||
permission: () => "granted",
|
||||
requestPermission: async () => "granted",
|
||||
},
|
||||
registration: {
|
||||
active: true,
|
||||
pushManager: {
|
||||
getSubscription: async () => mismatched,
|
||||
subscribe: async () => mismatched,
|
||||
},
|
||||
getNotifications: async () => [],
|
||||
},
|
||||
fenceStore,
|
||||
gateway: gateway({ revoke }),
|
||||
vapidPublicKey,
|
||||
now: () => now,
|
||||
});
|
||||
|
||||
expect(await adapter.reconcile({ authority })).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
state: "PUSH_UNAVAILABLE",
|
||||
reason: "SUBSCRIPTION_KEY_MISMATCH",
|
||||
},
|
||||
});
|
||||
expect(await fenceStore.read()).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
control: {
|
||||
association: {
|
||||
state: "REVOKED",
|
||||
associationEpoch: "association_01",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(revoke).toHaveBeenCalledOnce();
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("bounds a non-cooperative native subscription inspection", async () => {
|
||||
const fenceStore = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
);
|
||||
const clock = manualScheduler();
|
||||
const adapter = createWebPushSubscriptionAdapter({
|
||||
secureContext: true,
|
||||
userActivationIsActive: () => true,
|
||||
permission: {
|
||||
permission: () => "granted",
|
||||
requestPermission: async () => "granted",
|
||||
},
|
||||
registration: {
|
||||
active: true,
|
||||
pushManager: {
|
||||
getSubscription: () =>
|
||||
new Promise<WindowPushSubscriptionFacade | null>(() => {}),
|
||||
subscribe: async () => subscription(),
|
||||
},
|
||||
getNotifications: async () => [],
|
||||
},
|
||||
fenceStore,
|
||||
gateway: gateway(),
|
||||
vapidPublicKey,
|
||||
nativeOperationDeadlineMs: 1,
|
||||
scheduler: clock.scheduler,
|
||||
});
|
||||
|
||||
const inspection = adapter.inspect({ authority });
|
||||
await Promise.resolve();
|
||||
clock.expireAll();
|
||||
expect(await inspection).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEADLINE_EXCEEDED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts a non-cooperative native operation on dispose", async () => {
|
||||
const fenceStore = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
);
|
||||
const adapter = createWebPushSubscriptionAdapter({
|
||||
secureContext: true,
|
||||
userActivationIsActive: () => true,
|
||||
permission: {
|
||||
permission: () => "granted",
|
||||
requestPermission: async () => "granted",
|
||||
},
|
||||
registration: {
|
||||
active: true,
|
||||
pushManager: {
|
||||
getSubscription: () =>
|
||||
new Promise<WindowPushSubscriptionFacade | null>(() => {}),
|
||||
subscribe: async () => subscription(),
|
||||
},
|
||||
getNotifications: async () => [],
|
||||
},
|
||||
fenceStore,
|
||||
gateway: gateway(),
|
||||
vapidPublicKey,
|
||||
});
|
||||
|
||||
const inspection = adapter.inspect({ authority });
|
||||
await Promise.resolve();
|
||||
adapter.dispose();
|
||||
expect(await inspection).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ABORTED" },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps a throwing browser facade and releases the exclusive lease", async () => {
|
||||
const fenceStore = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
);
|
||||
const adapter = createWebPushSubscriptionAdapter({
|
||||
secureContext: true,
|
||||
userActivationIsActive: () => true,
|
||||
permission: {
|
||||
permission() {
|
||||
throw new Error("permission provider unavailable");
|
||||
},
|
||||
requestPermission: async () => "granted",
|
||||
},
|
||||
registration: {
|
||||
active: true,
|
||||
pushManager: {
|
||||
getSubscription: async () => null,
|
||||
subscribe: async () => subscription(),
|
||||
},
|
||||
getNotifications: async () => [],
|
||||
},
|
||||
fenceStore,
|
||||
gateway: gateway(),
|
||||
vapidPublicKey,
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await expect(
|
||||
adapter.inspect({ authority }),
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "NATIVE_FAILURE",
|
||||
operation: "SUBSCRIPTION_INSPECT",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds a non-cooperative backend register and revokes its late commit", async () => {
|
||||
const fenceStore = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
);
|
||||
const clock = manualScheduler();
|
||||
let signalRegisterStarted: (() => void) | undefined;
|
||||
const registerStarted = new Promise<void>((resolve) => {
|
||||
signalRegisterStarted = resolve;
|
||||
});
|
||||
let finishRegister:
|
||||
| ((
|
||||
result: Awaited<
|
||||
ReturnType<WebPushRegistrationGateway["register"]>
|
||||
>,
|
||||
) => void)
|
||||
| undefined;
|
||||
const lateRegister = new Promise<
|
||||
Awaited<ReturnType<WebPushRegistrationGateway["register"]>>
|
||||
>((resolve) => {
|
||||
finishRegister = resolve;
|
||||
});
|
||||
const revoke = vi.fn(async () =>
|
||||
webPushSuccess({ state: "REVOKED" as const }),
|
||||
);
|
||||
const adapter = createWebPushSubscriptionAdapter({
|
||||
secureContext: true,
|
||||
userActivationIsActive: () => true,
|
||||
permission: {
|
||||
permission: () => "granted",
|
||||
requestPermission: async () => "granted",
|
||||
},
|
||||
registration: {
|
||||
active: true,
|
||||
pushManager: {
|
||||
getSubscription: async () => subscription(),
|
||||
subscribe: async () => subscription(),
|
||||
},
|
||||
getNotifications: async () => [],
|
||||
},
|
||||
fenceStore,
|
||||
gateway: gateway({
|
||||
register: async () => {
|
||||
signalRegisterStarted?.();
|
||||
return await lateRegister;
|
||||
},
|
||||
revoke,
|
||||
}),
|
||||
vapidPublicKey,
|
||||
backendOperationDeadlineMs: 1,
|
||||
scheduler: clock.scheduler,
|
||||
idempotencyKeyFactory: () => "idempotency_01",
|
||||
});
|
||||
|
||||
const enabling = adapter.enable({
|
||||
authority,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
await registerStarted;
|
||||
clock.expireAll();
|
||||
expect(await enabling).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEADLINE_EXCEEDED" },
|
||||
});
|
||||
finishRegister?.(
|
||||
webPushSuccess({
|
||||
associationEpoch: "association_late",
|
||||
sessionBindingEpoch: "session_01",
|
||||
}),
|
||||
);
|
||||
for (let turn = 0; turn < 8; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
expect(revoke).toHaveBeenCalledWith({
|
||||
associationEpoch: "association_late",
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(await fenceStore.read()).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
control: { association: { state: "UNASSOCIATED" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,617 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
WEB_PUSH_PROTOCOLS,
|
||||
type NotificationClickDataV1,
|
||||
} from "../../src/contracts/web-push.ts";
|
||||
import { createPushAssociationFenceStore } from "../../src/adapters/web-push/push-association-fence-store.ts";
|
||||
import { createWebPushNotificationRegistry } from "../../src/adapters/web-push/notification-registry.ts";
|
||||
import { createPushEventAdapter } from "../../src/adapters/web-push/inbound/push-event-adapter.ts";
|
||||
import { createNotificationClickAdapter } from "../../src/adapters/web-push/inbound/notification-click-adapter.ts";
|
||||
import { createWebPushServiceWorkerRuntime } from "../../src/adapters/web-push/service-worker-runtime.ts";
|
||||
import type { TimeoutScheduler } from "../../src/adapters/web-push/runtime-support.ts";
|
||||
import {
|
||||
createFakePushControlStoreDependencies,
|
||||
} from "../helpers/fake-push-control-repository.ts";
|
||||
|
||||
const now = Date.parse("2026-07-28T00:00:00.000Z");
|
||||
const authority = Object.freeze({
|
||||
fenceGeneration: "fence_01",
|
||||
sessionBindingEpoch: "session_01",
|
||||
releaseEpoch: "release_01",
|
||||
});
|
||||
const nextAuthority = Object.freeze({
|
||||
fenceGeneration: "fence_02",
|
||||
sessionBindingEpoch: "session_02",
|
||||
releaseEpoch: "release_01",
|
||||
});
|
||||
const registry = createWebPushNotificationRegistry([
|
||||
{
|
||||
notificationType: "INBOX_ACTIVITY",
|
||||
routeIntent: "OPEN_INBOX",
|
||||
title: "새 알림이 있습니다",
|
||||
body: "앱을 열어 최신 내용을 확인하세요.",
|
||||
path: "/inbox",
|
||||
},
|
||||
]);
|
||||
|
||||
async function activeFence() {
|
||||
const store = createPushAssociationFenceStore(
|
||||
createFakePushControlStoreDependencies(),
|
||||
);
|
||||
const prepared = await store.prepare({
|
||||
authority,
|
||||
updatedAt: "2026-07-28T00:00:00.000Z",
|
||||
});
|
||||
if (!prepared.ok) throw new Error("expected prepared push control");
|
||||
const active = await store.activate({
|
||||
expectedRevision: prepared.value.revision,
|
||||
authority,
|
||||
associationEpoch: "association_01",
|
||||
updatedAt: "2026-07-28T00:00:01.000Z",
|
||||
});
|
||||
if (!active.ok) throw new Error("expected active push control");
|
||||
return store;
|
||||
}
|
||||
|
||||
function hint(overrides: Readonly<Record<string, unknown>> = {}) {
|
||||
return {
|
||||
protocol: WEB_PUSH_PROTOCOLS.hint,
|
||||
notificationType: "INBOX_ACTIVITY",
|
||||
notificationId: "notification_01",
|
||||
associationEpoch: "association_01",
|
||||
releaseEpoch: "release_01",
|
||||
issuedAt: "2026-07-27T23:59:00.000Z",
|
||||
expiresAt: "2026-07-28T01:00:00.000Z",
|
||||
routeIntent: "OPEN_INBOX",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function clickData(): NotificationClickDataV1 {
|
||||
return {
|
||||
protocol: WEB_PUSH_PROTOCOLS.click,
|
||||
notificationId: "notification_01",
|
||||
routeIntent: "OPEN_INBOX",
|
||||
associationEpoch: "association_01",
|
||||
releaseEpoch: "release_01",
|
||||
expiresAt: "2026-07-28T01:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
function manualScheduler(): Readonly<{
|
||||
scheduler: TimeoutScheduler;
|
||||
delays: readonly number[];
|
||||
expireAll(): void;
|
||||
}> {
|
||||
let sequence = 0;
|
||||
const callbacks = new Map<number, () => void>();
|
||||
const delays: number[] = [];
|
||||
return {
|
||||
scheduler: {
|
||||
setTimeout(callback, milliseconds) {
|
||||
sequence += 1;
|
||||
delays.push(milliseconds);
|
||||
callbacks.set(sequence, callback);
|
||||
return sequence;
|
||||
},
|
||||
clearTimeout(handle) {
|
||||
if (typeof handle === "number") callbacks.delete(handle);
|
||||
},
|
||||
},
|
||||
expireAll() {
|
||||
for (const callback of [...callbacks.values()]) callback();
|
||||
},
|
||||
delays,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Web Push worker runtime", () => {
|
||||
it("waits for strict hint handling and shows only registry-owned safe copy", async () => {
|
||||
const store = await activeFence();
|
||||
const shown: Array<Readonly<{ title: string; options: unknown }>> = [];
|
||||
let waited: Promise<void> | null = null;
|
||||
const adapter = createPushEventAdapter({
|
||||
fenceStore: store,
|
||||
registry,
|
||||
now: () => now,
|
||||
tagDigest: async () => new Uint8Array(32).fill(9).buffer,
|
||||
notifications: {
|
||||
async showNotification(title, options) {
|
||||
shown.push({ title, options });
|
||||
},
|
||||
},
|
||||
});
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(hint()));
|
||||
const result = await adapter.handle({
|
||||
data: { arrayBuffer: () => bytes.slice().buffer },
|
||||
waitUntil(task) {
|
||||
waited = task;
|
||||
},
|
||||
});
|
||||
await waited;
|
||||
|
||||
expect(result).toEqual({ ok: true, value: undefined });
|
||||
expect(shown).toHaveLength(1);
|
||||
expect(shown[0]).toMatchObject({
|
||||
title: "새 알림이 있습니다",
|
||||
options: {
|
||||
body: "앱을 열어 최신 내용을 확인하세요.",
|
||||
data: {
|
||||
protocol: WEB_PUSH_PROTOCOLS.click,
|
||||
notificationId: "notification_01",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(shown[0]?.options as { tag: string }).tag,
|
||||
).not.toContain("association_01");
|
||||
});
|
||||
|
||||
it("drops mismatched associations before notification rendering", async () => {
|
||||
const store = await activeFence();
|
||||
const showNotification = vi.fn(async () => {});
|
||||
const adapter = createPushEventAdapter({
|
||||
fenceStore: store,
|
||||
registry,
|
||||
now: () => now,
|
||||
tagDigest: async () => new Uint8Array(32).buffer,
|
||||
notifications: { showNotification },
|
||||
});
|
||||
const bytes = new TextEncoder().encode(
|
||||
JSON.stringify(hint({ associationEpoch: "association_old" })),
|
||||
);
|
||||
const result = await adapter.handle({
|
||||
data: { arrayBuffer: () => bytes.slice().buffer },
|
||||
waitUntil() {},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ASSOCIATION_MISMATCH" },
|
||||
});
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aborts late worker work at the handler deadline before notification display", async () => {
|
||||
const store = await activeFence();
|
||||
const clock = manualScheduler();
|
||||
const showNotification = vi.fn(async () => {});
|
||||
let signalDigestStarted: (() => void) | undefined;
|
||||
const digestStarted = new Promise<void>((resolve) => {
|
||||
signalDigestStarted = resolve;
|
||||
});
|
||||
let finishDigest: ((value: ArrayBuffer) => void) | undefined;
|
||||
const digest = new Promise<ArrayBuffer>((resolve) => {
|
||||
finishDigest = resolve;
|
||||
});
|
||||
const adapter = createPushEventAdapter({
|
||||
fenceStore: store,
|
||||
registry,
|
||||
now: () => now,
|
||||
handlerDeadlineMs: 1,
|
||||
scheduler: clock.scheduler,
|
||||
tagDigest: async () => {
|
||||
signalDigestStarted?.();
|
||||
return await digest;
|
||||
},
|
||||
notifications: { showNotification },
|
||||
});
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(hint()));
|
||||
const handling = adapter.handle({
|
||||
data: { arrayBuffer: () => bytes.slice().buffer },
|
||||
waitUntil() {},
|
||||
});
|
||||
await digestStarted;
|
||||
|
||||
clock.expireAll();
|
||||
expect(await handling).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "DEADLINE_EXCEEDED" },
|
||||
});
|
||||
finishDigest?.(new Uint8Array(32).buffer);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rechecks the durable generation after async push work", async () => {
|
||||
const store = await activeFence();
|
||||
const showNotification = vi.fn(async () => {});
|
||||
const adapter = createPushEventAdapter({
|
||||
fenceStore: store,
|
||||
registry,
|
||||
now: () => now,
|
||||
notifications: { showNotification },
|
||||
tagDigest: async () => {
|
||||
const current = await store.read();
|
||||
if (!current.ok || !current.value) {
|
||||
throw new Error("expected active control");
|
||||
}
|
||||
const rotated = await store.rotateAndRevoke({
|
||||
expectedRevision: current.value.revision,
|
||||
previousAuthority: authority,
|
||||
nextAuthority,
|
||||
updatedAt: "2026-07-28T00:00:02.000Z",
|
||||
});
|
||||
if (!rotated.ok) throw new Error("expected rotated control");
|
||||
return new Uint8Array(32).buffer;
|
||||
},
|
||||
});
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(hint()));
|
||||
|
||||
expect(
|
||||
await adapter.handle({
|
||||
data: { arrayBuffer: () => bytes.slice().buffer },
|
||||
waitUntil() {},
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ASSOCIATION_MISMATCH" },
|
||||
});
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revalidates persisted click authority and uses a same-origin handoff", async () => {
|
||||
const store = await activeFence();
|
||||
const messages: unknown[] = [];
|
||||
const focus = vi.fn(async () => {});
|
||||
const openWindow = vi.fn(async () => null);
|
||||
const close = vi.fn();
|
||||
let waited: Promise<void> | null = null;
|
||||
const adapter = createNotificationClickAdapter({
|
||||
fenceStore: store,
|
||||
registry,
|
||||
origin: "https://app.example.test",
|
||||
now: () => now,
|
||||
clients: {
|
||||
async matchControlledWindowClients() {
|
||||
return [
|
||||
{
|
||||
url: "https://app.example.test/current",
|
||||
focus,
|
||||
postMessage(message) {
|
||||
messages.push(message);
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
openWindow,
|
||||
},
|
||||
});
|
||||
const result = await adapter.handle({
|
||||
notification: { data: clickData(), close },
|
||||
waitUntil(task) {
|
||||
waited = task;
|
||||
},
|
||||
});
|
||||
await waited;
|
||||
|
||||
expect(result).toEqual({ ok: true, value: undefined });
|
||||
expect(close).toHaveBeenCalledOnce();
|
||||
expect(focus).toHaveBeenCalledOnce();
|
||||
expect(openWindow).not.toHaveBeenCalled();
|
||||
expect(messages).toEqual([
|
||||
expect.objectContaining({
|
||||
protocol: WEB_PUSH_PROTOCOLS.clickHandoff,
|
||||
routeIntent: "OPEN_INBOX",
|
||||
path: "/inbox",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rechecks the durable generation after client enumeration", async () => {
|
||||
const store = await activeFence();
|
||||
const postMessage = vi.fn();
|
||||
const focus = vi.fn(async () => {});
|
||||
const adapter = createNotificationClickAdapter({
|
||||
fenceStore: store,
|
||||
registry,
|
||||
origin: "https://app.example.test",
|
||||
now: () => now,
|
||||
clients: {
|
||||
async matchControlledWindowClients() {
|
||||
const current = await store.read();
|
||||
if (!current.ok || !current.value) {
|
||||
throw new Error("expected active control");
|
||||
}
|
||||
const rotated = await store.rotateAndRevoke({
|
||||
expectedRevision: current.value.revision,
|
||||
previousAuthority: authority,
|
||||
nextAuthority,
|
||||
updatedAt: "2026-07-28T00:00:02.000Z",
|
||||
});
|
||||
if (!rotated.ok) throw new Error("expected rotated control");
|
||||
return [
|
||||
{
|
||||
url: "https://app.example.test/",
|
||||
focus,
|
||||
postMessage,
|
||||
},
|
||||
];
|
||||
},
|
||||
openWindow: async () => null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
await adapter.handle({
|
||||
notification: { data: clickData(), close() {} },
|
||||
waitUntil() {},
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "ASSOCIATION_MISMATCH" },
|
||||
});
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aborts in-flight push work when the worker runtime is disposed", async () => {
|
||||
const store = await activeFence();
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const showNotification = vi.fn(async () => {});
|
||||
let signalDigestStarted: (() => void) | undefined;
|
||||
const digestStarted = new Promise<void>((resolve) => {
|
||||
signalDigestStarted = resolve;
|
||||
});
|
||||
let finishDigest: ((value: ArrayBuffer) => void) | undefined;
|
||||
const digest = new Promise<ArrayBuffer>((resolve) => {
|
||||
finishDigest = resolve;
|
||||
});
|
||||
const runtime = createWebPushServiceWorkerRuntime({
|
||||
fenceStore: store,
|
||||
registry,
|
||||
now: () => now,
|
||||
tagDigest: async () => {
|
||||
signalDigestStarted?.();
|
||||
return await digest;
|
||||
},
|
||||
host: {
|
||||
origin: "https://app.example.test",
|
||||
registration: { showNotification },
|
||||
clients: {
|
||||
matchAll: async () => [],
|
||||
openWindow: async () => null,
|
||||
},
|
||||
addEventListener(type, listener) {
|
||||
listeners.set(type, listener);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
if (listeners.get(type) === listener) listeners.delete(type);
|
||||
},
|
||||
},
|
||||
});
|
||||
let waited: Promise<void> | null = null;
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(hint()));
|
||||
listeners.get("push")?.({
|
||||
data: { arrayBuffer: () => bytes.slice().buffer },
|
||||
waitUntil(task: Promise<void>) {
|
||||
waited = task;
|
||||
},
|
||||
});
|
||||
await digestStarted;
|
||||
|
||||
runtime.dispose();
|
||||
await waited;
|
||||
finishDigest?.(new Uint8Array(32).buffer);
|
||||
for (let turn = 0; turn < 4; turn += 1) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
expect(showNotification).not.toHaveBeenCalled();
|
||||
expect(listeners.size).toBe(0);
|
||||
});
|
||||
|
||||
it("bounds a non-cooperative subscription-change handoff at ten seconds", async () => {
|
||||
const store = await activeFence();
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const clock = manualScheduler();
|
||||
const observations: unknown[] = [];
|
||||
const runtime = createWebPushServiceWorkerRuntime({
|
||||
fenceStore: store,
|
||||
registry,
|
||||
scheduler: clock.scheduler,
|
||||
observer: {
|
||||
record(observation) {
|
||||
observations.push(observation);
|
||||
},
|
||||
},
|
||||
host: {
|
||||
origin: "https://app.example.test",
|
||||
registration: { showNotification: async () => {} },
|
||||
clients: {
|
||||
matchAll: () => new Promise<readonly unknown[]>(() => {}),
|
||||
openWindow: async () => null,
|
||||
},
|
||||
addEventListener(type, listener) {
|
||||
listeners.set(type, listener);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
if (listeners.get(type) === listener) listeners.delete(type);
|
||||
},
|
||||
},
|
||||
});
|
||||
let waited: Promise<void> | null = null;
|
||||
listeners.get("pushsubscriptionchange")?.({
|
||||
waitUntil(task: Promise<void>) {
|
||||
waited = task;
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(clock.delays).toContain(10_000);
|
||||
clock.expireAll();
|
||||
await waited;
|
||||
expect(observations).toContainEqual({
|
||||
event: "web_push_subscription_rotated",
|
||||
outcome: "DEGRADED",
|
||||
reason: "DEADLINE_EXCEEDED",
|
||||
});
|
||||
runtime.dispose();
|
||||
});
|
||||
|
||||
it("aborts subscription-change handoff on dispose and contains waitUntil throws", async () => {
|
||||
const store = await activeFence();
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const observations: unknown[] = [];
|
||||
const runtime = createWebPushServiceWorkerRuntime({
|
||||
fenceStore: store,
|
||||
registry,
|
||||
observer: {
|
||||
record(observation) {
|
||||
observations.push(observation);
|
||||
},
|
||||
},
|
||||
host: {
|
||||
origin: "https://app.example.test",
|
||||
registration: { showNotification: async () => {} },
|
||||
clients: {
|
||||
matchAll: () => new Promise<readonly unknown[]>(() => {}),
|
||||
openWindow: async () => null,
|
||||
},
|
||||
addEventListener(type, listener) {
|
||||
listeners.set(type, listener);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
if (listeners.get(type) === listener) listeners.delete(type);
|
||||
},
|
||||
},
|
||||
});
|
||||
let waited: Promise<void> | null = null;
|
||||
listeners.get("pushsubscriptionchange")?.({
|
||||
waitUntil(task: Promise<void>) {
|
||||
waited = task;
|
||||
},
|
||||
});
|
||||
await Promise.resolve();
|
||||
runtime.dispose();
|
||||
await waited;
|
||||
expect(observations).toContainEqual({
|
||||
event: "web_push_subscription_rotated",
|
||||
outcome: "DEGRADED",
|
||||
reason: "ABORTED",
|
||||
});
|
||||
|
||||
const throwingStore = await activeFence();
|
||||
const throwingListeners = new Map<
|
||||
string,
|
||||
(event: unknown) => void
|
||||
>();
|
||||
const throwingObservations: unknown[] = [];
|
||||
let signalThrowingObservation: (() => void) | undefined;
|
||||
const throwingObservation = new Promise<void>((resolve) => {
|
||||
signalThrowingObservation = resolve;
|
||||
});
|
||||
const throwingRuntime = createWebPushServiceWorkerRuntime({
|
||||
fenceStore: throwingStore,
|
||||
registry,
|
||||
observer: {
|
||||
record(observation) {
|
||||
throwingObservations.push(observation);
|
||||
signalThrowingObservation?.();
|
||||
},
|
||||
},
|
||||
host: {
|
||||
origin: "https://app.example.test",
|
||||
registration: { showNotification: async () => {} },
|
||||
clients: {
|
||||
matchAll: async () => [],
|
||||
openWindow: async () => null,
|
||||
},
|
||||
addEventListener(type, listener) {
|
||||
throwingListeners.set(type, listener);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
if (throwingListeners.get(type) === listener) {
|
||||
throwingListeners.delete(type);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
throwingListeners.get("pushsubscriptionchange")?.({
|
||||
waitUntil() {
|
||||
throw new Error("waitUntil unavailable");
|
||||
},
|
||||
}),
|
||||
).not.toThrow();
|
||||
await throwingObservation;
|
||||
expect(throwingObservations).toContainEqual({
|
||||
event: "web_push_subscription_rotated",
|
||||
outcome: "DEGRADED",
|
||||
reason: "ABORTED",
|
||||
});
|
||||
throwingRuntime.dispose();
|
||||
});
|
||||
|
||||
it("installs no handlers until the uncomposed factory is called and removes all on dispose", async () => {
|
||||
const store = await activeFence();
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const messages: unknown[] = [];
|
||||
const matchPolicies: boolean[] = [];
|
||||
const client = {
|
||||
url: "https://app.example.test/",
|
||||
focus: async () => {},
|
||||
postMessage(message: unknown) {
|
||||
messages.push(message);
|
||||
},
|
||||
};
|
||||
expect(listeners.size).toBe(0);
|
||||
const runtime = createWebPushServiceWorkerRuntime({
|
||||
fenceStore: store,
|
||||
registry,
|
||||
now: () => now,
|
||||
host: {
|
||||
origin: "https://app.example.test",
|
||||
registration: {
|
||||
async showNotification() {},
|
||||
},
|
||||
clients: {
|
||||
async matchAll(input) {
|
||||
matchPolicies.push(input.includeUncontrolled);
|
||||
return [client];
|
||||
},
|
||||
async openWindow() {
|
||||
return client;
|
||||
},
|
||||
},
|
||||
addEventListener(type, listener) {
|
||||
listeners.set(type, listener);
|
||||
},
|
||||
removeEventListener(type, listener) {
|
||||
if (listeners.get(type) === listener) listeners.delete(type);
|
||||
},
|
||||
},
|
||||
});
|
||||
expect([...listeners.keys()].sort()).toEqual([
|
||||
"notificationclick",
|
||||
"push",
|
||||
"pushsubscriptionchange",
|
||||
]);
|
||||
let clickWait: Promise<void> | null = null;
|
||||
listeners.get("notificationclick")?.({
|
||||
notification: { data: clickData(), close() {} },
|
||||
waitUntil(task: Promise<void>) {
|
||||
clickWait = task;
|
||||
},
|
||||
});
|
||||
await clickWait;
|
||||
let subscriptionWait: Promise<void> | null = null;
|
||||
listeners.get("pushsubscriptionchange")?.({
|
||||
waitUntil(task: Promise<void>) {
|
||||
subscriptionWait = task;
|
||||
},
|
||||
});
|
||||
await subscriptionWait;
|
||||
expect(matchPolicies).toEqual([false, true]);
|
||||
expect(messages).toEqual([
|
||||
expect.objectContaining({
|
||||
protocol: WEB_PUSH_PROTOCOLS.clickHandoff,
|
||||
}),
|
||||
{ protocol: WEB_PUSH_PROTOCOLS.reconcileRequired },
|
||||
]);
|
||||
|
||||
runtime.dispose();
|
||||
expect(listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user