Lumen/tests/integration/ab-lifecycle.test.ts

609 lines
24 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, @typescript-eslint/require-await, @typescript-eslint/no-non-null-assertion */
/**
* Stage 8 — fault-injection integration suite mirroring SPIKE-02 §3
* (exp2-ab-update-sim.mjs S01–S14 + X1–X3) against the REAL
* pull→verify→stage→activate→boot state machine on fake-indexeddb.
*
* Invariant under test (SPIKE-02): "Either the previous valid dataset remains
* active or the new valid dataset becomes active. The app never knowingly
* exposes a partial dataset. Favorites are never lost."
*
* Acceptance is at state-machine level (IMPLEMENTATION-CONTRACT.md Stage 8);
* iOS jetsam proof remains device test T10 §36.
*/
import { describe, it, expect, beforeEach } 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";
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 type { KeyPair } from "../../pipeline/sign.js";
import { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js";
import { verifyPackage } from "../../src/sync/verifier/package.js";
import { pullCandidate } from "../../src/sync/pull.js";
import type { Transport } from "../../src/sync/transport/types.js";
import {
activateSlot,
openSystemDB,
readSystemMeta,
writeSystemMeta,
} from "../../src/data/system-meta/store.js";
import {
initializeStaging,
openSlotDB,
readStagingProgress,
writeSlotFileWithProgress,
} from "../../src/data/slot/store.js";
import { openUserDB, addFavorite, listFavorites } from "../../src/data/user/store.js";
import { isQuarantined, quarantineSink } from "../../src/data/user/quarantine.js";
import {
activateStagedPackage,
recoverPendingActivation,
restorePreviousSlot,
stageVerifiedPackage,
} from "../../src/sync/activation.js";
import type { StagedPackage } from "../../src/sync/activation.js";
import type { VerifiedPackage } from "../../src/sync/verifier/types.js";
import { withTx } from "../../src/platform/idb/wrapper.js";
import { DB, SLOT_FILES } from "../../src/platform/idb/names.js";
import type { SlotId } from "../../src/platform/idb/names.js";
import { evaluateBootReadiness, defaultBootDeps } from "../../src/app/readiness.js";
import type { BootReadinessDeps } from "../../src/app/readiness.js";
const EDITION = "lumen-2026";
// ——— harness ———
function deleteDb(name: string): Promise<void> {
return new Promise((resolve, reject) => {
const req = (g.indexedDB as IDBFactory).deleteDatabase(name);
req.onsuccess = () => {
resolve();
};
req.onerror = () => {
reject(req.error ?? new Error("delete database failed"));
};
req.onblocked = () => {
resolve();
};
});
}
interface Built {
readonly keyPair: KeyPair;
/** file path → canonical bytes, keyed the way the manifest references them. */
readonly files: Map<string, Uint8Array>;
readonly manifestBytes: Uint8Array;
readonly signatureBytes: Uint8Array;
readonly pkg: VerifiedPackage | null; // direct-verify witness (used by activation paths)
}
async function built(version: number, keyPair = generateTestKeyPair()): Promise<Built> {
const pkg = buildPackage(makeValidInput({ packageVersion: version }), { signWith: keyPair });
if (!pkg.ok || !pkg.pkg.signature) throw new Error("fixture build failed");
const manifestBytes = new TextEncoder().encode(canonicalJson(pkg.pkg.manifest));
const files = new Map<string, Uint8Array>();
for (const [name, file] of pkg.pkg.files) files.set(name, file.canonicalBytes);
for (const asset of pkg.pkg.assets) files.set(asset.file, asset.bytesContent);
const result = await verifyPackage(
{
manifestBytes,
signature: pkg.pkg.signature,
files: { getFile: (name) => Promise.resolve(files.get(name)) },
emergencyFloor: pkg.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 {
keyPair,
files,
manifestBytes,
signatureBytes: new TextEncoder().encode(JSON.stringify(pkg.pkg.signature)),
pkg: result,
};
}
/** Transport over an in-memory origin; records every URL it is asked for. */
function originTransport(build: Built, fetches: string[], version: number): Transport {
const base = `/editions/${EDITION}/packages/${String(version)}/`;
return {
isAvailable: () => true,
fetchPointer: async () => {
fetches.push("latest.json");
return {
edition: EDITION,
packageVersion: version,
manifestUrl: `${base}manifest.json`,
generatedAt: "2026-08-30T12:00:00.000Z",
};
},
fetchBytes: async (url) => {
fetches.push(url);
if (url.endsWith("manifest.json")) return build.manifestBytes;
if (url.endsWith("signature.json")) return build.signatureBytes;
const name = url.split("/").pop() ?? "";
const sub = url.includes("/assets/") ? `assets/${name}` : name;
const content = build.files.get(sub);
if (content) return content;
throw new Error(`unexpected fetch ${url}`);
},
};
}
function bootDeps(): BootReadinessDeps {
return {
...defaultBootDeps,
checkShell: async () => ({ ok: true, cacheName: "lumen-shell-test" }),
loadFloor: () => ({ ok: true, version: "embedded-1", bytes: 4096 }),
estimate: async () => null,
// Pipeline fixtures declare minAppVersion 1.0.0 / schema 1; the dev shell
// version in package.json is younger (same seam readiness.test uses).
appVersion: "1.0.0",
supportedSchemaRange: [1],
};
}
/** Full sync run: pull (with quarantine wiring) → stage → activate. */
async function syncRun(
build: Built,
version: number,
opts: { readonly trustedKeys?: Map<string, Uint8Array>; readonly fetches?: string[] } = {},
): Promise<{ readonly ok: boolean; readonly reason?: string; readonly skipped?: string }> {
const user = await openUserDB();
const fetches = opts.fetches ?? [];
const outcome = await pullCandidate(
originTransport(build, fetches, version),
EDITION,
{
trustedKeys:
opts.trustedKeys ??
new Map([
[build.keyPair.fingerprint, publicKeyFromDerBase64(build.keyPair.publicKeyDerBase64)],
]),
appVersion: "1.0.0",
supportedSchemaRange: [1],
quarantine: quarantineSink(user),
},
{ isQuarantined: async (v) => isQuarantined(user, EDITION, v) },
);
user.close();
if (!outcome) return { ok: false, reason: "no pointer" };
if ("skipped" in outcome) return { ok: false, skipped: outcome.skipped };
if (!outcome.result.ok) return { ok: false, reason: outcome.result.reason };
const staged = await stageVerifiedPackage(outcome.result);
const activated = await activateStagedPackage(outcome.result, staged, "1.0.0");
return activated.reason === undefined
? { ok: activated.ok }
: { ok: activated.ok, reason: activated.reason };
}
async function activateVersion(version: number): Promise<Built> {
const build = await built(version);
const staged = await stageVerifiedPackage(build.pkg!);
const activated = await activateStagedPackage(build.pkg!, staged, "1.0.0");
if (!activated.ok) throw new Error(`seed activation v${String(version)} failed`);
return build;
}
async function meta() {
const system = await openSystemDB();
const m = await readSystemMeta(system);
system.close();
return m;
}
async function bootState() {
return evaluateBootReadiness(bootDeps());
}
async function corruptFile(slot: SlotId, id: string): Promise<void> {
const db = await openSlotDB(slot);
await withTx(db, SLOT_FILES, "readwrite", (tx) => {
tx.objectStore(SLOT_FILES).delete(id);
});
db.close();
}
const SECTION_IDS = ["emergency", "schedule", "map", "info", "assets"] as const;
beforeEach(async () => {
await Promise.all(Object.values(DB).map((name) => deleteDb(name)));
});
// ——— SPIKE-02 §3 walkthrough ———
describe("Stage 8 integration — SPIKE-02 S01–S14 + X1–X3", () => {
it("S01: crash before any file lands → old (v1) still active, nothing staged", async () => {
await activateVersion(1);
const build = await built(2);
const fetches: string[] = [];
// "crash" = pull succeeds, staging never invoked (process died there).
const user = await openUserDB();
const outcome = await pullCandidate(originTransport(build, fetches, 2), EDITION, {
trustedKeys: new Map([
[build.keyPair.fingerprint, publicKeyFromDerBase64(build.keyPair.publicKeyDerBase64)],
]),
appVersion: "1.0.0",
supportedSchemaRange: [1],
quarantine: quarantineSink(user),
});
user.close();
expect(outcome && "result" in outcome && outcome.result.ok).toBe(true);
const m = await meta();
expect(m.activePackageVersion).toBe(1);
const report = await bootState();
expect(report.state).toBe("READY");
expect(report.packageVersion).toBe(1);
});
it("S02: partial staging (3/5 files) → old (v1) active; activation refuses incomplete slot", async () => {
await activateVersion(1);
const build = await built(2);
const slot = await openSlotDB("B");
let journal = await initializeStaging(slot, {
edition: EDITION,
packageVersion: 2,
manifestSha256: build.pkg!.manifestSha256,
startedAt: Date.now(),
lastProgressAt: Date.now(),
});
for (const id of SECTION_IDS.slice(0, 3)) {
const entry = build.pkg!.manifest.sections[id];
const bytes = build.pkg!.files.get(entry.file)!;
journal = await writeSlotFileWithProgress(
slot,
id,
{
bytes: entry.bytes,
sha256: entry.sha256,
json: JSON.parse(new TextDecoder().decode(bytes)) as unknown,
},
journal,
);
}
slot.close();
const fake: StagedPackage = { slot: "B", journal };
const result = await activateStagedPackage(build.pkg!, fake, "1.0.0");
expect(result).toMatchObject({ ok: false, rolledBack: false });
const m = await meta();
expect(m.activeSlot === "A" && m.activePackageVersion === 1).toBe(true);
const report = await bootState();
expect(report.state).toBe("READY");
expect(report.packageVersion).toBe(1);
});
it("S03: process terminated mid-staging → resume completes without redoing staged files", async () => {
await activateVersion(1);
const build = await built(2);
const slot = await openSlotDB("B");
let journal = await initializeStaging(slot, {
edition: EDITION,
packageVersion: 2,
manifestSha256: build.pkg!.manifestSha256,
startedAt: 1,
lastProgressAt: 1,
});
for (const id of SECTION_IDS.slice(0, 3)) {
const entry = build.pkg!.manifest.sections[id];
const bytes = build.pkg!.files.get(entry.file)!;
journal = await writeSlotFileWithProgress(
slot,
id,
{
bytes: entry.bytes,
sha256: entry.sha256,
json: JSON.parse(new TextDecoder().decode(bytes)) as unknown,
},
journal,
);
}
slot.close();
const resumed = await stageVerifiedPackage(build.pkg!);
expect(resumed.journal.complete).toBe(true);
expect(resumed.journal.startedAt).toBe(1); // resume, not restart
expect(resumed.journal.stagedFiles).toHaveLength(5);
const activated = await activateStagedPackage(build.pkg!, resumed, "1.0.0");
expect(activated.ok).toBe(true);
const report = await bootState();
expect(report.state).toBe("READY");
expect(report.packageVersion).toBe(2);
});
it("S04: reboot before activation (staged, never flipped) → old (v1) active, activationPending", async () => {
await activateVersion(1);
const build = await built(2);
await stageVerifiedPackage(build.pkg!); // fully staged; flip never happens
const m = await meta();
expect(m.activePackageVersion).toBe(1);
const report = await bootState();
// Active pointer still v1 → READY on old dataset (never exposes the unactivated slot).
expect(report.state).toBe("READY");
expect(report.packageVersion).toBe(1);
});
it("S05: dataset validation fails (generic reject) → old active, candidate quarantined", async () => {
await activateVersion(1);
const build = await built(2);
const fetches: string[] = [];
// Corrupt one staged file's bytes → post-download integrity/validation gate rejects.
const original = build.files.get("schedule.json")!;
build.files.set("schedule.json", new TextEncoder().encode("NOT JSON"));
const run = await syncRun(build, 2, { fetches });
expect(run.ok).toBe(false);
const m = await meta();
expect(m.activePackageVersion).toBe(1);
const user = await openUserDB();
expect(await isQuarantined(user, EDITION, 2)).toBe(true);
user.close();
// restore for a clean boot check
build.files.set("schedule.json", original);
const report = await bootState();
expect(report.state).toBe("READY");
});
it("S06: integrity hash fails (corrupt file) → old active, candidate rejected + quarantined", async () => {
await activateVersion(1);
const build = await built(2);
build.files.set("map.json", new Uint8Array([1, 2, 3, 4])); // wrong bytes, manifest hash still original
const run = await syncRun(build, 2);
expect(run.ok).toBe(false);
const m = await meta();
expect(m.activePackageVersion).toBe(1);
const user = await openUserDB();
expect(await isQuarantined(user, EDITION, 2)).toBe(true);
user.close();
});
it("S07: signature validation fails → rejected BEFORE any section/file fetch; quarantined under pointer identity", async () => {
await activateVersion(1);
const build = await built(2);
const fetches: string[] = [];
// Trust a DIFFERENT key: the package's fingerprint is unknown → signature gate fails.
const wrongTrusted = generateTestKeyPair();
const run = await syncRun(build, 2, {
trustedKeys: new Map([
[wrongTrusted.fingerprint, publicKeyFromDerBase64(wrongTrusted.publicKeyDerBase64)],
]),
fetches,
});
expect(run.ok).toBe(false);
// Only pointer + manifest + signature fetched — zero section/asset bytes downloaded.
const fileFetches = fetches.filter(
(f) =>
!f.endsWith("latest.json") && !f.endsWith("manifest.json") && !f.endsWith("signature.json"),
);
expect(fileFetches).toEqual([]);
const m = await meta();
expect(m.activePackageVersion).toBe(1);
const user = await openUserDB();
expect(await isQuarantined(user, EDITION, 2)).toBe(true); // hint-keyed despite pre-manifest rejection
user.close();
});
it("S08: schema validation fails → old active, candidate rejected", async () => {
await activateVersion(1);
const build = await built(2);
const user = await openUserDB();
const fetches: string[] = [];
const outcome = await pullCandidate(originTransport(build, fetches, 2), EDITION, {
trustedKeys: new Map([
[build.keyPair.fingerprint, publicKeyFromDerBase64(build.keyPair.publicKeyDerBase64)],
]),
appVersion: "1.0.0",
supportedSchemaRange: [2], // shell does not support schema 1 of the package
quarantine: quarantineSink(user),
});
user.close();
expect(outcome && "result" in outcome && outcome.result.ok).toBe(false);
const m = await meta();
expect(m.activePackageVersion).toBe(1);
});
it("S09: compatibility validation fails (app too old) → rejected before files; nothing downloaded", async () => {
await activateVersion(1);
const build = await built(2);
const user = await openUserDB();
const fetches: string[] = [];
const outcome = await pullCandidate(originTransport(build, fetches, 2), EDITION, {
trustedKeys: new Map([
[build.keyPair.fingerprint, publicKeyFromDerBase64(build.keyPair.publicKeyDerBase64)],
]),
appVersion: "0.5.0", // below manifest minAppVersion 1.0.0
supportedSchemaRange: [1],
quarantine: quarantineSink(user),
});
user.close();
expect(outcome && "result" in outcome && outcome.result.ok).toBe(false);
const fileFetches = fetches.filter(
(f) =>
!f.endsWith("latest.json") && !f.endsWith("manifest.json") && !f.endsWith("signature.json"),
);
expect(fileFetches).toEqual([]);
const m = await meta();
expect(m.activePackageVersion).toBe(1);
});
it("S10+S11: activation succeeds — pointer, version, edition, verification record flip together to v2", async () => {
const v1 = await activateVersion(1);
const build = await built(2);
const run = await syncRun(build, 2);
expect(run.ok).toBe(true);
const m = await meta();
expect(m.activeSlot).toBe("B");
expect(m.activePackageVersion).toBe(2);
expect(m.activeEdition).toBe(EDITION);
expect(m.verification?.manifestSha256).toBe(build.pkg!.manifestSha256);
expect(m.verification?.publicKeyFingerprint).toBe(build.keyPair.fingerprint);
expect(m.verification?.packageVersion).toBe(2);
expect(m.readbackPending).toBe(false); // cleared post-readback
// v1 remains intact in slot A (rollback depth 1)
const slotA = await openSlotDB("A");
const journalA = await readStagingProgress(slotA);
slotA.close();
expect(journalA?.packageVersion).toBe(1);
expect(v1.pkg !== null).toBe(true);
});
it("S12: process terminated immediately after flip → next-boot recovery confirms new (v2), pending cleared", async () => {
await activateVersion(1);
await activateVersion(2);
// Simulate the kill window (F-3): flip committed, readbackPending still set.
const m0 = await meta();
const system = await openSystemDB();
await writeSystemMeta(system, { ...m0, readbackPending: true });
system.close();
expect(await recoverPendingActivation()).toBe(true);
const m = await meta();
expect(m.activePackageVersion).toBe(2);
expect(m.readbackPending).toBe(false);
const report = await bootState();
expect(report.state).toBe("READY");
expect(report.packageVersion).toBe(2);
});
it("S13: crash during flip (transaction never commits) → old pointer stands", async () => {
await activateVersion(1);
const build = await built(2);
const staged = await stageVerifiedPackage(build.pkg!);
const result = await activateStagedPackage(build.pkg!, staged, "1.0.0", Date.now, {
// Model the uncommitted flip: both attempts die without committing.
activate: async () => {
throw new Error("transaction aborted mid-flip");
},
});
expect(result).toMatchObject({ ok: false, rolledBack: false });
const m = await meta();
expect(m.activeSlot === "A" && m.activePackageVersion === 1).toBe(true);
expect(m.readbackPending).toBe(false);
const report = await bootState();
expect(report.state).toBe("READY");
expect(report.packageVersion).toBe(1);
});
it("S14: corruption found by post-activation readback → automatic rollback to last complete slot", async () => {
await activateVersion(1);
const build = await built(2);
const staged = await stageVerifiedPackage(build.pkg!);
const result = await activateStagedPackage(build.pkg!, staged, "1.0.0", Date.now, {
afterFlip: async (slot) => corruptFile(slot, "map"),
});
expect(result).toMatchObject({ ok: false, rolledBack: true });
const m = await meta();
expect(m.activeSlot === "A" && m.activePackageVersion === 1).toBe(true);
const report = await bootState();
expect(report.state).toBe("READY");
expect(report.packageVersion).toBe(1);
});
it("X1: active slot corrupted at boot (readback pending) → fallback slot served", async () => {
await activateVersion(1);
await activateVersion(2); // A=v1, B=v2 active
// Post-activation bitrot hits the active slot B before next boot confirms it.
const system = await openSystemDB();
const m0 = await readSystemMeta(system);
await writeSystemMeta(system, { ...m0, readbackPending: true });
system.close();
await corruptFile("B", "schedule");
expect(await recoverPendingActivation()).toBe(true);
const m = await meta();
expect(m.activeSlot === "A" && m.activePackageVersion === 1).toBe(true);
const report = await bootState();
expect(report.state).toBe("READY");
expect(report.packageVersion).toBe(1);
});
it("X2: both slots lost → RECOVERY with emergency floor; favorites intact", async () => {
await activateVersion(1);
const user = await openUserDB();
await addFavorite(user, { eventId: "evt-x2", addedAt: 1 });
user.close();
await deleteDb(DB.SLOT_A);
await deleteDb(DB.SLOT_B);
const report = await bootState();
expect(report.state).toBe("RECOVERY");
expect(report.reason).toBe("missing");
expect(report.floorVersion).toBeTruthy(); // embedded emergency baseline present
const user2 = await openUserDB();
const favs = await listFavorites(user2);
user2.close();
expect(favs.map((f) => f.eventId)).toEqual(["evt-x2"]);
});
it("X3: favorites survive a full update lifecycle (v1 → v2 → rollback)", async () => {
const user = await openUserDB();
await addFavorite(user, { eventId: "evt-keep", addedAt: 7 });
user.close();
await activateVersion(1);
const build = await built(2);
expect((await syncRun(build, 2)).ok).toBe(true);
expect(await restorePreviousSlot()).toBe(true);
const user2 = await openUserDB();
const favs = await listFavorites(user2);
user2.close();
expect(favs.map((f) => f.eventId)).toEqual(["evt-keep"]);
const report = await bootState();
expect(report.state).toBe("READY");
expect(report.packageVersion).toBe(1);
});
it("no-loop: quarantined v2 is never re-fetched until latest.json advances past it", async () => {
await activateVersion(1);
const build = await built(2);
build.files.set("map.json", new Uint8Array([9, 9, 9])); // poison v2 → quarantine
expect((await syncRun(build, 2)).ok).toBe(false);
const fetches: string[] = [];
const user = await openUserDB();
const outcome = await pullCandidate(
originTransport(build, fetches, 2),
EDITION,
{
trustedKeys: new Map([
[build.keyPair.fingerprint, publicKeyFromDerBase64(build.keyPair.publicKeyDerBase64)],
]),
appVersion: "1.0.0",
supportedSchemaRange: [1],
quarantine: quarantineSink(user),
},
{ isQuarantined: async (v) => isQuarantined(user, EDITION, v) },
);
user.close();
expect(outcome).toMatchObject({ skipped: "quarantined" });
expect(fetches).toEqual(["latest.json"]);
const m = await meta();
expect(m.activePackageVersion).toBe(1);
});
it("activation seam honors single-txn contract: retry-once recovers a transient flip failure", async () => {
await activateVersion(1);
const build = await built(2);
const staged = await stageVerifiedPackage(build.pkg!);
let attempts = 0;
const result = await activateStagedPackage(build.pkg!, staged, "1.0.0", Date.now, {
activate: async (db, next) => {
attempts += 1;
if (attempts === 1) throw new Error("transient IDB failure");
return activateSlot(db, next);
},
});
expect(result.ok).toBe(true);
expect(attempts).toBe(2);
const report = await bootState();
expect(report.state).toBe("READY");
expect(report.packageVersion).toBe(2);
});
});