This commit is contained in:
parent
8e71537d57
commit
fcc18ddcc8
96 changed files with 8074 additions and 214 deletions
177
tests/unit/activation.test.ts
Normal file
177
tests/unit/activation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
749
tests/unit/persistence.test.ts
Normal file
749
tests/unit/persistence.test.ts
Normal 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
643
tests/unit/pipeline.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
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();
|
||||
});
|
||||
});
|
||||
380
tests/unit/verifier.test.ts
Normal file
380
tests/unit/verifier.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue