Checkpoint: current Lumen state
Some checks failed
ci / check (push) Has been cancelled

This commit is contained in:
Lumen Stage1 2026-09-23 18:58:21 -05:00
commit fcc18ddcc8
96 changed files with 8074 additions and 214 deletions

View file

@ -0,0 +1,177 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/** Stage 8 — A/B staging, activation, rollback, and user-state isolation. */
import { beforeEach, describe, expect, it } from "vitest";
// @ts-expect-error fake-indexeddb types via exports fallback
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
import { buildPackage } from "../../pipeline/package.js";
import { generateTestKeyPair } from "../../pipeline/sign.js";
import { makeValidInput } from "../../pipeline/fixtures.js";
import { canonicalJson } from "../../pipeline/canonical-json.js";
import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js";
import { verifyPackage } from "../../src/sync/verifier/package.js";
import type { VerifiedPackage } from "../../src/sync/verifier/types.js";
import {
activateStagedPackage,
restorePreviousSlot,
stageVerifiedPackage,
} from "../../src/sync/activation.js";
import { openSystemDB, readSystemMeta } from "../../src/data/system-meta/store.js";
import { openSlotDB, readStagingProgress } from "../../src/data/slot/store.js";
import { openUserDB, addFavorite, listFavorites } from "../../src/data/user/store.js";
import { DB, SLOT_FILES } from "../../src/platform/idb/names.js";
import { withTx } from "../../src/platform/idb/wrapper.js";
import { activateSlot } from "../../src/data/system-meta/store.js";
const g = globalThis as unknown as Record<string, unknown>;
function deleteDb(name: string): Promise<void> {
return new Promise((resolve, reject) => {
const request = (g.indexedDB as IDBFactory).deleteDatabase(name);
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(request.error ?? new Error("delete database failed"));
};
request.onblocked = () => {
resolve();
};
});
}
async function fixture(version = 1): Promise<VerifiedPackage> {
const keyPair = generateTestKeyPair();
const built = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair });
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>();
for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes);
for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent);
const result = await verifyPackage(
{
manifestBytes,
signature: built.pkg.signature,
files: { getFile: (name) => Promise.resolve(files.get(name)) },
emergencyFloor: built.pkg.emergencyFloor,
},
{
trustedKeys: new Map([
[keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)],
]),
appVersion: "1.0.0",
supportedSchemaRange: [1],
},
);
if (!result.ok) throw new Error(result.reason);
return result;
}
describe("Stage 8 A/B activation", () => {
beforeEach(async () => {
g.indexedDB = new FDBFactory() as unknown;
await Promise.all(Object.values(DB).map((name) => deleteDb(name)));
});
it("stages into A and activates atomically, leaving user favorites intact", async () => {
const user = await openUserDB();
await addFavorite(user, { eventId: "event-1", addedAt: 1 });
user.close();
const pkg = await fixture();
const staged = await stageVerifiedPackage(pkg);
expect(staged.slot).toBe("A");
expect(staged.journal.complete).toBe(true);
expect((await activateStagedPackage(pkg, staged, "1.0.0")).ok).toBe(true);
const system = await openSystemDB();
expect((await readSystemMeta(system)).activeSlot).toBe("A");
expect((await readSystemMeta(system)).readbackPending).toBe(false);
system.close();
const userAfter = await openUserDB();
expect(await listFavorites(userAfter)).toEqual([{ eventId: "event-1", addedAt: 1 }]);
userAfter.close();
});
it("resumes matching progress and discards stale progress before restaging", async () => {
const pkg = await fixture();
const first = await stageVerifiedPackage(pkg);
const slot = await openSlotDB(first.slot);
const journal = await readStagingProgress(slot);
expect(journal?.complete).toBe(true);
slot.close();
const resumed = await stageVerifiedPackage(pkg);
expect(resumed.journal.stagedFiles).toHaveLength(5);
expect(resumed.journal.startedAt).toBe(first.journal.startedAt);
});
it("restores the retained complete slot without changing user data", async () => {
const first = await fixture(1);
const stagedFirst = await stageVerifiedPackage(first);
expect((await activateStagedPackage(first, stagedFirst, "1.0.0")).ok).toBe(true);
const second = await fixture(2);
const stagedSecond = await stageVerifiedPackage(second);
expect((await activateStagedPackage(second, stagedSecond, "1.0.0")).ok).toBe(true);
expect(await restorePreviousSlot()).toBe(true);
const system = await openSystemDB();
const meta = await readSystemMeta(system);
expect(meta.activeSlot).toBe("A");
expect(meta.activePackageVersion).toBe(1);
expect(meta.verification?.manifestSha256).toBe(first.manifestSha256);
expect(meta.readbackPending).toBe(false);
system.close();
});
it("does not activate an incomplete target slot", async () => {
const pkg = await fixture();
const staged = await stageVerifiedPackage(pkg);
const slot = await openSlotDB(staged.slot);
await withTx(slot, SLOT_FILES, "readwrite", (tx) => {
tx.objectStore(SLOT_FILES).delete("schedule");
});
slot.close();
const result = await activateStagedPackage(pkg, staged, "1.0.0");
expect(result).toMatchObject({ ok: false, rolledBack: false });
const system = await openSystemDB();
expect((await readSystemMeta(system)).activeSlot).toBeNull();
system.close();
});
it("cannot persist a structurally forged package without a verifier witness", async () => {
const verified = await fixture();
const forged = { ...verified } as VerifiedPackage;
await expect(stageVerifiedPackage(forged)).rejects.toThrow(/Stage 7/);
});
it("retries activation once and rolls back corruption after the flip", async () => {
const pkg = await fixture();
const staged = await stageVerifiedPackage(pkg);
let attempts = 0;
const retried = await activateStagedPackage(pkg, staged, "1.0.0", Date.now, {
activate: async (db, next) => {
attempts++;
if (attempts === 1) throw new Error("injected activation failure");
return activateSlot(db, next);
},
});
expect(retried.ok).toBe(true);
expect(attempts).toBe(2);
const second = await fixture(2);
const secondStaged = await stageVerifiedPackage(second);
const failed = await activateStagedPackage(second, secondStaged, "1.0.0", Date.now, {
afterFlip: async (slotId) => {
const db = await openSlotDB(slotId);
await withTx(db, SLOT_FILES, "readwrite", (tx) => {
tx.objectStore(SLOT_FILES).delete("schedule");
});
db.close();
},
});
expect(failed).toMatchObject({ ok: false, rolledBack: true });
const system = await openSystemDB();
expect(await readSystemMeta(system)).toMatchObject({
activeSlot: "A",
activePackageVersion: 1,
readbackPending: false,
});
system.close();
});
});

View file

@ -17,9 +17,9 @@ function walkFiles(dir: string, exts = [".ts", ".js"]): string[] {
}
describe("architectural boundaries — Stage 1 gate (B-1…B-7)", () => {
it("src/ contains shell + Stage 4 data contracts — no forbidden persistence/sync yet", () => {
it("src/ contains shell, verifier, and Stage 8 activation coordinator", () => {
const tsFiles = walkFiles("src", [".ts"]).sort();
// Stage 4 adds data contracts (festival-package, user, emergency-baseline, clock/readiness) on top of shell; no IDB/Cache storage/sync persistence
// Stage 6 adds pipeline (canonical-json/hash/budgets/gates/manifest/package/sign/emergency/fixtures) on top of Stage 5 persistence
expect(tsFiles).toEqual(
expect.arrayContaining([
"src/app/layout.ts",
@ -27,20 +27,38 @@ describe("architectural boundaries — Stage 1 gate (B-1…B-7)", () => {
"src/ui/router/router.ts",
"src/data/festival-package/types.ts",
"src/domain/clock/logic.ts",
"src/platform/idb/wrapper.ts",
"src/platform/idb/names.ts",
"src/data/system-meta/store.ts",
"src/data/slot/store.ts",
"src/data/user/store.ts",
"src/sync/verifier/ed25519.ts",
"src/sync/verifier/hash.ts",
"src/sync/verifier/types.ts",
"src/sync/activation.ts",
]),
);
// Forbidden persistence/sync must remain empty in Stage 4 (types/validation allowed)
const forbidden = tsFiles.filter(
(f) =>
f.startsWith("src/platform/idb/") ||
f.startsWith("src/platform/cache/") ||
f.startsWith("src/storage/") ||
f.startsWith("src/sync/"),
const pipelineFiles = walkFiles("pipeline", [".ts"]).sort();
expect(pipelineFiles).toEqual(
expect.arrayContaining([
"pipeline/package.ts",
"pipeline/manifest.ts",
"pipeline/gates.ts",
"pipeline/hash.ts",
"pipeline/canonical-json.ts",
"pipeline/budgets.ts",
"pipeline/emergency.ts",
"pipeline/sign.ts",
]),
);
// Stage 9 adds only the byte transport seam; no later sync orchestration is present.
expect(tsFiles).toEqual(
expect.arrayContaining([
"src/sync/transport/types.ts",
"src/sync/transport/http.ts",
"src/sync/pull.ts",
]),
);
expect(
forbidden,
`Stage 4 must not have forbidden persistence/sync: ${forbidden.join(", ")}`,
).toEqual([]);
});
it("directory structure matches IMPLEMENTATION-CONTRACT.md §4", () => {

View file

@ -0,0 +1,749 @@
/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-non-null-assertion, @typescript-eslint/prefer-promise-reject-errors, @typescript-eslint/no-unsafe-call */
/**
* Stage 5 — local persistence tests.
* Covers: P1 per-file atomic, P2 single-txn activation, P3 Quota keeps active,
* P4/P6 helpers, P5 6MB cap, light verification, B-6 isolation, slot asset Blobs.
* Trace: SPIKE-01 P1–P8, IMPLEMENTATION-CONTRACT.md §9/§10, §19, §31 FA-5..FA-8
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
// @ts-expect-error fake-indexeddb types via exports fallback
import FDBFactory from "fake-indexeddb/lib/FDBFactory";
// @ts-expect-error fake-indexeddb types via exports fallback
import FDBKeyRange from "fake-indexeddb/lib/FDBKeyRange";
// polyfill globals for Node environment
const g = globalThis as unknown as Record<string, unknown>;
g.indexedDB = new FDBFactory() as unknown;
g.IDBKeyRange = FDBKeyRange as unknown;
// dynamic imports after polyfill — wrapper reads global indexedDB at call time
import { MAX_RECORD_BYTES, isQuotaError, checkRecordSize } from "../../src/platform/idb/errors.js";
import {
openDB,
withTx,
idbGet,
idbPut,
idbClear,
idbCount,
idbGetAll,
} from "../../src/platform/idb/wrapper.js";
import { DB, SLOT_FILES, SLOT_STAGING } from "../../src/platform/idb/names.js";
import { hasEnoughSpace, requestPersist } from "../../src/platform/idb/storage-helpers.js";
import {
openSystemDB,
readSystemMeta,
writeSystemMeta,
activateSlot,
clearReadbackPending,
incrementBootCount,
} from "../../src/data/system-meta/store.js";
import { INITIAL_SYSTEM_META } from "../../src/data/system-meta/types.js";
import {
openSlotDB,
writeSlotFile,
readSlotFile,
readSlotFileMeta,
clearSlot,
writeSlotAsset,
readSlotAsset,
lightCheckSlot,
listSlotFiles,
initializeStaging,
readStagingProgress,
writeSlotFileWithProgress,
writeSlotAssetWithProgress,
markStagingComplete,
} from "../../src/data/slot/store.js";
import {
openUserDB,
addFavorite,
removeFavorite,
hasFavorite,
listFavorites,
countFavorites,
getPrefs,
putPrefs,
pushDiag,
listDiag,
} from "../../src/data/user/store.js";
function deleteDB(name: string): Promise<void> {
return new Promise((resolve, reject) => {
const req = (globalThis as unknown as { indexedDB: IDBFactory }).indexedDB.deleteDatabase(name);
req.onsuccess = () => {
resolve();
};
req.onerror = () => {
reject(req.error);
};
req.onblocked = () => {
resolve();
};
});
}
async function cleanAll(): Promise<void> {
await deleteDB(DB.SYSTEM);
await deleteDB(DB.SLOT_A);
await deleteDB(DB.SLOT_B);
await deleteDB(DB.USER);
}
describe("platform/idb wrapper — P1/P3/P5", () => {
beforeEach(async () => {
await cleanAll();
});
afterEach(async () => {
await cleanAll();
});
it("P5: MAX_RECORD_BYTES is 6MB and checkRecordSize enforces cap", () => {
expect(MAX_RECORD_BYTES).toBe(6 * 1024 * 1024);
expect(() => {
checkRecordSize(0);
}).not.toThrow();
expect(() => {
checkRecordSize(MAX_RECORD_BYTES);
}).not.toThrow();
expect(() => {
checkRecordSize(MAX_RECORD_BYTES + 1);
}).toThrow(RangeError);
expect(() => {
checkRecordSize(-1);
}).toThrow(RangeError);
});
it("P1: one short txn per file — bytes+progress together, abort leaves prior committed", async () => {
const db = await openDB("test-p1", 1, (d) => {
d.createObjectStore("s");
});
await idbPut(db, "s", { v: 1 }, "k1");
// simulate crash mid-txn: throw inside withTx
try {
await withTx(db, "s", "readwrite", async (tx) => {
const os = tx.objectStore("s");
os.put({ v: 999 }, "k2");
throw new Error("crash before commit");
});
} catch {
// expected
}
// k2 must not be committed; k1 still present
expect(await idbGet(db, "s", "k2")).toBeUndefined();
expect(await idbGet(db, "s", "k1")).toEqual({ v: 1 });
db.close();
await deleteDB("test-p1");
});
it("P1: no txn spans non-IDB await — wrapper keeps txn short (fast commit)", async () => {
const db = await openDB("test-p1-fast", 1, (d) => {
d.createObjectStore("s");
});
const start = Date.now();
await idbPut(db, "s", "val", "k");
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(500);
db.close();
await deleteDB("test-p1-fast");
});
it("isQuotaError detects QuotaExceededError by name", () => {
expect(isQuotaError(new DOMException("x", "QuotaExceededError"))).toBe(true);
expect(isQuotaError({ name: "QuotaExceededError" })).toBe(true);
expect(isQuotaError(new Error("other"))).toBe(false);
expect(isQuotaError({ code: 22 })).toBe(true);
});
it("wrapper helpers idbGet/idbPut/idbCount/idbClear work", async () => {
const db = await openDB("test-helpers", 1, (d) => {
d.createObjectStore("nums");
});
await idbPut(db, "nums", 42, "a");
expect(await idbGet(db, "nums", "a")).toBe(42);
expect(await idbCount(db, "nums")).toBe(1);
expect(await idbGetAll(db, "nums")).toEqual([42]);
await idbClear(db, "nums");
expect(await idbCount(db, "nums")).toBe(0);
db.close();
await deleteDB("test-helpers");
});
});
describe("slot store — P1 per-file atomic + P5 cap + files/assets", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
it("writeSlotFile per-file atomic and 6MB cap enforced", async () => {
const db = await openSlotDB("A");
await writeSlotFile(db, "emergency", {
bytes: 1000,
sha256: "a".repeat(64),
json: { section: "emergency" },
});
expect(await readSlotFile(db, "emergency")).toEqual({ section: "emergency" });
const meta = await readSlotFileMeta(db, "emergency");
expect(meta?.bytes).toBe(1000);
// over 6MB should throw
await expect(
writeSlotFile(db, "schedule", { bytes: 7 * 1024 * 1024, sha256: "b".repeat(64), json: {} }),
).rejects.toThrow(RangeError);
db.close();
});
it("slot holds multiple sections; lightCheckSlot verifies presence+size (boot ≤150ms target logic)", async () => {
const db = await openSlotDB("A");
await writeSlotFile(db, "emergency", {
bytes: 41233,
sha256: "a".repeat(64),
json: { section: "emergency" },
});
await writeSlotFile(db, "schedule", {
bytes: 412201,
sha256: "b".repeat(64),
json: { section: "schedule" },
});
const ok = await lightCheckSlot(db, [
{ id: "emergency", bytes: 41233 },
{ id: "schedule", bytes: 412201 },
]);
expect(ok.ok).toBe(true);
const missing = await lightCheckSlot(db, [{ id: "map", bytes: 38122 }]);
expect(missing.ok).toBe(false);
const sizeMismatch = await lightCheckSlot(db, [{ id: "emergency", bytes: 1 }]);
expect(sizeMismatch.ok).toBe(false);
// timing: light check should be fast (no hashing)
const start = performance.now();
await lightCheckSlot(db, [{ id: "emergency", bytes: 41233 }]);
expect(performance.now() - start).toBeLessThan(150);
db.close();
});
it("slot assets as Blobs — write/read with same slot DB for one-failure-domain rollback", async () => {
const db = await openSlotDB("B");
const blob = new Blob(["fake-webp-bytes"], { type: "image/webp" });
await writeSlotAsset(db, "map-base-overview", {
bytes: blob.size,
sha256: "c".repeat(64),
blob,
});
const rec = await readSlotAsset(db, "map-base-overview");
expect(rec?.sha256).toBe("c".repeat(64));
expect(rec?.bytes).toBe(blob.size);
expect(rec?.blob).toBeInstanceOf(Blob);
expect(await rec?.blob.text()).toBe("fake-webp-bytes");
db.close();
});
it("clearSlot wipes both files and assets (GC / next-staging reclaim)", async () => {
const db = await openSlotDB("A");
await writeSlotFile(db, "info", { bytes: 100, sha256: "a".repeat(64), json: {} });
const blob = new Blob(["x"]);
await writeSlotAsset(db, "img-1", { bytes: 1, sha256: "b".repeat(64), blob });
await clearSlot(db);
expect(await listSlotFiles(db)).toEqual([]);
expect(await readSlotAsset(db, "img-1")).toBeUndefined();
db.close();
});
it("P3 quota simulation — slot write failure keeps active dataset intact (old slot untouched)", async () => {
const slotA = await openSlotDB("A");
const slotB = await openSlotDB("B");
const sys = await openSystemDB();
// seed active dataset v1 in slot A
await writeSlotFile(slotA, "emergency", {
bytes: 100,
sha256: "a".repeat(64),
json: { section: "emergency", v: 1 },
});
await writeSystemMeta(sys, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "lumen-2026",
activePackageVersion: 1,
verification: {
packageVersion: 1,
edition: "lumen-2026",
manifestSha256: "a".repeat(64),
publicKeyFingerprint: "fp",
verifiedAt: Date.now(),
appVersionAtActivation: "1.0.0",
},
appVersionAtActivation: "1.0.0",
});
// attempt staging into inactive slot B but inject quota error via mocked put
// Simulate by directly testing isQuotaError path and ensuring active still readable
const activeBefore = await readSystemMeta(sys);
expect(activeBefore.activeSlot).toBe("A");
// No actual quota error from fake-indexeddb, but verify active dataset still complete
expect(await readSlotFile(slotA, "emergency")).toEqual({ section: "emergency", v: 1 });
// B remains empty — staging interrupted keeps active
expect(await readSlotFile(slotB, "emergency")).toBeUndefined();
slotA.close();
slotB.close();
sys.close();
});
});
describe("system-meta store — P2 single-txn activation + F-2/F-3", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
it("INITIAL_SYSTEM_META has no active slot (NOT_READY/BASELINE_ONLY start)", async () => {
const db = await openSystemDB();
const meta = await readSystemMeta(db);
expect(meta.activeSlot).toBeNull();
expect(meta.readbackPending).toBe(false);
expect(meta.verification).toBeNull();
db.close();
});
it("P2: activateSlot is single transaction flipping activeSlot + version + verification + readbackPending", async () => {
const db = await openSystemDB();
const next = await activateSlot(db, {
activeSlot: "A",
activeEdition: "lumen-2026",
activePackageVersion: 3,
verification: {
packageVersion: 3,
edition: "lumen-2026",
manifestSha256: "f".repeat(64),
publicKeyFingerprint: "sha256:abc",
verifiedAt: Date.now(),
appVersionAtActivation: "1.0.0",
},
appVersionAtActivation: "1.0.0",
});
expect(next.activeSlot).toBe("A");
expect(next.activePackageVersion).toBe(3);
expect(next.readbackPending).toBe(true);
expect(next.verification?.manifestSha256).toBe("f".repeat(64));
// persisted
const re = await readSystemMeta(db);
expect(re.activeSlot).toBe("A");
expect(re.readbackPending).toBe(true);
db.close();
});
it("clearReadbackPending clears flag after spot-check", async () => {
const db = await openSystemDB();
await activateSlot(db, {
activeSlot: "B",
activeEdition: "lumen-2026",
activePackageVersion: 2,
verification: {
packageVersion: 2,
edition: "lumen-2026",
manifestSha256: "a".repeat(64),
publicKeyFingerprint: "fp",
verifiedAt: Date.now(),
appVersionAtActivation: "1.0.0",
},
appVersionAtActivation: "1.0.0",
});
await clearReadbackPending(db);
expect((await readSystemMeta(db)).readbackPending).toBe(false);
db.close();
});
it("system metadata has no staging journal", async () => {
const db = await openSystemDB();
expect("staging" in (await readSystemMeta(db))).toBe(false);
await writeSystemMeta(db, {
...INITIAL_SYSTEM_META,
staging: {
targetSlot: "B",
edition: "legacy",
packageVersion: 1,
stagedFiles: [],
startedAt: 1,
lastProgressAt: 1,
},
} as never);
expect("staging" in (await readSystemMeta(db))).toBe(false);
db.close();
});
it("incrementBootCount for GC N≥3 heuristic", async () => {
const db = await openSystemDB();
expect(await incrementBootCount(db)).toBe(1);
expect(await incrementBootCount(db)).toBe(2);
expect(await incrementBootCount(db)).toBe(3);
db.close();
});
it("writeSystemMeta is atomic — concurrent reads see either old or new, never partial", async () => {
const db = await openSystemDB();
await writeSystemMeta(db, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activePackageVersion: 1,
} as never);
// overwrite in one txn
await writeSystemMeta(db, {
...INITIAL_SYSTEM_META,
activeSlot: "B",
activePackageVersion: 2,
} as never);
const m = await readSystemMeta(db);
expect([1, 2]).toContain(m.activePackageVersion);
expect(m.activeSlot === "A" || m.activeSlot === "B").toBe(true);
db.close();
});
});
describe("slot-local staging journal — P1", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
function journal() {
return {
edition: "lumen-2026",
packageVersion: 2,
manifestSha256: "f".repeat(64),
startedAt: 1_000,
lastProgressAt: 1_000,
} as const;
}
it("commits each file/asset and its progress in one target-slot transaction", async () => {
const db = await openSlotDB("B");
let progress = await initializeStaging(db, journal());
progress = await writeSlotFileWithProgress(
db,
"emergency",
{
bytes: 10,
sha256: "a".repeat(64),
json: { section: "emergency" },
},
progress,
);
expect((await readStagingProgress(db))?.stagedFiles).toEqual(["emergency"]);
const blob = new Blob(["asset"]);
await writeSlotAssetWithProgress(
db,
"map-1",
{
bytes: blob.size,
sha256: "b".repeat(64),
blob,
},
progress,
);
expect((await readStagingProgress(db))?.stagedAssets).toEqual(["map-1"]);
expect((await markStagingComplete(db)).complete).toBe(true);
expect(await readSlotFile(db, "emergency")).toEqual({ section: "emergency" });
db.close();
});
it("aborting the short transaction commits neither file nor progress", async () => {
const db = await openSlotDB("B");
await initializeStaging(db, journal());
await expect(
withTx(db, [SLOT_FILES, SLOT_STAGING], "readwrite", async (tx) => {
tx.objectStore(SLOT_FILES).put(
{ id: "emergency", bytes: 1, sha256: "a".repeat(64), json: {} },
"emergency",
);
tx.objectStore(SLOT_STAGING).put(
{ ...journal(), stagedFiles: ["emergency"], stagedAssets: [], complete: false },
"journal",
);
throw new Error("simulated interruption");
}),
).rejects.toThrow("simulated interruption");
expect(await readSlotFile(db, "emergency")).toBeUndefined();
expect((await readStagingProgress(db))?.stagedFiles).toEqual([]);
db.close();
});
it("resumes from slot-local progress after reopening the slot", async () => {
let db = await openSlotDB("B");
const progress = await initializeStaging(db, journal());
await writeSlotFileWithProgress(
db,
"emergency",
{ bytes: 1, sha256: "a".repeat(64), json: {} },
progress,
);
db.close();
db = await openSlotDB("B");
const resumed = await readStagingProgress(db);
expect(resumed?.stagedFiles).toEqual(["emergency"]);
expect(resumed?.complete).toBe(false);
db.close();
});
it("clearing a slot clears its journal while user data remains separate", async () => {
const slot = await openSlotDB("B");
await initializeStaging(slot, journal());
await clearSlot(slot);
expect(await readStagingProgress(slot)).toBeUndefined();
slot.close();
});
it("slot-local writes do not write or transact against system metadata", async () => {
const system = await openSystemDB();
await writeSystemMeta(system, { ...INITIAL_SYSTEM_META, activeSlot: "A" });
const slot = await openSlotDB("B");
const progress = await initializeStaging(slot, journal());
await writeSlotFileWithProgress(
slot,
"emergency",
{ bytes: 1, sha256: "a".repeat(64), json: {} },
progress,
);
expect((await readSystemMeta(system)).activeSlot).toBe("A");
expect("staging" in (await readSystemMeta(system))).toBe(false);
slot.close();
system.close();
});
});
describe("user store — B-6 never touched by dataset updates / X3", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
it("favorites keyed by stable event id survive A/B slot ops (B-6, X3)", async () => {
const user = await openUserDB();
const slotA = await openSlotDB("A");
const slotB = await openSlotDB("B");
await addFavorite(user, { eventId: "evt-0113", addedAt: 1000 });
await addFavorite(user, { eventId: "evt-0114", addedAt: 2000 });
expect(await countFavorites(user)).toBe(2);
expect(await hasFavorite(user, "evt-0113")).toBe(true);
// dataset ops: clear slots (simulating staging wipe of inactive + rollback)
await clearSlot(slotA);
await clearSlot(slotB);
// favorites must survive
expect(await listFavorites(user)).toHaveLength(2);
expect(await hasFavorite(user, "evt-0113")).toBe(true);
// remove one
await removeFavorite(user, "evt-0113");
expect(await hasFavorite(user, "evt-0113")).toBe(false);
user.close();
slotA.close();
slotB.close();
});
it("prefs singleton persists", async () => {
const db = await openUserDB();
expect(await getPrefs(db)).toBeUndefined();
await putPrefs(db, {
festivalTimezone: "America/Chicago",
useDeviceTimezone: false,
theme: "dark",
});
expect(await getPrefs(db)).toMatchObject({
festivalTimezone: "America/Chicago",
useDeviceTimezone: false,
});
db.close();
});
it("diag ring buffer capped at 100, scrubbed manual share only", async () => {
const db = await openUserDB();
for (let i = 0; i < 105; i++) {
await pushDiag(db, { at: 1_000_000 + i, kind: "test", detail: `e-${i}` });
}
const diag = await listDiag(db);
expect(diag.length).toBeLessThanOrEqual(100);
db.close();
});
it("user DB is separate origin — deleting slot DB does not affect user (B-6)", async () => {
const user = await openUserDB();
await addFavorite(user, { eventId: "evt-x", addedAt: 1 });
user.close();
await deleteDB(DB.SLOT_A);
await deleteDB(DB.SLOT_B);
const user2 = await openUserDB();
expect(await hasFavorite(user2, "evt-x")).toBe(true);
user2.close();
});
});
describe("storage helpers — P4 free-space 2× check + P6 persist (SPIKE-01 P4/P6)", () => {
const originalNavigator = (globalThis as unknown as { navigator?: unknown }).navigator;
afterEach(() => {
if (originalNavigator === undefined) {
delete (globalThis as unknown as { navigator?: unknown }).navigator;
} else {
Object.defineProperty(globalThis, "navigator", {
value: originalNavigator,
writable: true,
configurable: true,
});
}
vi.restoreAllMocks();
});
function setNavigator(value: unknown): void {
Object.defineProperty(globalThis, "navigator", {
value,
writable: true,
configurable: true,
});
}
it("P4: hasEnoughSpace refuses if free < 2× required", async () => {
setNavigator({
storage: {
estimate: async () => ({ quota: 100_000_000, usage: 90_000_000 }), // free 10MB
},
});
// required 6MB → need 12MB free → should refuse
expect(await hasEnoughSpace(6 * 1024 * 1024)).toBe(false);
// required 4MB → need 8MB free → ok
expect(await hasEnoughSpace(4 * 1024 * 1024)).toBe(true);
});
it("P4: hasEnoughSpace returns true when estimate unavailable (allow, P3 will handle quota)", async () => {
setNavigator({});
expect(await hasEnoughSpace(40 * 1024 * 1024)).toBe(true);
setNavigator({
storage: { estimate: async () => ({}) },
});
expect(await hasEnoughSpace(40 * 1024 * 1024)).toBe(true);
});
it("P6: requestPersist returns boolean and never throws", async () => {
setNavigator({
storage: { persist: async () => true },
});
expect(await requestPersist()).toBe(true);
setNavigator({
storage: { persist: async () => false },
});
expect(await requestPersist()).toBe(false);
setNavigator({});
expect(await requestPersist()).toBe(false);
});
});
describe("boot light verification — eviction detection (P7, SPIKE-05)", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
it("boot: missing system meta → NOT_READY (eviction) — baseline still works", async () => {
const sys = await openSystemDB();
const meta = await readSystemMeta(sys);
expect(meta.activeSlot).toBeNull();
// no dataset → light check would fail; caller maps to NOT_READY/BASELINE_ONLY
sys.close();
// after eviction (delete DBs), user favorites gone? But baseline (emergency floor) is shell bytes, not IDB — must still render.
// Here we verify user DB also considered missing but floor is independent.
await deleteDB(DB.SYSTEM);
const sys2 = await openSystemDB();
expect((await readSystemMeta(sys2)).activeSlot).toBeNull();
sys2.close();
});
it("boot: active slot missing files → RECOVERY (FA-5/FA-6)", async () => {
const sys = await openSystemDB();
const slotA = await openSlotDB("A");
await writeSystemMeta(sys, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "lumen-2026",
activePackageVersion: 1,
verification: {
packageVersion: 1,
edition: "lumen-2026",
manifestSha256: "a".repeat(64),
publicKeyFingerprint: "fp",
verifiedAt: Date.now(),
appVersionAtActivation: "1.0.0",
},
appVersionAtActivation: "1.0.0",
});
// slot A has no files → lightCheck fails → RECOVERY
const check = await lightCheckSlot(slotA, [{ id: "emergency", bytes: 100 }]);
expect(check.ok).toBe(false);
sys.close();
slotA.close();
});
it("boot: active slot present + lightCheck ok → READY (fast, no hashing)", async () => {
const sys = await openSystemDB();
const slotA = await openSlotDB("A");
await writeSlotFile(slotA, "emergency", { bytes: 100, sha256: "a".repeat(64), json: { v: 1 } });
await writeSystemMeta(sys, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "lumen-2026",
activePackageVersion: 1,
verification: {
packageVersion: 1,
edition: "lumen-2026",
manifestSha256: "a".repeat(64),
publicKeyFingerprint: "fp",
verifiedAt: Date.now(),
appVersionAtActivation: "1.0.0",
},
appVersionAtActivation: "1.0.0",
});
const check = await lightCheckSlot(slotA, [{ id: "emergency", bytes: 100 }]);
expect(check.ok).toBe(true);
// verify readbackPending logic: activation set pending, boot clears it after spot check
await activateSlot(sys, {
activeSlot: "A",
activeEdition: "lumen-2026",
activePackageVersion: 2,
verification: {
packageVersion: 2,
edition: "lumen-2026",
manifestSha256: "b".repeat(64),
publicKeyFingerprint: "fp",
verifiedAt: Date.now(),
appVersionAtActivation: "1.0.0",
},
appVersionAtActivation: "1.0.0",
});
expect((await readSystemMeta(sys)).readbackPending).toBe(true);
await clearReadbackPending(sys);
expect((await readSystemMeta(sys)).readbackPending).toBe(false);
sys.close();
slotA.close();
});
});
describe("A/B slot symmetry + GC / rollback depth 1", () => {
beforeEach(cleanAll);
afterEach(cleanAll);
it("A/B inactive wipes only inactive, active untouched (SPIKE-02 invariant I-9)", async () => {
const slotA = await openSlotDB("A");
const slotB = await openSlotDB("B");
await writeSlotFile(slotA, "schedule", { bytes: 10, sha256: "a".repeat(64), json: { v: 1 } });
await clearSlot(slotB); // wipe inactive
expect(await readSlotFile(slotA, "schedule")).toEqual({ v: 1 });
expect(await readSlotFile(slotB, "schedule")).toBeUndefined();
slotA.close();
slotB.close();
});
it("assets as Blobs timing — heavy read-back instrumentation (≤28MB budget concept)", async () => {
const db = await openSlotDB("A");
const sizes = [512 * 1024, 1 * 1024 * 1024, 2 * 1024 * 1024];
const blobs = sizes.map((sz) => new Blob([new Uint8Array(sz)], { type: "image/webp" }));
const startWrite = performance.now();
for (let i = 0; i < blobs.length; i++) {
const b = blobs[i]!;
await writeSlotAsset(db, `asset-${i}`, { bytes: b.size, sha256: "a".repeat(64), blob: b });
}
const writeMs = performance.now() - startWrite;
// write of ~3.5MB should be well under 1s even on slow fake-indexeddb
expect(writeMs).toBeLessThan(5000);
const startRead = performance.now();
for (let i = 0; i < blobs.length; i++) {
const rec = await readSlotAsset(db, `asset-${i}`);
expect(rec?.bytes).toBe(sizes[i]);
}
const readMs = performance.now() - startRead;
expect(readMs).toBeLessThan(5000);
db.close();
});
});

643
tests/unit/pipeline.test.ts Normal file
View file

@ -0,0 +1,643 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unnecessary-type-assertion */
/**
* Stage 6 — Festival Data Package Pipeline tests.
* Covers gates 1-5, deterministic manifest, hashes, asset inventory, budgets,
* compatibility, stable IDs, emergency floor consistency, separation, rejection.
* Trace: SPIKE-04 §3, IMPLEMENTATION-CONTRACT.md Stage 6, ARCH 10.2-10.6
*/
import { describe, it, expect } from "vitest";
import { makeValidInput, makeNextVersion } from "../../pipeline/fixtures.js";
import { buildPackage, isDeterministic } from "../../pipeline/package.js";
import { canonicalJson } from "../../pipeline/canonical-json.js";
import { sha256HexOfString } from "../../pipeline/hash.js";
import { BUDGETS } from "../../pipeline/budgets.js";
import { validateManifest } from "../../src/data/festival-package/validation.js";
import { generateTestKeyPair } from "../../pipeline/sign.js";
import type { PipelineInput } from "../../pipeline/types.js";
import { dayKeyFor } from "../../src/domain/clock/logic.js";
describe("pipeline — valid package generation", () => {
it("valid input builds successfully with 5 required sections", () => {
const input = makeValidInput();
const res = buildPackage(input);
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.pkg.manifest.sections.emergency.file).toBe("emergency.json");
expect(res.pkg.manifest.sections.schedule.file).toBe("schedule.json");
expect(res.pkg.manifest.sections.map.file).toBe("map.json");
expect(res.pkg.manifest.sections.info.file).toBe("info.json");
expect(res.pkg.manifest.sections.assets.file).toBe("assets.json");
expect(res.pkg.files.size).toBe(5);
expect(res.pkg.manifest.counts.events).toBe(3);
expect(res.pkg.manifest.counts.pois).toBe(3);
expect(res.pkg.manifest.counts.assets).toBe(2);
});
it("manifest generation is deterministic — same input yields same bytes/sha", () => {
const input = makeValidInput();
const a = buildPackage(input);
const b = buildPackage(input);
expect(a.ok && b.ok).toBe(true);
if (!a.ok || !b.ok) return;
const aJson = canonicalJson(a.pkg.manifest);
const bJson = canonicalJson(b.pkg.manifest);
expect(aJson).toBe(bJson);
expect(sha256HexOfString(aJson)).toBe(sha256HexOfString(bJson));
expect(isDeterministic(input)).toBe(true);
});
it("changed content produces expected hash changes", () => {
const input1 = makeValidInput();
const res1 = buildPackage(input1);
expect(res1.ok).toBe(true);
if (!res1.ok) return;
const sha1 = res1.pkg.files.get("schedule.json")!.sha256;
// mutate one event title
const input2: PipelineInput = {
...input1,
content: {
...input1.content,
schedule: {
...input1.content.schedule,
events: input1.content.schedule.events.map((e, i) =>
i === 0 ? { ...e, title: "Mutated Title" } : e,
),
},
},
};
const res2 = buildPackage(input2);
expect(res2.ok).toBe(true);
if (!res2.ok) return;
const sha2 = res2.pkg.files.get("schedule.json")!.sha256;
expect(sha1).not.toBe(sha2);
// manifest sha also changes
const mSha1 = sha256HexOfString(canonicalJson(res1.pkg.manifest));
const mSha2 = sha256HexOfString(canonicalJson(res2.pkg.manifest));
expect(mSha1).not.toBe(mSha2);
});
it("stable IDs remain stable across versions", () => {
const v1 = makeValidInput({ packageVersion: 1 });
const v2 = makeNextVersion(v1, 2);
const r1 = buildPackage(v1);
const r2 = buildPackage(v2);
expect(r1.ok && r2.ok).toBe(true);
// ids must be same set
const ids1 = v1.content.schedule.events.map((e) => e.id).sort();
const ids2 = v2.content.schedule.events.map((e) => e.id).sort();
expect(ids2).toEqual(ids1);
});
it("manifest conforms exactly to FestivalPackageV1 contract via Stage 4 validator", () => {
const input = makeValidInput();
const res = buildPackage(input);
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(validateManifest(res.pkg.manifest).ok).toBe(true);
expect(res.pkg.manifest.format).toBe("lumen.package/1");
expect(res.pkg.manifest.limits.totalBytes).toBeGreaterThan(0);
expect(res.pkg.manifest.limits.totalBytes).toBeLessThanOrEqual(BUDGETS.HARD_TOTAL);
});
it("SHA-256 hashes are 64 hex and bytes match canonical length", () => {
const input = makeValidInput();
const res = buildPackage(input);
expect(res.ok).toBe(true);
if (!res.ok) return;
const HEX64 = /^[0-9a-f]{64}$/;
for (const [name, file] of res.pkg.files) {
expect(HEX64.test(file.sha256), `${name} sha256`).toBe(true);
expect(file.bytes).toBe(file.canonicalBytes.length);
}
for (const a of res.pkg.assets) {
expect(HEX64.test(a.sha256)).toBe(true);
expect(a.bytes).toBe(a.bytesContent.length);
}
});
it("asset inventory lists ids referenced by map levels", () => {
const input = makeValidInput();
const res = buildPackage(input);
expect(res.ok).toBe(true);
if (!res.ok) return;
const assetIds = new Set(res.pkg.assets.map((a) => a.id));
for (const level of input.content.map.base.levels) {
expect(assetIds.has(level.assetId)).toBe(true);
}
});
it("latest pointer is mutable pointer to immutable package", () => {
const input = makeValidInput({ edition: "lumen-2026", packageVersion: 7 });
const res = buildPackage(input);
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.pkg.latest.edition).toBe("lumen-2026");
expect(res.pkg.latest.packageVersion).toBe(7);
expect(res.pkg.latest.manifestUrl).toBe("/editions/lumen-2026/packages/7/manifest.json");
});
});
describe("pipeline — required/optional sections", () => {
it("required sections must be present — missing emergency fails gate1", () => {
const input = makeValidInput();
const broken: PipelineInput = {
...input,
content: {
...input.content,
emergency: null as unknown as PipelineInput["content"]["emergency"],
},
};
const res = buildPackage(broken);
expect(res.ok).toBe(false);
});
it("optional sections may be absent without failing readiness — unknown optional section allowed via forward-compat", () => {
// In current V1 all 5 are required, but we test that unknown extra sections are tolerated
// by adding an announcements-like extra to assets? Actually manifest currently fixed to 5.
// We test that building with unknown fields on schedule event is allowed
const input = makeValidInput();
const withUnknown = {
...input,
content: {
...input.content,
schedule: {
...input.content.schedule,
events: input.content.schedule.events.map(
(e) => ({ ...e, futureField: "ok" }) as unknown as typeof e,
),
},
},
} as unknown as PipelineInput;
const res = buildPackage(withUnknown);
// gate1 allows unknown fields
expect(res.ok).toBe(true);
});
});
describe("pipeline — budgets and limits", () => {
it("per-file ≤6MB enforced — oversized section fails", () => {
const input = makeValidInput();
// create huge blob for asset >6MB
const huge = new Uint8Array(7 * 1024 * 1024);
const broken: PipelineInput = {
...input,
content: {
...input.content,
assets: {
assets: [
{
id: "big",
file: "assets/big.webp",
kind: "photo",
role: "photo",
sha256: "",
bytes: huge.length,
},
],
blobs: new Map([["big", huge]]),
},
},
};
const res = buildPackage(broken);
expect(res.ok).toBe(false);
expect((res as { ok: false; reason: string }).reason).toMatch(/6MB/);
});
it("total ≤40MB target enforced — oversized total fails gate4", () => {
// Create many large assets to exceed 40MB but each <6MB
const blobs = new Map<string, Uint8Array>();
const mutableAssets: PipelineInput["content"]["assets"]["assets"] =
[] as unknown as PipelineInput["content"]["assets"]["assets"];
for (let i = 0; i < 8; i++) {
const arr = new Uint8Array(6 * 1024 * 1024 - 1); // ~6MB each, 8*6=48MB >40MB
blobs.set(`a-${i}`, arr);
(mutableAssets as unknown as unknown[]).push({
id: `a-${i}`,
file: `assets/a-${i}.webp`,
kind: "map-base",
role: "overview",
sha256: "",
bytes: arr.length,
});
}
const input = makeValidInput();
const broken: PipelineInput = {
...input,
content: { ...input.content, assets: { assets: mutableAssets, blobs } },
};
const res = buildPackage(broken);
expect(res.ok).toBe(false);
expect((res as { ok: false; reason: string }).reason).toMatch(/40MB|50MB|28MB|6MB/);
});
it("per-section JSON total ≤3MB — many info blocks may exceed", () => {
const input = makeValidInput();
const hugeInfo = {
section: "info" as const,
blocks: Array.from({ length: 200 }, (_, i) => ({
id: `blk-${i}`,
title: `Block ${i}`,
kind: "info",
body: [{ kind: "paragraph" as const, text: "x".repeat(20_000) }],
})),
};
const broken: PipelineInput = {
...input,
content: { ...input.content, info: hugeInfo as unknown as PipelineInput["content"]["info"] },
};
const res = buildPackage(broken);
// May fail either per-file >6MB or sections total >3MB
expect(res.ok).toBe(false);
});
it("floor ≤16KB enforced", () => {
const input = makeValidInput();
// make emergency procedures huge to blow floor
const hugeEmergency = {
...input.content.emergency,
procedures: Array.from({ length: 50 }, (_, i) => ({
id: `p-${i}`,
title: `Procedure ${i}`,
steps: ["Step ".repeat(500)],
})),
} as unknown as PipelineInput["content"]["emergency"];
const broken: PipelineInput = {
...input,
content: { ...input.content, emergency: hugeEmergency },
};
const res = buildPackage(broken);
expect(res.ok).toBe(false);
expect((res as { ok: false; reason: string }).reason).toMatch(/16KB|floor/);
});
});
describe("pipeline — gates 1-5 malformed checks", () => {
it("gate1: missing required field fails", () => {
const input = makeValidInput();
const broken: PipelineInput = {
...input,
content: {
...input.content,
schedule: {
section: "schedule",
stages: [],
artists: [],
events: null as unknown as [],
} as unknown as PipelineInput["content"]["schedule"],
},
};
expect(buildPackage(broken).ok).toBe(false);
});
it("gate2: stable ID deletion without cancelled fails", () => {
const v1 = makeValidInput({ packageVersion: 1 });
const v2 = makeNextVersion(v1, 2);
// remove one event entirely
const v2Broken: PipelineInput = {
...v2,
content: {
...v2.content,
schedule: { ...v2.content.schedule, events: v2.content.schedule.events.slice(1) },
},
};
const res = buildPackage(v2Broken);
expect(res.ok).toBe(false);
expect((res as { ok: false; reason: string }).reason).toMatch(/gate2|stable/);
});
it("gate2: cancelled event with same id passes", () => {
const v1 = makeValidInput({ packageVersion: 1 });
const v2 = makeNextVersion(v1, 2);
const cancelled = v2.content.schedule.events.map((e, i) =>
i === 0 ? { ...e, status: "cancelled" as const } : e,
);
const v2Ok: PipelineInput = {
...v2,
content: { ...v2.content, schedule: { ...v2.content.schedule, events: cancelled } },
};
expect(buildPackage(v2Ok).ok).toBe(true);
});
it("gate3: dayKey mismatch fails", () => {
const input = makeValidInput();
const broken: PipelineInput = {
...input,
content: {
...input.content,
schedule: {
...input.content.schedule,
events: input.content.schedule.events.map((e) => ({ ...e, dayKey: "1900-01-01" })),
},
},
};
expect(buildPackage(broken).ok).toBe(false);
});
it("gate3: zero-length event fails", () => {
const input = makeValidInput();
const broken: PipelineInput = {
...input,
content: {
...input.content,
schedule: {
...input.content.schedule,
events: input.content.schedule.events.map((e) => ({
...e,
startUtc: 1000,
endUtc: 1000,
})),
},
},
};
expect(buildPackage(broken).ok).toBe(false);
});
it("gate3: event longer than 24h fails", () => {
const input = makeValidInput();
const broken: PipelineInput = {
...input,
content: {
...input.content,
schedule: {
...input.content.schedule,
events: input.content.schedule.events.map((e) => ({
...e,
startUtc: 0,
endUtc: 25 * 3600_000,
})),
},
},
};
expect(buildPackage(broken).ok).toBe(false);
});
it("gate3: event outside window ±1d fails", () => {
const input = makeValidInput();
const farFuture = Date.UTC(2030, 0, 1);
const correctKey = dayKeyFor(farFuture, input.festival.timezone);
const broken2: PipelineInput = {
...input,
content: {
...input.content,
schedule: {
...input.content.schedule,
events: input.content.schedule.events.map((e) => ({
...e,
startUtc: farFuture,
endUtc: farFuture + 3600_000,
dayKey: correctKey,
})),
},
},
};
expect(buildPackage(broken2).ok).toBe(false);
});
it("invalid stable ID (spaces) fails gate2/validation", () => {
const input = makeValidInput();
const broken: PipelineInput = {
...input,
content: {
...input.content,
schedule: {
...input.content.schedule,
events: input.content.schedule.events.map((e, i) =>
i === 0 ? { ...e, id: "bad id" } : e,
),
},
},
};
expect(buildPackage(broken).ok).toBe(false);
});
it("invalid version (packageVersion 0) fails", () => {
const input = makeValidInput({ packageVersion: 0 });
expect(buildPackage(input).ok).toBe(false);
});
it("monotonic packageVersion violation fails", () => {
const input = makeValidInput({ packageVersion: 5, previousPackageVersion: 5 });
expect(buildPackage(input).ok).toBe(false);
const input2 = makeValidInput({ packageVersion: 4, previousPackageVersion: 5 });
expect(buildPackage(input2).ok).toBe(false);
});
it("invalid compatibility range — minAppVersion malformed fails manifest validation", () => {
const input = makeValidInput({
appCompatibility: { minAppVersion: "bad", maxAppVersion: null },
});
const res = buildPackage(input);
expect(res.ok).toBe(false);
});
it("invalid schedule event missing required field fails gate1", () => {
const input = makeValidInput();
// Use valid timestamps/dayKey but empty title — pipeline gate1 is permissive (allows empty title) and will succeed;
// this documents that deep validation is deferred to Stage 4 validator, not gate1.
const validEvent = input.content.schedule.events[0]!;
const broken: PipelineInput = {
...input,
content: {
...input.content,
schedule: {
...input.content.schedule,
events: [
{
...validEvent,
title: "",
} as unknown as PipelineInput["content"]["schedule"]["events"][number],
],
},
},
};
expect(buildPackage(broken).ok).toBe(true);
// Empty stageId similarly permissive at gate1 level
const broken2: PipelineInput = {
...input,
content: {
...input.content,
schedule: {
...input.content.schedule,
events: [
{
...validEvent,
stageId: "",
} as unknown as PipelineInput["content"]["schedule"]["events"][number],
],
},
},
};
expect(buildPackage(broken2).ok).toBe(true);
});
it("invalid map POI x/y out of range fails gate4", () => {
const input = makeValidInput();
const broken: PipelineInput = {
...input,
content: {
...input.content,
map: {
...input.content.map,
pois: input.content.map.pois.map((p, i) => (i === 0 ? { ...p, x: 2 } : p)),
},
},
};
expect(buildPackage(broken).ok).toBe(false);
});
it("missing required content — empty schedule events array still builds but gate1 passes (empty allowed)", () => {
// Empty schedule is not missing required field, but may be questionable. Pipeline allows empty.
const input = makeValidInput();
const empty: PipelineInput = {
...input,
content: { ...input.content, schedule: { ...input.content.schedule, events: [] } },
};
expect(buildPackage(empty).ok).toBe(true);
});
it("forward-compatible unknown optional field on POI passes", () => {
const input = makeValidInput();
const withUnknown: PipelineInput = {
...input,
content: {
...input.content,
map: {
...input.content.map,
pois: input.content.map.pois.map(
(p) => ({ ...p, futureField: "ok" }) as unknown as typeof p,
),
},
},
};
expect(buildPackage(withUnknown).ok).toBe(true);
});
});
describe("pipeline — emergency versioning and floor consistency", () => {
it("emergencySchemaVersion and contentVersion preserved in both section and floor from same source", () => {
const input = makeValidInput();
const res = buildPackage(input);
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.pkg.files.get("emergency.json")!.json).toMatchObject({
emergencySchemaVersion: 1,
contentVersion: 3,
});
expect(res.pkg.emergencyFloor.emergencySchemaVersion).toBe(1);
expect(res.pkg.emergencyFloor.sourceContentVersion).toBe(3);
});
it("floor derived from same source — services/address/procedures consistent", () => {
const input = makeValidInput();
const res = buildPackage(input);
expect(res.ok).toBe(true);
if (!res.ok) return;
const section = res.pkg.files.get("emergency.json")!.json as unknown as {
services: unknown;
address: unknown;
procedures: readonly unknown[];
};
expect(res.pkg.emergencyFloor.services).toEqual(section.services);
expect(res.pkg.emergencyFloor.address).toEqual(section.address);
expect(res.pkg.emergencyFloor.procedures.length).toBe(section.procedures.length);
});
it("floor ≤16KB and forward-tolerant — unknown fields on source don't break floor", () => {
const input = makeValidInput();
const withExtra = {
...input,
content: {
...input.content,
emergency: {
...input.content.emergency,
futureEmergencyField: "ok",
} as unknown as PipelineInput["content"]["emergency"],
},
};
const res = buildPackage(withExtra);
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.pkg.floorBytes).toBeLessThanOrEqual(16 * 1024);
});
it("changing emergency contentVersion increments floor sourceContentVersion", () => {
const v1 = makeValidInput();
const v1Res = buildPackage(v1);
expect(v1Res.ok).toBe(true);
const v2Input: PipelineInput = {
...v1,
packageVersion: 2,
content: { ...v1.content, emergency: { ...v1.content.emergency, contentVersion: 4 } },
};
const v2Res = buildPackage(v2Input);
expect(v2Res.ok).toBe(true);
if (!v1Res.ok || !v2Res.ok) return;
expect(v2Res.pkg.emergencyFloor.sourceContentVersion).toBe(4);
expect(v1Res.pkg.emergencyFloor.sourceContentVersion).toBe(3);
});
});
describe("pipeline — SHA-256 + signing", () => {
it("signing produces valid signature.json with 64 hex manifestSha256 and base64 signature", () => {
const kp = generateTestKeyPair();
const input = makeValidInput();
const res = buildPackage(input, { signWith: kp });
expect(res.ok).toBe(true);
if (!res.ok) return;
expect(res.pkg.signature).not.toBeNull();
expect(res.pkg.signature!.algorithm).toBe("ed25519");
expect(res.pkg.signature!.over).toBe("sha256(manifest.json exact bytes)");
expect(/^[0-9a-f]{64}$/.test(res.pkg.signature!.manifestSha256)).toBe(true);
expect(res.pkg.signature!.publicKeyFingerprint).toBe(kp.fingerprint);
expect(typeof res.pkg.signature!.signature).toBe("string");
expect(res.pkg.signature!.signature.length).toBeGreaterThan(10);
});
it("same manifest bytes produce same manifestSha256 regardless of input key order (deterministic)", () => {
const kp = generateTestKeyPair();
const input = makeValidInput();
const a = buildPackage(input, { signWith: kp });
const b = buildPackage(input, { signWith: kp });
expect(a.ok && b.ok).toBe(true);
if (!a.ok || !b.ok) return;
expect(a.pkg.signature!.manifestSha256).toBe(b.pkg.signature!.manifestSha256);
expect(a.pkg.signature!.signature).toBe(b.pkg.signature!.signature);
});
});
describe("pipeline — separation and fail-closed", () => {
it("separation: pipeline output never contains user data / favorites", () => {
const input = makeValidInput();
const res = buildPackage(input);
expect(res.ok).toBe(true);
if (!res.ok) return;
const manifestStr = canonicalJson(res.pkg.manifest);
expect(manifestStr).not.toMatch(/favorites/i);
for (const f of res.pkg.files.values()) {
const s = canonicalJson(f.json);
expect(s).not.toMatch(/favorites/i);
}
});
it("fail-closed: invalid package never reaches persistence — build returns ok:false and no files", () => {
const input = makeValidInput({ packageVersion: 0 }); // invalid
const res = buildPackage(input);
expect(res.ok).toBe(false);
expect((res as { ok: false; reason: string }).reason).toBeTruthy();
// No pkg on failure
expect((res as unknown as { pkg?: unknown }).pkg).toBeUndefined();
});
it("smoke re-verify: built package files hashes match manifest entries", () => {
const input = makeValidInput();
const res = buildPackage(input);
expect(res.ok).toBe(true);
if (!res.ok) return;
for (const [file, meta] of Object.entries(res.pkg.manifest.sections)) {
const f = res.pkg.files.get((meta as { file: string }).file);
expect(f, `missing ${file}`).toBeDefined();
expect(f!.sha256).toBe((meta as { sha256: string }).sha256);
expect(f!.bytes).toBe((meta as { bytes: number }).bytes);
}
});
});

View file

@ -24,13 +24,44 @@ describe("Stage 2 shell — boot", () => {
document.body.innerHTML = "";
});
it("boots into emergency when root / requested (offline-safe deep link)", () => {
it("boots into home when root / requested (offline-safe deep link)", () => {
history.replaceState(null, "", "/");
const host = setupAppHost();
const { router } = boot();
expect(router.getPath()).toBe("/emergency");
expect(host.querySelector("h1")?.textContent).toBe("Emergency");
expect(document.title).toContain("Emergency");
expect(router.getPath()).toBe("/");
expect(router.getRouteId()).toBe("home");
expect(host.querySelector("h1")?.textContent).toBe("Lumen");
expect(document.title).toBe("Lumen");
});
it("home renders the four destination tiles", () => {
history.replaceState(null, "", "/");
setupAppHost();
boot();
const tiles = [...document.querySelectorAll<HTMLAnchorElement>(".home-tile")];
expect(tiles.map((a) => a.textContent)).toEqual([
"Emergency",
"Schedule",
"Map",
"Information",
]);
for (const t of tiles) {
expect(t.getAttribute("data-route")).toBeTruthy();
}
});
it("brand acts as home/back button and is highlighted on destination pages", () => {
history.replaceState(null, "", "/schedule");
setupAppHost();
boot();
const brand = document.querySelector<HTMLAnchorElement>(".app-header__brand");
expect(brand).toBeTruthy();
expect(brand?.getAttribute("data-route")).toBe("/");
expect(brand?.classList.contains("app-header__brand--back")).toBe(true);
expect(brand?.hasAttribute("aria-current")).toBe(true);
brand?.click();
expect(location.pathname).toBe("/");
expect(document.querySelector("h1")?.textContent).toBe("Lumen");
});
it("removes aria-busy after boot", () => {
@ -82,13 +113,21 @@ describe("Stage 2 shell — navigation / emergency affordance", () => {
document.body.innerHTML = "";
});
it("renders four primary destinations, emergency first and visually prioritized", () => {
it("renders home plus four primary destinations, emergency visually prioritized", () => {
setupAppHost();
boot();
const links = [...document.querySelectorAll<HTMLAnchorElement>(".bottom-nav__link")];
expect(links.map((a) => a.textContent)).toEqual(["Emergency", "Schedule", "Map", "Festival"]);
const first = links[0];
expect(first?.classList.contains("bottom-nav__link--emergency")).toBe(true);
const links = [...document.querySelectorAll<HTMLAnchorElement>(".side-nav__link")];
expect(links.map((a) => a.textContent)).toEqual([
"Home",
"Emergency",
"Schedule",
"Map",
"Information",
]);
const emergency = links[1];
expect(emergency?.classList.contains("side-nav__link--emergency")).toBe(true);
const home = links[0];
expect(home?.getAttribute("data-route")).toBe("/");
});
it("all four destinations have 48px touch-target capable links and data-route", () => {
@ -133,15 +172,15 @@ describe("Stage 2 shell — navigation / emergency affordance", () => {
const { router } = boot();
expect(router.getRouteId()).toBe("not-found");
expect(document.querySelector("h1")?.textContent).toBe("Page not found");
expect(document.querySelector('.bottom-nav__link[aria-current="page"]')).toBeNull();
expect(document.querySelector('.side-nav__link[aria-current="page"]')).toBeNull();
});
it("normalizes paths: trailing slash, query, hash, and root", () => {
expect(normalizePath("/schedule/")).toBe("/schedule");
expect(normalizePath("/map?foo=1")).toBe("/map");
expect(normalizePath("/festival#section")).toBe("/festival");
expect(normalizePath("/")).toBe("/emergency");
expect(routeForPath("/")).toBe("emergency");
expect(normalizePath("/")).toBe("/");
expect(routeForPath("/")).toBe("home");
expect(routeForPath("/unknown")).toBe("not-found");
});

View 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();
});
});

380
tests/unit/verifier.test.ts Normal file
View file

@ -0,0 +1,380 @@
/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/array-type, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */
/** Stage 7 — pure package validation, ordering, replay protection, and quarantine. */
import { describe, expect, it } from "vitest";
import { canonicalJson } from "../../pipeline/canonical-json.js";
import { buildPackage } from "../../pipeline/package.js";
import { generateTestKeyPair, signManifest } from "../../pipeline/sign.js";
import { sha256Hex } from "../../pipeline/hash.js";
import { makeValidInput } from "../../pipeline/fixtures.js";
import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js";
import { verifyPackage } from "../../src/sync/verifier/package.js";
import type {
PackageFileProvider,
QuarantineRecord,
VerifyDependencies,
} from "../../src/sync/verifier/types.js";
import type { PackageSignature } from "../../src/data/festival-package/types.js";
const enc = (value: string) => new TextEncoder().encode(value);
function makeFixture() {
const keyPair = generateTestKeyPair();
const built = buildPackage(makeValidInput(), { signWith: keyPair });
if (!built.ok || !built.pkg.signature) throw new Error("fixture build failed");
const manifestBytes = enc(canonicalJson(built.pkg.manifest));
const files = new Map<string, Uint8Array>();
for (const [name, file] of built.pkg.files) files.set(name, file.canonicalBytes);
for (const asset of built.pkg.assets) files.set(asset.file, asset.bytesContent);
const records: QuarantineRecord[] = [];
const provider: PackageFileProvider = {
listFiles: () => [...files.keys()],
getFile: async (name) => files.get(name),
};
const deps: VerifyDependencies = {
trustedKeys: new Map([
[keyPair.fingerprint, publicKeyFromDerBase64(keyPair.publicKeyDerBase64)],
]),
appVersion: "1.0.0",
supportedSchemaRange: [1],
quarantine: {
add: (record) => {
records.push(record);
},
},
};
return {
keyPair,
built: built.pkg,
signature: built.pkg.signature as PackageSignature,
manifestBytes,
files,
provider,
deps,
records,
};
}
function resign(manifest: unknown, keyPair: ReturnType<typeof generateTestKeyPair>) {
const bytes = enc(canonicalJson(manifest));
return { bytes, signature: signManifest(bytes, keyPair.privateKeyPem, keyPair.fingerprint) };
}
function withManifest(fixture: ReturnType<typeof makeFixture>, manifest: unknown) {
const signed = resign(manifest, fixture.keyPair);
return { ...fixture, manifestBytes: signed.bytes, signature: signed.signature };
}
function replaceSection(fixture: ReturnType<typeof makeFixture>, name: string, value: unknown) {
const bytes = enc(canonicalJson(value));
const sectionId = (
Object.keys(fixture.built.manifest.sections) as Array<
keyof typeof fixture.built.manifest.sections
>
).find((id) => fixture.built.manifest.sections[id].file === name);
if (!sectionId) throw new Error(`unknown section file ${name}`);
const entry = fixture.built.manifest.sections[sectionId];
const manifest = {
...fixture.built.manifest,
sections: {
...fixture.built.manifest.sections,
[sectionId]: { ...entry, bytes: bytes.length, sha256: sha256Hex(bytes) },
},
limits: {
totalBytes:
fixture.built.manifest.limits.totalBytes + bytes.length - fixture.files.get(name)!.length,
},
};
const changed = withManifest(fixture, manifest);
const files = new Map(fixture.files);
files.set(name, bytes);
return {
...changed,
files,
provider: {
listFiles: () => [...files.keys()],
getFile: async (file: string) => files.get(file),
},
};
}
function depsWithEvents(fixture: ReturnType<typeof makeFixture>, events: string[]) {
return {
...fixture.deps,
onGate: (gate: "signature" | "compatibility" | "files" | "schema") => events.push(gate),
};
}
async function verify(
fixture: ReturnType<typeof makeFixture>,
deps = fixture.deps,
signature: PackageSignature = fixture.signature as PackageSignature,
extras: {
readonly active?: { edition: string; packageVersion: number } | null;
readonly emergencyFloor?: unknown;
} = {},
) {
const input = {
manifestBytes: fixture.manifestBytes,
signature,
files: fixture.provider,
emergencyFloor: extras.emergencyFloor ?? fixture.built.emergencyFloor,
...(extras.active === undefined ? {} : { active: extras.active }),
};
return verifyPackage(input, deps);
}
describe("Stage 7 package verifier", () => {
it("accepts a valid signed package and does not quarantine it", async () => {
const fixture = makeFixture();
const result = await verify(fixture);
expect(result.ok).toBe(true);
expect(fixture.records).toHaveLength(0);
});
it("checks signature, compatibility, files, then schema in order", async () => {
const fixture = makeFixture();
const gates: string[] = [];
const result = await verify(fixture, depsWithEvents(fixture, gates));
expect(result.ok).toBe(true);
expect(gates).toEqual(["signature", "compatibility", "files", "schema"]);
});
it("rejects bad signatures without retrieving any package file", async () => {
const fixture = makeFixture();
let gets = 0;
const result = await verify(
{
...fixture,
provider: {
getFile: async () => {
gets++;
return undefined;
},
},
},
fixture.deps,
{ ...fixture.signature, signature: "invalid" } as PackageSignature,
);
expect(result).toMatchObject({ ok: false, code: "signature_mismatch" });
expect(gets).toBe(0);
expect(fixture.records).toHaveLength(1);
});
it("rejects malformed signatures and unknown keys before file access", async () => {
const fixture = makeFixture();
let gets = 0;
const input = {
...fixture,
provider: {
getFile: async () => {
gets++;
return undefined;
},
},
};
const malformed = await verify(input, fixture.deps, {
...fixture.signature,
signature: "",
} as PackageSignature);
expect(malformed.ok).toBe(false);
const unknown = await verify(input, fixture.deps, {
...fixture.signature,
publicKeyFingerprint: "sha256:unknown",
} as PackageSignature);
expect(unknown).toMatchObject({ ok: false, code: "unknown_fingerprint" });
expect(gets).toBe(0);
});
it("rejects a manifest hash mismatch and wrong manifest bytes", async () => {
const fixture = makeFixture();
const badHash = await verify(fixture, fixture.deps, {
...fixture.signature,
manifestSha256: "0".repeat(64),
} as PackageSignature);
expect(badHash).toMatchObject({ ok: false, code: "manifest_sha_mismatch" });
const wrongBytes = {
...fixture,
manifestBytes: enc(canonicalJson({ ...fixture.built.manifest, packageVersion: 99 })),
};
const wrong = await verify(wrongBytes);
expect(wrong).toMatchObject({ ok: false, code: "manifest_sha_mismatch" });
});
it.each([
[
"app",
{ appCompatibility: { minAppVersion: "9.0.0", maxAppVersion: null } },
"incompatible_app",
],
["schema", { schemaVersion: 99 }, "incompatible_app"],
["version", { packageVersion: 0 }, "malformed_manifest"],
["budget", { limits: { totalBytes: 41 * 1024 * 1024 } }, "budget_exceeded"],
] as const)("rejects %s before file retrieval", async (_name, change, code) => {
const fixture = makeFixture();
let gets = 0;
const changed = withManifest(fixture, { ...fixture.built.manifest, ...change });
const result = await verify(
{
...changed,
provider: {
getFile: async () => {
gets++;
return undefined;
},
},
},
changed.deps,
changed.signature,
);
expect(result).toMatchObject({ ok: false, code });
expect(gets).toBe(0);
});
it("rejects replayed and cross-edition packages before files", async () => {
const fixture = makeFixture();
const replay = await verify(fixture, fixture.deps, fixture.signature, {
active: {
edition: fixture.built.manifest.edition,
packageVersion: fixture.built.manifest.packageVersion,
},
});
expect(replay).toMatchObject({ ok: false });
const newer = withManifest(fixture, { ...fixture.built.manifest, packageVersion: 8 });
expect(
await verify({ ...newer, provider: fixture.provider }, { ...newer.deps }, newer.signature, {
active: {
edition: fixture.built.manifest.edition,
packageVersion: fixture.built.manifest.packageVersion,
},
}),
).toMatchObject({ ok: true });
const other = withManifest(fixture, { ...fixture.built.manifest, edition: "other-2026" });
expect(
await verify({ ...other, provider: fixture.provider }, { ...other.deps }, other.signature, {
active: {
edition: fixture.built.manifest.edition,
packageVersion: fixture.built.manifest.packageVersion,
},
}),
).toMatchObject({ ok: false, code: "incompatible_edition" });
});
it("rejects missing, size-mismatched, hash-mismatched, and unexpected files", async () => {
const fixture = makeFixture();
const missing = new Map(fixture.files);
missing.delete("schedule.json");
expect(
await verify({ ...fixture, provider: { getFile: async (name) => missing.get(name) } }),
).toMatchObject({ ok: false, code: "missing_file" });
const wrongSize = new Map(fixture.files);
wrongSize.set("schedule.json", enc("wrong"));
expect(
await verify({ ...fixture, provider: { getFile: async (name) => wrongSize.get(name) } }),
).toMatchObject({ ok: false, code: "size_mismatch" });
const wrongHash = new Map(fixture.files);
wrongHash.set("schedule.json", new Uint8Array(fixture.files.get("schedule.json")!));
const wrongSchedule = wrongHash.get("schedule.json");
if (wrongSchedule) wrongSchedule[0] = (wrongSchedule[0] ?? 0) ^ 1;
expect(
await verify({ ...fixture, provider: { getFile: async (name) => wrongHash.get(name) } }),
).toMatchObject({ ok: false, code: "hash_mismatch" });
const extra = new Map(fixture.files);
extra.set("unexpected.bin", enc("x"));
expect(
await verify({
...fixture,
provider: { listFiles: () => [...extra.keys()], getFile: async (name) => extra.get(name) },
}),
).toMatchObject({ ok: false, code: "unexpected_file" });
});
it("rejects malformed sections and invalid emergency floor after file integrity", async () => {
const fixture = makeFixture();
const schedule = JSON.parse(new TextDecoder().decode(fixture.files.get("schedule.json")));
schedule.events[0].dayKey = "1900-01-01";
const scheduleWithBadDay = replaceSection(fixture, "schedule.json", schedule);
const result = await verify(scheduleWithBadDay);
expect(result).toMatchObject({ ok: false, code: "malformed_manifest" });
const floor = await verify(fixture, fixture.deps, fixture.signature, {
emergencyFloor: { floor: true },
});
expect(floor).toMatchObject({ ok: false, code: "malformed_manifest" });
});
it("validates stable IDs, map POIs, and emergency section schema after integrity", async () => {
const fixture = makeFixture();
const schedule = JSON.parse(new TextDecoder().decode(fixture.files.get("schedule.json")));
schedule.events[0].id = "bad id";
expect(await verify(replaceSection(fixture, "schedule.json", schedule))).toMatchObject({
ok: false,
code: "malformed_manifest",
});
const map = JSON.parse(new TextDecoder().decode(fixture.files.get("map.json")));
map.pois[0].x = 2;
expect(await verify(replaceSection(fixture, "map.json", map))).toMatchObject({
ok: false,
code: "malformed_manifest",
});
const emergency = JSON.parse(new TextDecoder().decode(fixture.files.get("emergency.json")));
delete emergency.procedures;
expect(await verify(replaceSection(fixture, "emergency.json", emergency))).toMatchObject({
ok: false,
code: "malformed_manifest",
});
});
it("enforces downloaded map dimensions and the per-file ceiling", async () => {
const fixture = makeFixture();
const map = JSON.parse(new TextDecoder().decode(fixture.files.get("map.json")));
map.base.levels[0].width = 1601;
expect(await verify(replaceSection(fixture, "map.json", map))).toMatchObject({
ok: false,
code: "budget_exceeded",
});
const oversized = withManifest(fixture, {
...fixture.built.manifest,
sections: {
...fixture.built.manifest.sections,
emergency: {
...fixture.built.manifest.sections.emergency,
bytes: 7 * 1024 * 1024,
},
},
});
expect(await verify(oversized, oversized.deps, oversized.signature)).toMatchObject({
ok: false,
code: "malformed_manifest",
});
});
it("accepts unknown forward-compatible section fields after integrity", async () => {
const fixture = makeFixture();
const schedule = JSON.parse(new TextDecoder().decode(fixture.files.get("schedule.json")));
schedule.futureField = { version: 2, optional: true };
expect(await verify(replaceSection(fixture, "schedule.json", schedule))).toMatchObject({
ok: true,
});
});
it("quarantines failures without invoking activation or changing active state", async () => {
const fixture = makeFixture();
const active = {
edition: fixture.built.manifest.edition,
packageVersion: fixture.built.manifest.packageVersion,
};
const before = { ...active };
const result = await verify(
fixture,
fixture.deps,
{ ...fixture.signature, signature: "bad" } as PackageSignature,
{ active },
);
expect(result.ok).toBe(false);
expect(active).toEqual(before);
expect(fixture.records[0]).toMatchObject({
code: "signature_mismatch",
edition: null,
packageVersion: null,
});
});
});