371 lines
15 KiB
TypeScript
371 lines
15 KiB
TypeScript
/**
|
||
* @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 = "";
|
||
});
|
||
});
|