Lumen/tests/unit/emergency.test.ts

371 lines
15 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.

/**
* @vitest-environment jsdom
* Stage 10 — Emergency three tiers: resolution matrix, Tier 2 rendering,
* demotion guidance, zero-IDB floor, loader, and boot wiring.
* Trace: IMPLEMENTATION-CONTRACT.md §14, SPIKE-06.
*/
/* eslint-disable @typescript-eslint/no-unsafe-call */
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";
import { mount as boot } from "../../src/app/main.js";
import { loadDatasetEmergency } from "../../src/app/emergency.js";
import {
demotedBaseline,
isSupportedEmergencySchema,
narrowEmergencySection,
resolveEmergency,
visibleNotices,
} from "../../src/domain/emergency/logic.js";
import type { VerifiedEmergencyData } from "../../src/domain/emergency/types.js";
import type { EmergencySection } from "../../src/data/festival-package/types.js";
import { createEmergencyView } from "../../src/ui/views/emergency/emergency.js";
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 { openSystemDB } from "../../src/data/system-meta/store.js";
import { openSlotDB, writeSlotFile } from "../../src/data/slot/store.js";
import { DB } from "../../src/platform/idb/names.js";
const g = globalThis as unknown as Record<string, unknown>;
const NOW = 1_750_000_000_000;
function section(patch: Record<string, unknown> = {}): EmergencySection {
return {
section: "emergency",
emergencySchemaVersion: 1,
contentVersion: 2,
updatedAt: "2026-09-01T00:00:00.000Z",
services: {
emergencyNumber: "911",
security: { phone: "555-0100", location: "North gate" },
firstAid: { location: "Medical tent", hours: "10:00–22:00" },
},
locations: {
musterPoints: [{ id: "m1", name: "North field", poi: "poi-muster" }],
exits: [{ id: "e1", name: "Main gate", poi: "poi-exit" }],
aeds: [{ poi: "poi-aed" }],
},
address: { lines: ["1 Festival Way"], coordinates: { lat: 29.9, lon: -97.9 } },
procedures: [{ id: "medical", title: "Medical", steps: ["Call 911", "Find staff"] }],
notices: [
{
id: "n1",
severity: "info",
title: "Gates open",
body: ["Gates open at 9am"],
expiresAtUtc: null,
},
],
...patch,
};
}
function dataset(patch: Partial<VerifiedEmergencyData> = {}): VerifiedEmergencyData {
return {
section: section(),
packageVersion: 3,
generatedAt: "2026-08-20T00:00:00.000Z",
emergencySchemaVersion: 1,
...patch,
};
}
describe("resolveEmergency — tier matrix (SPIKE-06)", () => {
it("no dataset → baseline with no-dataset demotion", () => {
const model = resolveEmergency(undefined, NOW);
expect(model.source).toBe("baseline");
expect(model.demotion).toBe("no-dataset");
expect(model.provenance).toMatch(/^BASELINE v/);
expect(model.section).toBeNull();
});
it("valid dataset → dataset tier with merged floor fields and provenance", () => {
const sparse = section();
const { services: _dropServices, ...withoutServices } = sparse;
void _dropServices;
const model = resolveEmergency(dataset({ section: withoutServices as EmergencySection }), NOW);
expect(model.source).toBe("dataset");
expect(model.demotion).toBeNull();
// Floor-only services backfill the sparse section (defensive merge).
expect(model.section?.services.emergencyNumber).toBeTruthy();
expect(model.provenance).toContain("FESTIVAL DATA v3");
expect(model.provenance).toContain("generated 2026-08-20");
expect(model.provenance).toContain("updated 2026-09-01");
});
it("sparse sections merge floor fields instead of demoting", () => {
const sparse = section({ services: {}, procedures: [] });
const model = resolveEmergency(dataset({ section: sparse }), NOW);
expect(model.source).toBe("dataset");
expect(model.section?.services.emergencyNumber).toBeTruthy();
expect(model.section?.procedures.length).toBeGreaterThan(0);
});
it("unsupported schema → baseline with unsupported-schema demotion", () => {
const model = resolveEmergency(dataset({ emergencySchemaVersion: 99 }), NOW);
expect(model.source).toBe("baseline");
expect(model.demotion).toBe("unsupported-schema");
expect(isSupportedEmergencySchema(99)).toBe(false);
expect(isSupportedEmergencySchema(1)).toBe(true);
});
it("malformed section → baseline with malformed-section demotion", () => {
const badValues: unknown[] = [
"nope",
42,
null,
{},
{ section: "emergency" },
{ section: "emergency", emergencySchemaVersion: 0 },
{ section: "emergency", emergencySchemaVersion: "1" },
{ section: "emergency", emergencySchemaVersion: 1, services: "911" },
{
section: "emergency",
emergencySchemaVersion: 1,
procedures: [{ id: "x" }],
},
];
for (const bad of badValues) {
const model = resolveEmergency(dataset({ section: bad as EmergencySection }), NOW);
expect(model.source).toBe("baseline");
expect(model.demotion).toBe("malformed-section");
}
});
it("unknown future fields never throw (forward tolerance)", () => {
const future = {
...section(),
brandNewField: { deep: [1, 2, 3] },
services: { ...section().services, another: "x" },
};
const model = resolveEmergency(dataset({ section: future }), NOW);
expect(model.source).toBe("dataset");
});
it("expired notices hide; unexpiring and future notices show", () => {
const withNotices = section({
notices: [
{ id: "old", severity: "info", title: "Old", body: ["x"], expiresAtUtc: NOW - 1000 },
{ id: "live", severity: "urgent", title: "Live", body: ["y"], expiresAtUtc: NOW + 1000 },
{ id: "forever", severity: "info", title: "Forever", body: ["z"], expiresAtUtc: null },
],
});
const filtered = visibleNotices(withNotices, NOW);
expect(filtered.notices?.map((n) => n.id)).toEqual(["live", "forever"]);
const model = resolveEmergency(dataset({ section: withNotices }), NOW);
expect(model.section?.notices?.map((n) => n.id)).toEqual(["live", "forever"]);
});
it("demotedBaseline carries the reason with floor provenance", () => {
const model = demotedBaseline("unsupported-schema");
expect(model.source).toBe("baseline");
expect(model.demotion).toBe("unsupported-schema");
expect(model.provenance).toMatch(/^BASELINE v/);
});
});
describe("narrowEmergencySection", () => {
it("accepts a valid section, rejects everything else without throwing", () => {
expect(narrowEmergencySection(section())).toBeTruthy();
for (const bad of [undefined, null, 7, "x", [], {}, { section: "schedule" }]) {
expect(narrowEmergencySection(bad)).toBeNull();
}
});
});
describe("emergency view", () => {
it("floor tier renders number, dial link, procedures, and guidance", () => {
const view = createEmergencyView(undefined, NOW);
expect(view.querySelector("h1")?.textContent).toBe("Emergency");
expect(view.querySelector('a[href^="tel:"]')).toBeTruthy();
expect(view.textContent).toContain("BASELINE v");
expect(view.textContent).toContain("isn't on this device yet");
});
it("emergency number is last-resort: bottom of page, collapsed, after procedures", () => {
const view = createEmergencyView(undefined, NOW);
const last = view.querySelector(".emergency-last-resort");
expect(last).toBeTruthy();
// collapsed by default — the number is not presented until asked
expect(last?.hasAttribute("open")).toBe(false);
// it is the very last element of the view
expect(view.lastElementChild).toBe(last);
// ...it comes after the procedures section...
const idx = (selector: string): number => {
const el = view.querySelector(selector);
return el ? [...view.children].indexOf(el) : -1;
};
expect(idx(".emergency-last-resort")).toBeGreaterThan(
idx('[aria-labelledby="emergency-procedures-heading"]'),
);
// ...and the page leads with event help, not the dial number
expect(idx(".emergency-share-card")).toBeGreaterThanOrEqual(1);
expect(view.querySelector(".emergency-share-card")?.textContent).toContain("event help");
});
it("dataset tier renders locations with map handoffs and notices", () => {
const view = createEmergencyView(dataset(), NOW);
expect(view.textContent).toContain("FESTIVAL DATA v3");
expect(view.textContent).not.toContain("isn't on this device yet");
expect(view.textContent).toContain("North field");
expect(view.textContent).toContain("[info] Gates open");
const mapLinks = [...view.querySelectorAll('a[data-route="/map"]')];
expect(mapLinks.length).toBeGreaterThan(0);
expect(view.textContent).toContain("First-aid hours: 10:00–22:00");
});
it("each demotion reason renders its own guidance", () => {
for (const [reason, hint] of [
["no-dataset", "isn't on this device yet"],
["unsupported-schema", "newer app version"],
["malformed-section", "couldn't be read"],
] as const) {
const view = createEmergencyView(demotedBaseline(reason), NOW);
expect(view.textContent).toContain("BASELINE v");
expect(view.textContent).toContain(hint);
}
});
it("renders with zero storage access (Ring-0: no indexedDB, no caches)", () => {
const idx = g.indexedDB;
const cachesGlobal = (globalThis as Record<string, unknown>).caches;
// Ring-0 proof — storage must not exist here.
delete g.indexedDB;
delete (globalThis as Record<string, unknown>).caches;
try {
const view = createEmergencyView(undefined, NOW);
expect(view.querySelector("h1")?.textContent).toBe("Emergency");
expect(view.querySelector('a[href^="tel:"]')).toBeTruthy();
} finally {
g.indexedDB = idx;
if (cachesGlobal !== undefined) {
(globalThis as Record<string, unknown>).caches = cachesGlobal;
}
}
});
it("never renders raw HTML from content", () => {
const evil = section({ procedures: [{ id: "x", title: "<img src=x>", steps: ["<b>do</b>"] }] });
const view = createEmergencyView(dataset({ section: evil }), NOW);
expect(view.innerHTML).not.toContain("<img src=x>");
expect(view.innerHTML).not.toContain("<b>do</b>");
});
});
describe("dataset emergency loader", () => {
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 failed"));
};
request.onblocked = () => {
resolve();
};
});
}
async function fixturePackage(version: number) {
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(`verify failed: ${result.reason}`);
return result;
}
beforeEach(async () => {
g.indexedDB = new FDBFactory() as unknown;
g.IDBKeyRange = FDBKeyRange as unknown;
await deleteDb(DB.SYSTEM);
await deleteDb(DB.SLOT_A);
await deleteDb(DB.SLOT_B);
});
afterEach(() => {
// Leave no polyfill behind for the zero-storage test above.
delete g.indexedDB;
});
it("fresh install → absent", async () => {
expect(await loadDatasetEmergency()).toEqual({ status: "absent" });
});
it("staged + activated fixture → ready with version detail", async () => {
const pkg = await fixturePackage(2);
const staged = await stageVerifiedPackage(pkg);
const activated = await activateStagedPackage(pkg, staged, "1.0.0");
expect(activated.ok).toBe(true);
const loaded = await loadDatasetEmergency();
expect(loaded.status).toBe("ready");
if (loaded.status !== "ready") throw new Error("unreachable");
expect(loaded.data.packageVersion).toBe(2);
expect(loaded.data.section.services.emergencyNumber).toBeTruthy();
expect(loaded.data.generatedAt).toBe(pkg.manifest.generatedAt);
});
it("malformed stored section → unusable malformed-section", async () => {
const system = await openSystemDB();
const { writeSystemMeta } = await import("../../src/data/system-meta/store.js");
const { INITIAL_SYSTEM_META } = await import("../../src/data/system-meta/types.js");
await writeSystemMeta(system, {
...INITIAL_SYSTEM_META,
activeSlot: "A",
activeEdition: "test-edition",
activePackageVersion: 1,
});
system.close();
const slot = await openSlotDB("A");
await writeSlotFile(slot, "emergency", {
bytes: 32,
sha256: "0".repeat(64),
json: { section: "emergency", services: {} },
});
slot.close();
expect(await loadDatasetEmergency()).toEqual({
status: "unusable",
reason: "malformed-section",
});
});
it("boot renders Tier 2 through the real path after staging", async () => {
const pkg = await fixturePackage(1);
const staged = await stageVerifiedPackage(pkg);
expect((await activateStagedPackage(pkg, staged, "1.0.0")).ok).toBe(true);
history.replaceState(null, "", "/emergency");
document.body.innerHTML = '<div id="app" aria-busy="true"></div>';
boot();
await vi.waitFor(() => {
expect(document.querySelector(".emergency-view")?.textContent).toContain("FESTIVAL DATA v1");
});
document.body.innerHTML = "";
});
});