229 lines
8.8 KiB
TypeScript
229 lines
8.8 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/require-await */
|
|
/**
|
|
* Stage 9 cadence — user-initiated FULL re-verification of the active slot
|
|
* (§20.3): clean pass, per-file hash corruption detected + quarantined,
|
|
* record tampering detected, asset blob corruption detected, no active
|
|
* dataset handled without quarantine. Plus the boot light-check budget
|
|
* (≤150 ms target, no hashing — timing measured generously on CI).
|
|
* Trace: IMPLEMENTATION-CONTRACT.md §20.3, SPIKE-05 §4, SPIKE-02 F-5.
|
|
*/
|
|
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 { publicKeyFromDerBase64 } from "../../src/sync/verifier/ed25519.js";
|
|
import { verifyPackage } from "../../src/sync/verifier/package.js";
|
|
import { activateStagedPackage, stageVerifiedPackage } from "../../src/sync/activation.js";
|
|
import { fullReverifyActiveSlot } from "../../src/sync/reverify.js";
|
|
import { openSystemDB, readSystemMeta } from "../../src/data/system-meta/store.js";
|
|
import {
|
|
openSlotDB,
|
|
readSlotAsset,
|
|
readSlotManifest,
|
|
writeSlotFile,
|
|
} from "../../src/data/slot/store.js";
|
|
import { openUserDB } from "../../src/data/user/store.js";
|
|
import { isQuarantined, listQuarantined } from "../../src/data/user/quarantine.js";
|
|
import { withTx } from "../../src/platform/idb/wrapper.js";
|
|
import { DB, SLOT_ASSETS, SLOT_FILES } from "../../src/platform/idb/names.js";
|
|
import type { SectionId } from "../../src/data/festival-package/types.js";
|
|
import { evaluateBootReadiness, defaultBootDeps } from "../../src/app/readiness.js";
|
|
|
|
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();
|
|
};
|
|
});
|
|
}
|
|
|
|
async function activateFixture(version: number): Promise<{
|
|
manifestSha256: string;
|
|
edition: string;
|
|
files: Map<string, Uint8Array>;
|
|
}> {
|
|
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);
|
|
const staged = await stageVerifiedPackage(result);
|
|
const activated = await activateStagedPackage(result, staged, "1.0.0");
|
|
if (!activated.ok) throw new Error("activation failed");
|
|
return { manifestSha256: result.manifestSha256, edition: result.manifest.edition, files };
|
|
}
|
|
|
|
async function activeSlot(): Promise<"A" | "B"> {
|
|
const system = await openSystemDB();
|
|
const meta = await readSystemMeta(system);
|
|
system.close();
|
|
if (!meta.activeSlot) throw new Error("no active slot");
|
|
return meta.activeSlot;
|
|
}
|
|
|
|
/** Overwrite the stored section JSON (post-verification corruption). */
|
|
async function corruptSection(id: SectionId, json: unknown): Promise<void> {
|
|
const slot = await openSlotDB(await activeSlot());
|
|
const manifest = await readSlotManifest(slot);
|
|
const entry = manifest?.sections[id];
|
|
if (!entry) throw new Error("manifest entry missing");
|
|
await writeSlotFile(slot, id, { bytes: entry.bytes, sha256: entry.sha256, json });
|
|
slot.close();
|
|
}
|
|
|
|
/** Keep bytes but tamper the stored hash record. */
|
|
async function tamperSectionRecord(id: SectionId): Promise<void> {
|
|
const slot = await openSlotDB(await activeSlot());
|
|
await withTx(slot, SLOT_FILES, "readwrite", (tx) => {
|
|
tx.objectStore(SLOT_FILES).put({ id, bytes: 1, sha256: "f".repeat(64), json: {} }, id);
|
|
});
|
|
slot.close();
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await Promise.all(Object.values(DB).map((name) => deleteDb(name)));
|
|
});
|
|
|
|
describe("full re-verify active slot (§20.3 user check)", () => {
|
|
it("clean activated dataset passes with its version", async () => {
|
|
await activateFixture(1);
|
|
const outcome = await fullReverifyActiveSlot({
|
|
appVersion: "1.0.0",
|
|
supportedSchemaRange: [1],
|
|
});
|
|
expect(outcome).toEqual({ ok: true, packageVersion: 1 });
|
|
});
|
|
|
|
it("corrupted section JSON is detected and quarantined (F-5)", async () => {
|
|
const { edition } = await activateFixture(1);
|
|
await corruptSection("schedule", { section: "schedule", events: [] });
|
|
const outcome = await fullReverifyActiveSlot({
|
|
appVersion: "1.0.0",
|
|
supportedSchemaRange: [1],
|
|
});
|
|
expect(outcome.ok).toBe(false);
|
|
if (!outcome.ok) {
|
|
expect(outcome.quarantined).toBe(true);
|
|
expect(outcome.reason).toContain("schedule");
|
|
}
|
|
const user = await openUserDB();
|
|
expect(await isQuarantined(user, edition, 1)).toBe(true);
|
|
const list = await listQuarantined(user, edition);
|
|
expect(list[0]?.reason).toContain("full re-verify");
|
|
user.close();
|
|
});
|
|
|
|
it("tampered stored hash record is detected", async () => {
|
|
await activateFixture(1);
|
|
await tamperSectionRecord("map");
|
|
const outcome = await fullReverifyActiveSlot({
|
|
appVersion: "1.0.0",
|
|
supportedSchemaRange: [1],
|
|
});
|
|
expect(outcome.ok).toBe(false);
|
|
if (!outcome.ok) expect(outcome.reason).toContain("record mismatch map");
|
|
});
|
|
|
|
it("corrupted asset blob is detected", async () => {
|
|
await activateFixture(1);
|
|
const slot = await openSlotDB(await activeSlot());
|
|
const all = await readSlotAsset(slot, "map-base-overview");
|
|
expect(all).toBeDefined();
|
|
await withTx(slot, SLOT_ASSETS, "readwrite", (tx) => {
|
|
tx.objectStore(SLOT_ASSETS).put(
|
|
{
|
|
id: "map-base-overview",
|
|
bytes: all?.bytes,
|
|
sha256: all?.sha256,
|
|
blob: new Blob(["CORRUPT"]),
|
|
},
|
|
"map-base-overview",
|
|
);
|
|
});
|
|
slot.close();
|
|
const outcome = await fullReverifyActiveSlot({
|
|
appVersion: "1.0.0",
|
|
supportedSchemaRange: [1],
|
|
});
|
|
expect(outcome.ok).toBe(false);
|
|
if (!outcome.ok) expect(outcome.reason).toContain("map-base-overview");
|
|
});
|
|
|
|
it("no active dataset → not ok, nothing quarantined", async () => {
|
|
const outcome = await fullReverifyActiveSlot({
|
|
appVersion: "1.0.0",
|
|
supportedSchemaRange: [1],
|
|
});
|
|
expect(outcome).toMatchObject({ ok: false, quarantined: false });
|
|
const user = await openUserDB();
|
|
expect(await listQuarantined(user, "lumen-2026")).toHaveLength(0);
|
|
user.close();
|
|
});
|
|
|
|
it("incompatible app version fails the compatibility gate", async () => {
|
|
await activateFixture(1);
|
|
const outcome = await fullReverifyActiveSlot({
|
|
appVersion: "0.0.1",
|
|
supportedSchemaRange: [1],
|
|
});
|
|
expect(outcome.ok).toBe(false);
|
|
if (!outcome.ok) expect(outcome.reason).toContain("incompatible");
|
|
});
|
|
});
|
|
|
|
describe("boot light-check budget (§20.3 ≤150 ms, no hashing)", () => {
|
|
it("READY boot evaluation on an activated dataset stays within budget", async () => {
|
|
await activateFixture(1);
|
|
const deps = {
|
|
...defaultBootDeps,
|
|
appVersion: "1.0.0",
|
|
checkShell: async () => ({ ok: true, cacheName: "lumen-shell-test" }),
|
|
loadFloor: () => ({ ok: true, version: "embedded-1", bytes: 4096 }),
|
|
estimate: async () => null,
|
|
};
|
|
// Warm the IDB open path, then time a representative boot evaluation.
|
|
await evaluateBootReadiness(deps);
|
|
const start = performance.now();
|
|
const report = await evaluateBootReadiness(deps);
|
|
const elapsedMs = performance.now() - start;
|
|
expect(report.state).toBe("READY");
|
|
// fake-indexeddb runs faster than real IDB but Node CI jitter is real —
|
|
// the 150 ms target is measured with a generous 4x margin on this seam.
|
|
expect(elapsedMs).toBeLessThan(600);
|
|
});
|
|
});
|