This commit is contained in:
parent
fcc18ddcc8
commit
e83946c207
57 changed files with 6257 additions and 775 deletions
350
tests/unit/emergency.test.ts
Normal file
350
tests/unit/emergency.test.ts
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
/**
|
||||
* @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("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 = "";
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue