Stage 9 close-out: user-initiated full re-verify of active slot (S20.3 cadence) wired into Check my data, boot light-check budget test
Some checks failed
ci / check (push) Has been cancelled
Some checks failed
ci / check (push) Has been cancelled
This commit is contained in:
parent
506bebed9c
commit
859cb649c8
5 changed files with 457 additions and 20 deletions
|
|
@ -23,6 +23,7 @@ import { openUserDB, addFavorite, removeFavorite, putPrefs } from "../data/user/
|
||||||
import { createScheduleView } from "../ui/views/schedule/schedule.js";
|
import { createScheduleView } from "../ui/views/schedule/schedule.js";
|
||||||
import type { ScheduleViewActions, ScheduleViewInput } from "../ui/views/schedule/schedule.js";
|
import type { ScheduleViewActions, ScheduleViewInput } from "../ui/views/schedule/schedule.js";
|
||||||
import { restorePreviousSlot } from "../sync/activation.js";
|
import { restorePreviousSlot } from "../sync/activation.js";
|
||||||
|
import { fullReverifyActiveSlot } from "../sync/reverify.js";
|
||||||
import { runSync } from "./sync.js";
|
import { runSync } from "./sync.js";
|
||||||
import { createLayout, setActiveNav } from "./layout.js";
|
import { createLayout, setActiveNav } from "./layout.js";
|
||||||
import { Router, routeForPath, normalizePath } from "../ui/router/router.js";
|
import { Router, routeForPath, normalizePath } from "../ui/router/router.js";
|
||||||
|
|
@ -109,6 +110,57 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
statusChip.setAttribute("aria-label", `${chip.label} Activate for status details.`);
|
statusChip.setAttribute("aria-label", `${chip.label} Activate for status details.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// §20.3 cadence: "Check my data" is the user-initiated FULL re-verify
|
||||||
|
// (all hashes + schema); the plain re-evaluation path stays light.
|
||||||
|
let checkBusy = false;
|
||||||
|
let checkMessage: string | null = null;
|
||||||
|
|
||||||
|
function runCheck(report: BootReadinessReport, dialog: HTMLDialogElement): void {
|
||||||
|
if (checkBusy) return;
|
||||||
|
checkBusy = true;
|
||||||
|
checkMessage = "Checking all festival data on this device…";
|
||||||
|
dialog.replaceChildren(statusViewFor(report));
|
||||||
|
void fullReverifyActiveSlot()
|
||||||
|
.then((outcome) => {
|
||||||
|
checkBusy = false;
|
||||||
|
checkMessage = outcome.ok
|
||||||
|
? `All festival data checked — v${String(outcome.packageVersion)} verified on this device.`
|
||||||
|
: outcome.quarantined
|
||||||
|
? `Check found a problem (${outcome.reason}). This version was quarantined; reopening status re-checks what remains.`
|
||||||
|
: `Check found no active dataset (${outcome.reason}).`;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
checkBusy = false;
|
||||||
|
checkMessage = "Check could not complete on this device.";
|
||||||
|
})
|
||||||
|
.then(() => void refreshReadiness(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusViewFor(report: BootReadinessReport): HTMLElement {
|
||||||
|
return createStatusView(
|
||||||
|
report,
|
||||||
|
{
|
||||||
|
onCheckData: () => {
|
||||||
|
const dialog = statusDialog;
|
||||||
|
const current = latestReport ?? report;
|
||||||
|
if (dialog) runCheck(current, dialog);
|
||||||
|
},
|
||||||
|
onGetData: () => {
|
||||||
|
showPrepGuidance();
|
||||||
|
},
|
||||||
|
onRestore: report.canRestore
|
||||||
|
? () => {
|
||||||
|
void restorePreviousSlot()
|
||||||
|
.catch(() => false)
|
||||||
|
.then(() => refreshReadiness(true));
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
onClose: () => statusDialog?.close(),
|
||||||
|
},
|
||||||
|
checkMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function showStatus(report: BootReadinessReport): void {
|
function showStatus(report: BootReadinessReport): void {
|
||||||
if (!statusDialog) {
|
if (!statusDialog) {
|
||||||
statusDialog = document.createElement("dialog");
|
statusDialog = document.createElement("dialog");
|
||||||
|
|
@ -121,25 +173,7 @@ function mount(): { router: Router; cleanup: () => void } {
|
||||||
root.append(statusDialog);
|
root.append(statusDialog);
|
||||||
}
|
}
|
||||||
const dialog = statusDialog;
|
const dialog = statusDialog;
|
||||||
const close = (): void => {
|
dialog.replaceChildren(statusViewFor(report));
|
||||||
dialog.close();
|
|
||||||
};
|
|
||||||
dialog.replaceChildren(
|
|
||||||
createStatusView(report, {
|
|
||||||
onCheckData: () => void refreshReadiness(true),
|
|
||||||
onGetData: () => {
|
|
||||||
showPrepGuidance();
|
|
||||||
},
|
|
||||||
onRestore: report.canRestore
|
|
||||||
? () => {
|
|
||||||
void restorePreviousSlot()
|
|
||||||
.catch(() => false)
|
|
||||||
.then(() => refreshReadiness(true));
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
onClose: close,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (!dialog.open) dialog.showModal();
|
if (!dialog.open) dialog.showModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
149
src/sync/reverify.ts
Normal file
149
src/sync/reverify.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
/**
|
||||||
|
* Full re-verification of the ACTIVE slot (§20.3 cadence).
|
||||||
|
*
|
||||||
|
* The boot light check never hashes; this is the heavy pass the cadence table
|
||||||
|
* schedules for user-initiated "Check my data" and post-IDB-error checks:
|
||||||
|
* every required section is re-serialized (canonical bytes) and re-hashed
|
||||||
|
* against the manifest record, every asset blob is re-hashed, and the schema
|
||||||
|
* + compatibility gates run again. Any failure quarantines the checked
|
||||||
|
* edition/version (§20.3 "quarantine on failure", SPIKE-02 F-5) — the active
|
||||||
|
* dataset itself is never modified here; state transitions belong to boot.
|
||||||
|
*
|
||||||
|
* Trace: IMPLEMENTATION-CONTRACT.md §20.3, SPIKE-05 §4, SPIKE-02 F-5.
|
||||||
|
*/
|
||||||
|
import { canonicalJson } from "../../pipeline/canonical-json.js";
|
||||||
|
import {
|
||||||
|
openSlotDB,
|
||||||
|
readSlotAsset,
|
||||||
|
readSlotFile,
|
||||||
|
readSlotFileMeta,
|
||||||
|
readSlotManifest,
|
||||||
|
} from "../data/slot/store.js";
|
||||||
|
import { openSystemDB, readSystemMeta } from "../data/system-meta/store.js";
|
||||||
|
import { isCompatible, validateManifest } from "../data/festival-package/validation.js";
|
||||||
|
import type { FestivalManifest, SectionId } from "../data/festival-package/types.js";
|
||||||
|
import { openUserDB } from "../data/user/store.js";
|
||||||
|
import { addQuarantined } from "../data/user/quarantine.js";
|
||||||
|
import { APP_VERSION, SUPPORTED_SCHEMA_RANGE } from "../domain/readiness/config.js";
|
||||||
|
import { sha256Hex } from "./verifier/hash.js";
|
||||||
|
import type { VerifyErrorCode } from "./verifier/types.js";
|
||||||
|
|
||||||
|
export interface ReverifyDeps {
|
||||||
|
readonly appVersion?: string;
|
||||||
|
readonly supportedSchemaRange?: readonly number[];
|
||||||
|
readonly now?: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReverifyOutcome =
|
||||||
|
| { readonly ok: true; readonly packageVersion: number }
|
||||||
|
| { readonly ok: false; readonly reason: string; readonly quarantined: boolean };
|
||||||
|
|
||||||
|
interface ReverifyFailure {
|
||||||
|
readonly reason: string;
|
||||||
|
readonly code: VerifyErrorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECTION_IDS = ["emergency", "schedule", "map", "info", "assets"] as const;
|
||||||
|
|
||||||
|
function fail(reason: string, code: VerifyErrorCode): ReverifyFailure {
|
||||||
|
return { reason, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredSectionIds(manifest: FestivalManifest): readonly SectionId[] {
|
||||||
|
return SECTION_IDS.filter((id) => manifest.sections[id].required !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkSections(
|
||||||
|
slot: IDBDatabase,
|
||||||
|
manifest: FestivalManifest,
|
||||||
|
): Promise<ReverifyFailure | null> {
|
||||||
|
for (const id of requiredSectionIds(manifest)) {
|
||||||
|
const entry = manifest.sections[id];
|
||||||
|
const stored = await readSlotFileMeta(slot, id);
|
||||||
|
if (!stored) return fail(`missing ${id}`, "missing_file");
|
||||||
|
// Stored meta must agree with the manifest before bytes are trusted.
|
||||||
|
if (stored.sha256 !== entry.sha256) return fail(`record mismatch ${id}`, "hash_mismatch");
|
||||||
|
const json = await readSlotFile(slot, id);
|
||||||
|
if (json === undefined) return fail(`unreadable ${id}`, "missing_file");
|
||||||
|
// Sections persist the parsed canonical object; re-serialization through
|
||||||
|
// the deterministic canonicalizer reproduces the published bytes exactly.
|
||||||
|
const bytes = new TextEncoder().encode(canonicalJson(json));
|
||||||
|
if (bytes.length !== entry.bytes) return fail(`size mismatch ${id}`, "size_mismatch");
|
||||||
|
const hex = await sha256Hex(bytes);
|
||||||
|
if (hex !== entry.sha256) return fail(`hash mismatch ${id}`, "hash_mismatch");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkAssets(slot: IDBDatabase): Promise<ReverifyFailure | null> {
|
||||||
|
const inventory = (await readSlotFile<{ assets?: unknown }>(slot, "assets")) as
|
||||||
|
{ assets?: unknown } | undefined;
|
||||||
|
const list = inventory?.assets;
|
||||||
|
if (!Array.isArray(list)) return fail("assets inventory unreadable", "malformed_manifest");
|
||||||
|
for (const item of list) {
|
||||||
|
const a = item as Record<string, unknown>;
|
||||||
|
if (typeof a.id !== "string" || typeof a.sha256 !== "string") continue;
|
||||||
|
if (a.required === false) continue;
|
||||||
|
const record = await readSlotAsset(slot, a.id);
|
||||||
|
if (!record) return fail(`missing asset ${a.id}`, "missing_file");
|
||||||
|
if (record.sha256 !== a.sha256) return fail(`record mismatch asset ${a.id}`, "hash_mismatch");
|
||||||
|
const bytes = new Uint8Array(await record.blob.arrayBuffer());
|
||||||
|
if (bytes.length !== record.bytes) return fail(`size mismatch asset ${a.id}`, "size_mismatch");
|
||||||
|
const hex = await sha256Hex(bytes);
|
||||||
|
if (hex !== a.sha256) return fail(`hash mismatch asset ${a.id}`, "hash_mismatch");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full C3–C7 re-verification of the active slot: schema, compatibility,
|
||||||
|
* per-file hashes (sections + assets). Any failure quarantines the checked
|
||||||
|
* edition/version in lumen-user; nothing on disk is rewritten.
|
||||||
|
*/
|
||||||
|
export async function fullReverifyActiveSlot(deps: ReverifyDeps = {}): Promise<ReverifyOutcome> {
|
||||||
|
const appVersion = deps.appVersion ?? APP_VERSION;
|
||||||
|
const supportedSchemaRange = deps.supportedSchemaRange ?? SUPPORTED_SCHEMA_RANGE;
|
||||||
|
const now = deps.now ?? ((): number => Date.now());
|
||||||
|
|
||||||
|
const system = await openSystemDB();
|
||||||
|
const meta = await readSystemMeta(system);
|
||||||
|
system.close();
|
||||||
|
if (!meta.activeSlot || !meta.activeEdition || meta.activePackageVersion === null) {
|
||||||
|
return { ok: false, reason: "no active dataset", quarantined: false };
|
||||||
|
}
|
||||||
|
const edition = meta.activeEdition;
|
||||||
|
const packageVersion = meta.activePackageVersion;
|
||||||
|
|
||||||
|
let failure: ReverifyFailure | null = null;
|
||||||
|
const slot = await openSlotDB(meta.activeSlot);
|
||||||
|
try {
|
||||||
|
const manifest = await readSlotManifest(slot);
|
||||||
|
if (!manifest) {
|
||||||
|
failure = fail("manifest missing from active slot", "missing_file");
|
||||||
|
} else {
|
||||||
|
const schema = validateManifest(manifest);
|
||||||
|
if (!schema.ok) {
|
||||||
|
failure = fail(`schema invalid: ${schema.reason}`, "malformed_manifest");
|
||||||
|
} else if (!isCompatible(manifest, supportedSchemaRange, appVersion)) {
|
||||||
|
failure = fail("package incompatible with this app", "incompatible_app");
|
||||||
|
}
|
||||||
|
failure ??= await checkSections(slot, manifest);
|
||||||
|
failure ??= await checkAssets(slot);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
slot.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!failure) return { ok: true, packageVersion };
|
||||||
|
|
||||||
|
const user = await openUserDB();
|
||||||
|
await addQuarantined(user, {
|
||||||
|
edition,
|
||||||
|
packageVersion,
|
||||||
|
code: failure.code,
|
||||||
|
reason: `full re-verify: ${failure.reason}`,
|
||||||
|
at: now(),
|
||||||
|
});
|
||||||
|
user.close();
|
||||||
|
return { ok: false, reason: failure.reason, quarantined: true };
|
||||||
|
}
|
||||||
|
|
@ -62,7 +62,12 @@ function sectionRow(section: SectionStatus): HTMLLIElement {
|
||||||
* dialog/overlay lifecycle; this returns fresh content on every call so the
|
* dialog/overlay lifecycle; this returns fresh content on every call so the
|
||||||
* caller can re-render after re-evaluation.
|
* caller can re-render after re-evaluation.
|
||||||
*/
|
*/
|
||||||
export function createStatusView(report: BootReadinessReport, actions: StatusActions): HTMLElement {
|
export function createStatusView(
|
||||||
|
report: BootReadinessReport,
|
||||||
|
actions: StatusActions,
|
||||||
|
/** Live notice from an in-flight or finished check (transient, not part of the report). */
|
||||||
|
checkMessage?: string | null,
|
||||||
|
): HTMLElement {
|
||||||
const section = document.createElement("section");
|
const section = document.createElement("section");
|
||||||
section.className = "status-view";
|
section.className = "status-view";
|
||||||
section.setAttribute("aria-labelledby", "status-heading");
|
section.setAttribute("aria-labelledby", "status-heading");
|
||||||
|
|
@ -118,6 +123,13 @@ export function createStatusView(report: BootReadinessReport, actions: StatusAct
|
||||||
: `Storage: ${formatBytes(usage.usage)} used of ${formatBytes(usage.quota)}${report.shellCache ? ` · shell ${report.shellCache}` : ""}`;
|
: `Storage: ${formatBytes(usage.usage)} used of ${formatBytes(usage.quota)}${report.shellCache ? ` · shell ${report.shellCache}` : ""}`;
|
||||||
section.append(text("p", storageText));
|
section.append(text("p", storageText));
|
||||||
|
|
||||||
|
if (checkMessage) {
|
||||||
|
const notice = text("p", checkMessage);
|
||||||
|
notice.className = "status-check-notice";
|
||||||
|
notice.setAttribute("role", "status");
|
||||||
|
section.append(notice);
|
||||||
|
}
|
||||||
|
|
||||||
const buttons = document.createElement("div");
|
const buttons = document.createElement("div");
|
||||||
buttons.className = "status-actions";
|
buttons.className = "status-actions";
|
||||||
|
|
||||||
|
|
|
||||||
229
tests/unit/reverify.test.ts
Normal file
229
tests/unit/reverify.test.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
/* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -123,6 +123,19 @@ describe("Status view", () => {
|
||||||
expect(view.innerHTML).not.toContain("<b>map</b>");
|
expect(view.innerHTML).not.toContain("<b>map</b>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("check notice renders as live status text when provided", () => {
|
||||||
|
const view = createStatusView(
|
||||||
|
report(),
|
||||||
|
actions(),
|
||||||
|
"All festival data checked — v1 verified on this device.",
|
||||||
|
);
|
||||||
|
const notice = view.querySelector(".status-check-notice");
|
||||||
|
expect(notice?.textContent).toContain("v1 verified");
|
||||||
|
expect(notice?.getAttribute("role")).toBe("status");
|
||||||
|
const plain = createStatusView(report(), actions());
|
||||||
|
expect(plain.querySelector(".status-check-notice")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("prep guidance has Back and Close", () => {
|
it("prep guidance has Back and Close", () => {
|
||||||
const onBack = vi.fn();
|
const onBack = vi.fn();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue