248 lines
10 KiB
TypeScript
248 lines
10 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
|
/** Stage 9 — pull-only transport and verifier boundary. */
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { buildPackage } from "../../pipeline/package.js";
|
|
import { makeValidInput } from "../../pipeline/fixtures.js";
|
|
import { canonicalJson } from "../../pipeline/canonical-json.js";
|
|
import { generateTestKeyPair } from "../../pipeline/sign.js";
|
|
import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js";
|
|
import { HttpTransport } from "../../src/sync/transport/http.js";
|
|
import { TransportError } from "../../src/sync/transport/types.js";
|
|
import { pullCandidate } from "../../src/sync/pull.js";
|
|
|
|
function response(body: string | Uint8Array, status = 200): Response {
|
|
const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
arrayBuffer: () => Promise.resolve(bytes.buffer as ArrayBuffer),
|
|
} as Response;
|
|
}
|
|
|
|
function inputUrl(input: RequestInfo | URL): string {
|
|
if (input instanceof URL) return input.toString();
|
|
if (typeof input === "string") return input;
|
|
return input.url;
|
|
}
|
|
|
|
function pointer(edition = "lumen-2026") {
|
|
return JSON.stringify({
|
|
edition,
|
|
packageVersion: 1,
|
|
manifestUrl: `/editions/${edition}/packages/1/manifest.json`,
|
|
generatedAt: "2026-08-30T12:00:00.000Z",
|
|
});
|
|
}
|
|
|
|
describe("Stage 9 HttpTransport", () => {
|
|
let fetchImpl: ReturnType<typeof vi.fn>;
|
|
let transport: HttpTransport;
|
|
|
|
beforeEach(() => {
|
|
fetchImpl = vi.fn();
|
|
transport = new HttpTransport("https://festival.example/", { fetchImpl });
|
|
Object.defineProperty(navigator, "onLine", { configurable: true, value: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("reports available even when the browser thinks it is offline (isolated LAN hub)", () => {
|
|
expect(transport.isAvailable()).toBe(true);
|
|
Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
|
|
// A festival box has no upstream internet: onLine is false while the
|
|
// origin is reachable. Availability must not gate the attempt — a truly
|
|
// dead network fails fast at fetch time instead.
|
|
expect(transport.isAvailable()).toBe(true);
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("retrieves and validates the edition pointer at the static-origin URL", async () => {
|
|
fetchImpl.mockResolvedValue(response(pointer()));
|
|
await expect(transport.fetchPointer("lumen-2026")).resolves.toMatchObject({
|
|
edition: "lumen-2026",
|
|
packageVersion: 1,
|
|
});
|
|
expect(inputUrl(fetchImpl.mock.calls[0]?.[0] as RequestInfo | URL)).toBe(
|
|
"https://festival.example/editions/lumen-2026/latest.json",
|
|
);
|
|
expect(fetchImpl.mock.calls[0]?.[1]).toEqual(
|
|
expect.objectContaining({ signal: expect.anything() }),
|
|
);
|
|
});
|
|
|
|
it("encodes pointer path segments and rejects cross-origin paths", async () => {
|
|
fetchImpl.mockResolvedValue(response(pointer("lumen/2026")));
|
|
await expect(transport.fetchPointer("lumen/2026")).resolves.toMatchObject({
|
|
edition: "lumen/2026",
|
|
});
|
|
expect(inputUrl(fetchImpl.mock.calls[0]?.[0] as RequestInfo | URL)).toBe(
|
|
"https://festival.example/editions/lumen%2F2026/latest.json",
|
|
);
|
|
await expect(transport.fetchBytes("https://other.example/file")).rejects.toMatchObject({
|
|
code: "malformed_response",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[404, "missing_resource"],
|
|
[500, "http_error"],
|
|
] as const)("maps HTTP %s to %s", async (status, code) => {
|
|
fetchImpl.mockResolvedValue(response("failure", status));
|
|
await expect(transport.fetchBytes("/file")).rejects.toMatchObject({ code, status });
|
|
});
|
|
|
|
it("maps network failures, malformed pointers, timeout, and caller abort", async () => {
|
|
fetchImpl.mockRejectedValue(new TypeError("offline"));
|
|
await expect(transport.fetchBytes("/file")).rejects.toMatchObject({
|
|
code: "network_unavailable",
|
|
});
|
|
|
|
fetchImpl.mockResolvedValue(response("{}"));
|
|
await expect(transport.fetchPointer("lumen-2026")).rejects.toMatchObject({
|
|
code: "malformed_response",
|
|
});
|
|
|
|
fetchImpl.mockImplementation(
|
|
() =>
|
|
new Promise<Response>((_resolve, reject) => {
|
|
setTimeout(() => {
|
|
reject(new Error("request interrupted"));
|
|
}, 20);
|
|
}),
|
|
);
|
|
await expect(transport.fetchBytes("/slow", { timeoutMs: 1 })).rejects.toMatchObject({
|
|
code: "timeout",
|
|
});
|
|
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
await expect(transport.fetchBytes("/aborted", { signal: controller.signal })).rejects.toEqual(
|
|
expect.objectContaining({ code: "aborted" }),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Stage 9 pull and verifier boundary", () => {
|
|
it("fetches pointer, manifest, and signature before protected files", async () => {
|
|
const keys = generateTestKeyPair();
|
|
const built = buildPackage(makeValidInput(), { signWith: keys });
|
|
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
|
|
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
|
|
const calls: string[] = [];
|
|
const files = new Map<string, Uint8Array>();
|
|
files.set("/editions/lumen-2026/packages/1/manifest.json", manifestBytes);
|
|
files.set(
|
|
"/editions/lumen-2026/packages/1/signature.json",
|
|
new TextEncoder().encode(JSON.stringify(built.pkg.signature)),
|
|
);
|
|
for (const [name, file] of built.pkg.files)
|
|
files.set(`/editions/lumen-2026/packages/1/${name}`, file.canonicalBytes);
|
|
for (const asset of built.pkg.assets)
|
|
files.set(`/editions/lumen-2026/packages/1/${asset.file}`, asset.bytesContent);
|
|
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
const url = inputUrl(input);
|
|
calls.push(url);
|
|
if (url.endsWith("/latest.json")) return Promise.resolve(response(pointer()));
|
|
const body = files.get(new URL(url).pathname);
|
|
return Promise.resolve(body ? response(body) : response("missing", 404));
|
|
});
|
|
const result = await pullCandidate(
|
|
new HttpTransport("https://festival.example/", { fetchImpl }),
|
|
"lumen-2026",
|
|
{
|
|
trustedKeys: new Map([[keys.fingerprint, publicKeyFromDerBase64(keys.publicKeyDerBase64)]]),
|
|
appVersion: "1.0.0",
|
|
supportedSchemaRange: [1],
|
|
},
|
|
);
|
|
if (!result || "skipped" in result) throw new Error("expected candidate");
|
|
expect(result.result.ok).toBe(true);
|
|
expect(calls[0]).toMatch(/latest\.json$/);
|
|
expect(calls[1]).toMatch(/manifest\.json$/);
|
|
expect(calls[2]).toMatch(/signature\.json$/);
|
|
expect(
|
|
calls.indexOf("https://festival.example/editions/lumen-2026/packages/1/emergency.json"),
|
|
).toBeGreaterThan(2);
|
|
});
|
|
|
|
it("retries a transient network failure mid-download and reports progress", async () => {
|
|
const keys = generateTestKeyPair();
|
|
const built = buildPackage(makeValidInput(), { signWith: keys });
|
|
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
|
|
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
|
|
const files = new Map<string, Uint8Array>();
|
|
files.set("/editions/lumen-2026/packages/1/manifest.json", manifestBytes);
|
|
files.set(
|
|
"/editions/lumen-2026/packages/1/signature.json",
|
|
new TextEncoder().encode(JSON.stringify(built.pkg.signature)),
|
|
);
|
|
for (const [name, file] of built.pkg.files)
|
|
files.set(`/editions/lumen-2026/packages/1/${name}`, file.canonicalBytes);
|
|
for (const asset of built.pkg.assets)
|
|
files.set(`/editions/lumen-2026/packages/1/${asset.file}`, asset.bytesContent);
|
|
let emergencyFailures = 1;
|
|
const seen: string[] = [];
|
|
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
const url = inputUrl(input);
|
|
if (url.endsWith("/latest.json")) return Promise.resolve(response(pointer()));
|
|
if (url.endsWith("/emergency.json") && emergencyFailures > 0) {
|
|
emergencyFailures -= 1;
|
|
return Promise.reject(new TypeError("wifi hiccup"));
|
|
}
|
|
const body = files.get(new URL(url).pathname);
|
|
return Promise.resolve(body ? response(body) : response("missing", 404));
|
|
});
|
|
const result = await pullCandidate(
|
|
new HttpTransport("https://festival.example/", { fetchImpl }),
|
|
"lumen-2026",
|
|
{
|
|
trustedKeys: new Map([[keys.fingerprint, publicKeyFromDerBase64(keys.publicKeyDerBase64)]]),
|
|
appVersion: "1.0.0",
|
|
supportedSchemaRange: [1],
|
|
},
|
|
{ onProgress: (progress) => seen.push(progress.label) },
|
|
);
|
|
if (!result || "skipped" in result) throw new Error("expected candidate");
|
|
expect(result.result.ok).toBe(true);
|
|
expect(emergencyFailures).toBe(0);
|
|
expect(seen).toContain("emergency.json");
|
|
});
|
|
|
|
it("does not retrieve protected files when the signature gate fails", async () => {
|
|
const keys = generateTestKeyPair();
|
|
const built = buildPackage(makeValidInput(), { signWith: keys });
|
|
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
|
|
const manifestBytes = new TextEncoder().encode(canonicalJson(built.pkg.manifest));
|
|
const calls: string[] = [];
|
|
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
const url = inputUrl(input);
|
|
calls.push(url);
|
|
if (url.endsWith("/latest.json")) return Promise.resolve(response(pointer()));
|
|
if (url.endsWith("/manifest.json")) return Promise.resolve(response(manifestBytes));
|
|
return Promise.resolve(
|
|
response(JSON.stringify({ ...built.pkg.signature, signature: "bad" })),
|
|
);
|
|
});
|
|
const result = await pullCandidate(
|
|
new HttpTransport("https://festival.example/", { fetchImpl }),
|
|
"lumen-2026",
|
|
{
|
|
trustedKeys: new Map([[keys.fingerprint, publicKeyFromDerBase64(keys.publicKeyDerBase64)]]),
|
|
appVersion: "1.0.0",
|
|
supportedSchemaRange: [1],
|
|
},
|
|
);
|
|
if (!result || "skipped" in result) throw new Error("expected candidate");
|
|
expect(result.result).toMatchObject({ ok: false, code: "signature_mismatch" });
|
|
expect(calls).toHaveLength(3);
|
|
expect(calls.some((url) => /emergency|schedule|map|info|assets\.json/.test(url))).toBe(false);
|
|
});
|
|
|
|
it("has no activation, auth, push, background-sync, mesh, or IndexedDB dependency", async () => {
|
|
const source = await import("../../src/sync/pull.js");
|
|
expect(source).not.toHaveProperty("activateStagedPackage");
|
|
expect(TransportError).toBeDefined();
|
|
});
|
|
});
|