This commit is contained in:
parent
8e71537d57
commit
fcc18ddcc8
96 changed files with 8074 additions and 214 deletions
200
tests/unit/transport.test.ts
Normal file
200
tests/unit/transport.test.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/* 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 availability from the browser online hint without making a request", () => {
|
||||
expect(transport.isAvailable()).toBe(true);
|
||||
Object.defineProperty(navigator, "onLine", { configurable: true, value: false });
|
||||
expect(transport.isAvailable()).toBe(false);
|
||||
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],
|
||||
},
|
||||
);
|
||||
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("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],
|
||||
},
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue