Lumen/tests/unit/readiness.test.ts
Lumen Stage1 e83946c207
Some checks failed
ci / check (push) Has been cancelled
Checkpoint: current working state
2026-09-29 21:33:33 -05:00

627 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable @typescript-eslint/no-unsafe-call */
/**
* Stage 9 — Offline Ready predicate C1–C8, six-state taxonomy, boot evaluation.
* Pure truth table (every state reachable) + boot integration against
* fake-indexeddb using the Stage 8 staging/activation path.
* Trace: SPIKE-05 §2–§5, IMPLEMENTATION-CONTRACT.md §20.
*/
import { describe, it, expect, beforeEach, 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";
import { readFileSync } from "node:fs";
const g = globalThis as unknown as Record<string, unknown>;
g.indexedDB = new FDBFactory() as unknown;
g.IDBKeyRange = FDBKeyRange as unknown;
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, writeSystemMeta } from "../../src/data/system-meta/store.js";
import { INITIAL_SYSTEM_META } from "../../src/data/system-meta/types.js";
import {
initializeStaging,
markStagingComplete,
openSlotDB,
readSlotFileMeta,
writeSlotFile,
writeSlotFileWithProgress,
writeSlotManifest,
} from "../../src/data/slot/store.js";
import type { FestivalManifest } from "../../src/data/festival-package/types.js";
import { withTx } from "../../src/platform/idb/wrapper.js";
import { DB, SLOT_FILES } from "../../src/platform/idb/names.js";
import { APP_VERSION, SUPPORTED_SCHEMA_RANGE } from "../../src/domain/readiness/config.js";
import {
chipForState,
evaluateReadiness,
preparationLevel,
} from "../../src/domain/readiness/logic.js";
import type { ReadinessEvidence } from "../../src/domain/readiness/types.js";
import { checkShellCache } from "../../src/platform/cache/shell-cache.js";
import { defaultBootDeps, evaluateBootReadiness } from "../../src/app/readiness.js";
import type { BootReadinessDeps } from "../../src/app/readiness.js";
const NOW = 1_750_000_000_000;
function okEvidence(patch: Partial<ReadinessEvidence> = {}): ReadinessEvidence {
return {
shellValid: true,
datasetPresent: true,
authenticity: "verified",
integrity: "ok",
schemaCompatible: true,
requiredSectionsOk: true,
requiredAssetsOk: true,
emergencyFloorOk: true,
missing: [],
hadVerifiedDataset: true,
manifestOk: true,
incompatible: false,
failure: null,
presentSections: ["emergency", "schedule", "info", "map", "assets"] as readonly string[],
stagedPending: null,
...patch,
};
}
describe("readiness predicate — C1–C8 truth table (SPIKE-05 §5)", () => {
it("all true → READY, level L2, green chip", () => {
const result = evaluateReadiness(okEvidence());
expect(result.state).toBe("READY");
expect(result.missing).toEqual([]);
expect(result.level).toBe("L2");
const chip = chipForState("READY");
expect(chip.text).toBe("OFFLINE READY ✓");
expect(chip.tone).toBe("ready");
});
it("no dataset + never verified → NOT_READY; + previously verified → RECOVERY(missing)", () => {
expect(
evaluateReadiness(okEvidence({ datasetPresent: false, hadVerifiedDataset: false })).state,
).toBe("NOT_READY");
const recovery = evaluateReadiness(
okEvidence({ datasetPresent: false, hadVerifiedDataset: true }),
);
expect(recovery.state).toBe("RECOVERY");
expect(recovery.reason).toBe("missing");
});
it("record present but files gone → RECOVERY(missing), not corrupt", () => {
const result = evaluateReadiness(
okEvidence({ authenticity: "mismatch", integrity: "files-missing", missing: ["schedule"] }),
);
expect(result.state).toBe("RECOVERY");
expect(result.reason).toBe("missing");
});
it("size tampering → RECOVERY(corrupt); record mismatch alone → RECOVERY(corrupt)", () => {
expect(evaluateReadiness(okEvidence({ integrity: "corrupt" }))).toMatchObject({
state: "RECOVERY",
reason: "corrupt",
});
expect(evaluateReadiness(okEvidence({ authenticity: "mismatch" }))).toMatchObject({
state: "RECOVERY",
reason: "corrupt",
});
});
it("incompatible schema demotes to RECOVERY(incompatible), ahead of PARTIAL", () => {
const result = evaluateReadiness(
okEvidence({
schemaCompatible: false,
incompatible: true,
requiredSectionsOk: false,
missing: ["map"],
}),
);
expect(result.state).toBe("RECOVERY");
expect(result.reason).toBe("incompatible");
});
it("files present but record missing → PARTIAL(verification), full re-verify path", () => {
const result = evaluateReadiness(okEvidence({ authenticity: "record-missing" }));
expect(result.state).toBe("PARTIAL");
expect(result.missing).toContain("verification");
});
it("staged manifest missing → PARTIAL(manifest)", () => {
const result = evaluateReadiness(
okEvidence({ manifestOk: false, requiredSectionsOk: false, missing: ["manifest"] }),
);
expect(result.state).toBe("PARTIAL");
expect(result.missing).toContain("manifest");
});
it("missing required sections/assets → PARTIAL(list); optional never gates", () => {
const partial = evaluateReadiness(
okEvidence({ requiredAssetsOk: false, missing: ["asset:map-base-overview"] }),
);
expect(partial.state).toBe("PARTIAL");
expect(partial.missing).toEqual(["asset:map-base-overview"]);
// Optional sections are not evidence at all — absence changes nothing.
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
});
it("session failure → FAILED ahead of everything, even READY evidence", () => {
const result = evaluateReadiness(okEvidence({ failure: { op: "activation" } }));
expect(result.state).toBe("FAILED");
expect(result.failedOp).toBe("activation");
expect(chipForState("FAILED", "activation").tone).toBe("failed");
});
it("broken floor build → FAILED(emergency-floor)", () => {
const result = evaluateReadiness(okEvidence({ emergencyFloorOk: false }));
expect(result.state).toBe("FAILED");
expect(result.failedOp).toBe("emergency-floor");
});
it("shell cache missing + dataset → PARTIAL(shell-cache); without dataset → NOT_READY", () => {
expect(evaluateReadiness(okEvidence({ shellValid: false })).state).toBe("PARTIAL");
expect(evaluateReadiness(okEvidence({ shellValid: false })).missing).toContain("shell-cache");
expect(
evaluateReadiness(
okEvidence({ shellValid: false, datasetPresent: false, hadVerifiedDataset: false }),
).state,
).toBe("NOT_READY");
});
it("interrupted staging with no active pointer → PARTIAL (resumable)", () => {
const result = evaluateReadiness(
okEvidence({
datasetPresent: false,
hadVerifiedDataset: false,
requiredSectionsOk: false,
requiredAssetsOk: false,
missing: ["info", "map", "assets"],
presentSections: ["emergency", "schedule"],
stagedPending: { missing: ["info", "map", "assets"] },
}),
);
expect(result.state).toBe("PARTIAL");
expect(result.level).toBe("L1");
});
it("preparation levels L0/L1/L2 follow staged sections, not trust", () => {
expect(preparationLevel([])).toBe("L0");
expect(preparationLevel(["emergency"])).toBe("L0");
expect(preparationLevel(["emergency", "schedule"])).toBe("L1");
expect(preparationLevel(["emergency", "schedule", "info", "map", "assets"])).toBe("L2");
});
it("chip mapping covers every state", () => {
expect(chipForState("PARTIAL", "map").tone).toBe("partial");
expect(chipForState("NOT_READY").text).toBe("Get festival data");
expect(chipForState("RECOVERY", "missing").text).toContain("missing");
expect(chipForState("BASELINE_ONLY").tone).toBe("baseline");
});
it("verdicts do not consult clocks", () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2031-05-04T12:00:00Z"));
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
vi.setSystemTime(new Date("1999-01-01T00:00:00Z"));
expect(evaluateReadiness(okEvidence()).state).toBe("READY");
} finally {
vi.useRealTimers();
}
});
it("APP_VERSION matches package.json; schema range covers v1", () => {
const pkg = JSON.parse(readFileSync("package.json", "utf8")) as { version: string };
expect(APP_VERSION).toBe(pkg.version);
expect(SUPPORTED_SCHEMA_RANGE).toContain(1);
});
});
describe("shell cache check (C1)", () => {
it("reports unavailable where Cache Storage is absent (Node)", async () => {
expect(await checkShellCache()).toMatchObject({ ok: false, cacheName: null });
});
});
// ——— Boot integration ———
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(`fixture verify failed: ${result.reason}`);
return result;
}
function testDeps(patch: Partial<BootReadinessDeps> = {}): BootReadinessDeps {
return {
...defaultBootDeps,
checkShell: () => Promise.resolve({ ok: true, cacheName: "lumen-shell-test" }),
estimate: () => Promise.resolve(null),
// Pipeline fixtures declare minAppVersion 1.0.0 / schema 1 (activation.test
// does the same) — the dev shell version in package.json is younger.
appVersion: "1.0.0",
supportedSchemaRange: [1],
now: () => NOW,
...patch,
};
}
async function activateFixture(version: number): Promise<VerifiedPackage> {
const pkg = await fixture(version);
const staged = await stageVerifiedPackage(pkg);
const activated = await activateStagedPackage(pkg, staged, APP_VERSION, () => NOW);
expect(activated.ok).toBe(true);
return pkg;
}
beforeEach(async () => {
await deleteDb(DB.SYSTEM);
await deleteDb(DB.SLOT_A);
await deleteDb(DB.SLOT_B);
await deleteDb(DB.USER);
});
describe("boot evaluation", () => {
it("fresh install → NOT_READY, L0, floor present", async () => {
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("NOT_READY");
expect(report.level).toBe("L0");
expect(report.floorVersion).toBeTruthy();
expect(report.canRestore).toBe(false);
});
it("staged + activated fixture → READY with version detail, L2", async () => {
const pkg = await activateFixture(1);
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("READY");
expect(report.edition).toBe(pkg.manifest.edition);
expect(report.packageVersion).toBe(1);
expect(report.generatedAt).toBe(pkg.manifest.generatedAt);
// Journal progress timestamps come from the real clock at staging time —
// display only, never a verdict input.
expect(report.fetchedAt).toBeGreaterThan(0);
expect(report.sections).toHaveLength(5);
expect(report.sections.every((s) => s.present && s.parseable)).toBe(true);
expect(report.assets.total).toBeGreaterThan(0);
expect(report.assets.present).toBe(report.assets.total);
expect(report.level).toBe("L2");
expect(report.shellCache).toBe("lumen-shell-test");
});
it("second update retains rollback slot → canRestore; restore returns to v1 READY", async () => {
await activateFixture(1);
await activateFixture(2);
const updated = await evaluateBootReadiness(testDeps());
expect(updated.state).toBe("READY");
expect(updated.packageVersion).toBe(2);
expect(updated.canRestore).toBe(true);
expect(await restorePreviousSlot()).toBe(true);
const restored = await evaluateBootReadiness(testDeps());
expect(restored.state).toBe("READY");
expect(restored.packageVersion).toBe(1);
});
it("slot loss with surviving record → RECOVERY(missing)", async () => {
await activateFixture(1);
await deleteDb(DB.SLOT_A);
await deleteDb(DB.SLOT_B);
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("RECOVERY");
expect(report.reason).toBe("missing");
});
it("size tampering post-activation → RECOVERY(corrupt)", async () => {
await activateFixture(1);
const system = await openSystemDB();
const meta = await readSystemMeta(system);
system.close();
const slot = await openSlotDB(meta.activeSlot ?? "A");
const current = await readSlotFileMeta(slot, "schedule");
await writeSlotFile(slot, "schedule", {
bytes: (current?.bytes ?? 100) + 7,
sha256: current?.sha256 ?? "0".repeat(64),
json: { section: "schedule" },
});
slot.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("RECOVERY");
expect(report.reason).toBe("corrupt");
});
it("staged manifest deleted → PARTIAL(manifest), re-verify path", async () => {
await activateFixture(1);
const system = await openSystemDB();
const meta = await readSystemMeta(system);
system.close();
const slot = await openSlotDB(meta.activeSlot ?? "A");
await withTx(slot, SLOT_FILES, "readwrite", async (tx) => {
await new Promise<void>((resolve, reject) => {
const req = tx.objectStore(SLOT_FILES).delete("manifest");
req.onsuccess = () => {
resolve();
};
req.onerror = () => {
reject(req.error ?? new Error("delete failed"));
};
});
});
slot.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("PARTIAL");
expect(report.missing).toContain("manifest");
});
it("verification record wiped but files present → PARTIAL(verification)", async () => {
await activateFixture(1);
const system = await openSystemDB();
const meta = await readSystemMeta(system);
await writeSystemMeta(system, { ...meta, verification: null });
system.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("PARTIAL");
expect(report.missing).toContain("verification");
});
it("interrupted staging (emergency+schedule) with no active → PARTIAL, L1 core-ready", async () => {
const pkg = await fixture(1);
const slot = await openSlotDB("A");
let journal = await initializeStaging(slot, {
edition: pkg.manifest.edition,
packageVersion: 1,
manifestSha256: pkg.manifestSha256,
startedAt: NOW,
lastProgressAt: NOW,
});
for (const id of ["emergency", "schedule"] as const) {
const entry = pkg.manifest.sections[id];
const bytes = pkg.files.get(entry.file);
if (!bytes) throw new Error("fixture file missing");
journal = await writeSlotFileWithProgress(
slot,
id,
{
bytes: entry.bytes,
sha256: entry.sha256,
json: JSON.parse(new TextDecoder().decode(bytes)) as unknown,
},
journal,
);
}
expect(journal.complete).toBe(false);
slot.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("PARTIAL");
expect(report.level).toBe("L1");
expect(report.missing).toEqual(expect.arrayContaining(["info", "map", "assets"]));
expect(report.activationPending).toBe(false);
});
it("hand-built incompatible package (schema 99) → RECOVERY(incompatible)", async () => {
const manifest = {
format: "lumen.package/1",
edition: "test-edition",
packageVersion: 9,
schemaVersion: 99,
generatedAt: "2026-09-01T00:00:00.000Z",
festival: { name: "Test", timezone: "America/Chicago", startUtc: 1, endUtc: 2 },
appCompatibility: { minAppVersion: "0.1.0", maxAppVersion: null },
sections: {
emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 10 },
schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 10 },
map: { file: "map.json", sha256: "c".repeat(64), bytes: 10 },
info: { file: "info.json", sha256: "d".repeat(64), bytes: 10 },
assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 2 },
},
counts: { events: 0, pois: 0, assets: 0 },
limits: { totalBytes: 52 },
} as unknown as FestivalManifest;
const slot = await openSlotDB("A");
let journal = await initializeStaging(slot, {
edition: "test-edition",
packageVersion: 9,
manifestSha256: "f".repeat(64),
startedAt: NOW,
lastProgressAt: NOW,
});
for (const id of ["emergency", "schedule", "map", "info"] as const) {
journal = await writeSlotFileWithProgress(
slot,
id,
{ bytes: 10, sha256: `${id}-sha`, json: { section: id } },
journal,
);
}
journal = await writeSlotFileWithProgress(
slot,
"assets",
{ bytes: 2, sha256: "e".repeat(64), json: { assets: [] } },
journal,
);
await writeSlotManifest(slot, manifest);
journal = await markStagingComplete(slot);
expect(journal.complete).toBe(true);
slot.close();
const system = await openSystemDB();
await writeSystemMeta(system, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "test-edition",
activePackageVersion: 9,
verification: {
packageVersion: 9,
edition: "test-edition",
manifestSha256: "f".repeat(64),
publicKeyFingerprint: "test-key",
verifiedAt: NOW,
appVersionAtActivation: APP_VERSION,
},
appVersionAtActivation: APP_VERSION,
});
system.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("RECOVERY");
expect(report.reason).toBe("incompatible");
});
it("optional section (required:false) absent → still READY", async () => {
const manifest = {
format: "lumen.package/1",
edition: "test-edition",
packageVersion: 3,
schemaVersion: 1,
generatedAt: "2026-09-01T00:00:00.000Z",
festival: { name: "Test", timezone: "America/Chicago", startUtc: 1, endUtc: 2 },
appCompatibility: { minAppVersion: "0.1.0", maxAppVersion: null },
sections: {
emergency: { file: "emergency.json", sha256: "a".repeat(64), bytes: 10 },
schedule: { file: "schedule.json", sha256: "b".repeat(64), bytes: 10 },
map: { file: "map.json", sha256: "c".repeat(64), bytes: 10 },
info: { file: "info.json", sha256: "d".repeat(64), bytes: 10, required: false },
assets: { file: "assets.json", sha256: "e".repeat(64), bytes: 2 },
},
counts: { events: 0, pois: 0, assets: 0 },
limits: { totalBytes: 42 },
} as unknown as FestivalManifest;
const slot = await openSlotDB("A");
let journal = await initializeStaging(slot, {
edition: "test-edition",
packageVersion: 3,
manifestSha256: "f".repeat(64),
startedAt: NOW,
lastProgressAt: NOW,
});
for (const id of ["emergency", "schedule", "map"] as const) {
journal = await writeSlotFileWithProgress(
slot,
id,
{ bytes: 10, sha256: `${id}-sha`, json: { section: id } },
journal,
);
}
journal = await writeSlotFileWithProgress(
slot,
"assets",
{ bytes: 2, sha256: "e".repeat(64), json: { assets: [] } },
journal,
);
await writeSlotManifest(slot, manifest);
await markStagingComplete(slot);
slot.close();
const system = await openSystemDB();
await writeSystemMeta(system, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "test-edition",
activePackageVersion: 3,
verification: {
packageVersion: 3,
edition: "test-edition",
manifestSha256: "f".repeat(64),
publicKeyFingerprint: "test-key",
verifiedAt: NOW,
appVersionAtActivation: APP_VERSION,
},
appVersionAtActivation: APP_VERSION,
});
system.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("READY");
expect(report.sections.find((s) => s.id === "info")).toMatchObject({
required: false,
present: false,
});
});
it("readbackPending left by a kill resolves at next boot → READY", async () => {
await activateFixture(1);
const system = await openSystemDB();
const meta = await readSystemMeta(system);
expect(meta.readbackPending).toBe(false);
await writeSystemMeta(system, { ...meta, readbackPending: true });
system.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("READY");
const reopened = await openSystemDB();
expect((await readSystemMeta(reopened)).readbackPending).toBe(false);
reopened.close();
});
it("unavailable storage → BASELINE_ONLY; slot IDB error → FAILED", async () => {
const baseline = await evaluateBootReadiness(
testDeps({
openSystem: () => Promise.reject(new Error("denied")),
}),
);
expect(baseline.state).toBe("BASELINE_ONLY");
expect(baseline.floorVersion).toBeTruthy();
await activateFixture(1);
const failed = await evaluateBootReadiness(
testDeps({
openSlot: () => Promise.reject(new Error("IDB exploded")),
}),
);
expect(failed.state).toBe("FAILED");
expect(failed.failedOp).toBe("boot-check");
});
it("stale staging (>7d) is not resumable → NOT_READY", async () => {
const pkg = await fixture(1);
const slot = await openSlotDB("A");
const journal = await initializeStaging(slot, {
edition: pkg.manifest.edition,
packageVersion: 1,
manifestSha256: pkg.manifestSha256,
startedAt: NOW - 8 * 24 * 60 * 60 * 1000,
lastProgressAt: NOW - 8 * 24 * 60 * 60 * 1000,
});
expect(journal.complete).toBe(false);
slot.close();
const report = await evaluateBootReadiness(testDeps());
expect(report.state).toBe("NOT_READY");
});
});