643 lines
22 KiB
TypeScript
643 lines
22 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-non-null-assertion, @typescript-eslint/restrict-template-expressions, @typescript-eslint/no-unnecessary-type-assertion */
|
|
/**
|
|
* Stage 6 — Festival Data Package Pipeline tests.
|
|
* Covers gates 1-5, deterministic manifest, hashes, asset inventory, budgets,
|
|
* compatibility, stable IDs, emergency floor consistency, separation, rejection.
|
|
* Trace: SPIKE-04 §3, IMPLEMENTATION-CONTRACT.md Stage 6, ARCH 10.2-10.6
|
|
*/
|
|
import { describe, it, expect } from "vitest";
|
|
import { makeValidInput, makeNextVersion } from "../../pipeline/fixtures.js";
|
|
import { buildPackage, isDeterministic } from "../../pipeline/package.js";
|
|
import { canonicalJson } from "../../pipeline/canonical-json.js";
|
|
import { sha256HexOfString } from "../../pipeline/hash.js";
|
|
import { BUDGETS } from "../../pipeline/budgets.js";
|
|
import { validateManifest } from "../../src/data/festival-package/validation.js";
|
|
import { generateTestKeyPair } from "../../pipeline/sign.js";
|
|
import type { PipelineInput } from "../../pipeline/types.js";
|
|
import { dayKeyFor } from "../../src/domain/clock/logic.js";
|
|
|
|
describe("pipeline — valid package generation", () => {
|
|
it("valid input builds successfully with 5 required sections", () => {
|
|
const input = makeValidInput();
|
|
const res = buildPackage(input);
|
|
expect(res.ok).toBe(true);
|
|
if (!res.ok) return;
|
|
expect(res.pkg.manifest.sections.emergency.file).toBe("emergency.json");
|
|
expect(res.pkg.manifest.sections.schedule.file).toBe("schedule.json");
|
|
expect(res.pkg.manifest.sections.map.file).toBe("map.json");
|
|
expect(res.pkg.manifest.sections.info.file).toBe("info.json");
|
|
expect(res.pkg.manifest.sections.assets.file).toBe("assets.json");
|
|
expect(res.pkg.files.size).toBe(5);
|
|
expect(res.pkg.manifest.counts.events).toBe(3);
|
|
expect(res.pkg.manifest.counts.pois).toBe(3);
|
|
expect(res.pkg.manifest.counts.assets).toBe(2);
|
|
});
|
|
|
|
it("manifest generation is deterministic — same input yields same bytes/sha", () => {
|
|
const input = makeValidInput();
|
|
const a = buildPackage(input);
|
|
const b = buildPackage(input);
|
|
expect(a.ok && b.ok).toBe(true);
|
|
if (!a.ok || !b.ok) return;
|
|
const aJson = canonicalJson(a.pkg.manifest);
|
|
const bJson = canonicalJson(b.pkg.manifest);
|
|
expect(aJson).toBe(bJson);
|
|
expect(sha256HexOfString(aJson)).toBe(sha256HexOfString(bJson));
|
|
expect(isDeterministic(input)).toBe(true);
|
|
});
|
|
|
|
it("changed content produces expected hash changes", () => {
|
|
const input1 = makeValidInput();
|
|
const res1 = buildPackage(input1);
|
|
expect(res1.ok).toBe(true);
|
|
if (!res1.ok) return;
|
|
const sha1 = res1.pkg.files.get("schedule.json")!.sha256;
|
|
// mutate one event title
|
|
const input2: PipelineInput = {
|
|
...input1,
|
|
content: {
|
|
...input1.content,
|
|
schedule: {
|
|
...input1.content.schedule,
|
|
events: input1.content.schedule.events.map((e, i) =>
|
|
i === 0 ? { ...e, title: "Mutated Title" } : e,
|
|
),
|
|
},
|
|
},
|
|
};
|
|
const res2 = buildPackage(input2);
|
|
expect(res2.ok).toBe(true);
|
|
if (!res2.ok) return;
|
|
const sha2 = res2.pkg.files.get("schedule.json")!.sha256;
|
|
expect(sha1).not.toBe(sha2);
|
|
// manifest sha also changes
|
|
const mSha1 = sha256HexOfString(canonicalJson(res1.pkg.manifest));
|
|
const mSha2 = sha256HexOfString(canonicalJson(res2.pkg.manifest));
|
|
expect(mSha1).not.toBe(mSha2);
|
|
});
|
|
|
|
it("stable IDs remain stable across versions", () => {
|
|
const v1 = makeValidInput({ packageVersion: 1 });
|
|
const v2 = makeNextVersion(v1, 2);
|
|
const r1 = buildPackage(v1);
|
|
const r2 = buildPackage(v2);
|
|
expect(r1.ok && r2.ok).toBe(true);
|
|
// ids must be same set
|
|
const ids1 = v1.content.schedule.events.map((e) => e.id).sort();
|
|
const ids2 = v2.content.schedule.events.map((e) => e.id).sort();
|
|
expect(ids2).toEqual(ids1);
|
|
});
|
|
|
|
it("manifest conforms exactly to FestivalPackageV1 contract via Stage 4 validator", () => {
|
|
const input = makeValidInput();
|
|
const res = buildPackage(input);
|
|
expect(res.ok).toBe(true);
|
|
if (!res.ok) return;
|
|
expect(validateManifest(res.pkg.manifest).ok).toBe(true);
|
|
expect(res.pkg.manifest.format).toBe("lumen.package/1");
|
|
expect(res.pkg.manifest.limits.totalBytes).toBeGreaterThan(0);
|
|
expect(res.pkg.manifest.limits.totalBytes).toBeLessThanOrEqual(BUDGETS.HARD_TOTAL);
|
|
});
|
|
|
|
it("SHA-256 hashes are 64 hex and bytes match canonical length", () => {
|
|
const input = makeValidInput();
|
|
const res = buildPackage(input);
|
|
expect(res.ok).toBe(true);
|
|
if (!res.ok) return;
|
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
for (const [name, file] of res.pkg.files) {
|
|
expect(HEX64.test(file.sha256), `${name} sha256`).toBe(true);
|
|
expect(file.bytes).toBe(file.canonicalBytes.length);
|
|
}
|
|
for (const a of res.pkg.assets) {
|
|
expect(HEX64.test(a.sha256)).toBe(true);
|
|
expect(a.bytes).toBe(a.bytesContent.length);
|
|
}
|
|
});
|
|
|
|
it("asset inventory lists ids referenced by map levels", () => {
|
|
const input = makeValidInput();
|
|
const res = buildPackage(input);
|
|
expect(res.ok).toBe(true);
|
|
if (!res.ok) return;
|
|
const assetIds = new Set(res.pkg.assets.map((a) => a.id));
|
|
for (const level of input.content.map.base.levels) {
|
|
expect(assetIds.has(level.assetId)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("latest pointer is mutable pointer to immutable package", () => {
|
|
const input = makeValidInput({ edition: "lumen-2026", packageVersion: 7 });
|
|
const res = buildPackage(input);
|
|
expect(res.ok).toBe(true);
|
|
if (!res.ok) return;
|
|
expect(res.pkg.latest.edition).toBe("lumen-2026");
|
|
expect(res.pkg.latest.packageVersion).toBe(7);
|
|
expect(res.pkg.latest.manifestUrl).toBe("/editions/lumen-2026/packages/7/manifest.json");
|
|
});
|
|
});
|
|
|
|
describe("pipeline — required/optional sections", () => {
|
|
it("required sections must be present — missing emergency fails gate1", () => {
|
|
const input = makeValidInput();
|
|
const broken: PipelineInput = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
emergency: null as unknown as PipelineInput["content"]["emergency"],
|
|
},
|
|
};
|
|
const res = buildPackage(broken);
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
|
|
it("optional sections may be absent without failing readiness — unknown optional section allowed via forward-compat", () => {
|
|
// In current V1 all 5 are required, but we test that unknown extra sections are tolerated
|
|
// by adding an announcements-like extra to assets? Actually manifest currently fixed to 5.
|
|
// We test that building with unknown fields on schedule event is allowed
|
|
const input = makeValidInput();
|
|
const withUnknown = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
schedule: {
|
|
...input.content.schedule,
|
|
events: input.content.schedule.events.map(
|
|
(e) => ({ ...e, futureField: "ok" }) as unknown as typeof e,
|
|
),
|
|
},
|
|
},
|
|
} as unknown as PipelineInput;
|
|
const res = buildPackage(withUnknown);
|
|
// gate1 allows unknown fields
|
|
expect(res.ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("pipeline — budgets and limits", () => {
|
|
it("per-file ≤6MB enforced — oversized section fails", () => {
|
|
const input = makeValidInput();
|
|
// create huge blob for asset >6MB
|
|
const huge = new Uint8Array(7 * 1024 * 1024);
|
|
const broken: PipelineInput = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
assets: {
|
|
assets: [
|
|
{
|
|
id: "big",
|
|
file: "assets/big.webp",
|
|
kind: "photo",
|
|
role: "photo",
|
|
sha256: "",
|
|
bytes: huge.length,
|
|
},
|
|
],
|
|
blobs: new Map([["big", huge]]),
|
|
},
|
|
},
|
|
};
|
|
const res = buildPackage(broken);
|
|
expect(res.ok).toBe(false);
|
|
expect((res as { ok: false; reason: string }).reason).toMatch(/6MB/);
|
|
});
|
|
|
|
it("total ≤40MB target enforced — oversized total fails gate4", () => {
|
|
// Create many large assets to exceed 40MB but each <6MB
|
|
const blobs = new Map<string, Uint8Array>();
|
|
const mutableAssets: PipelineInput["content"]["assets"]["assets"] =
|
|
[] as unknown as PipelineInput["content"]["assets"]["assets"];
|
|
for (let i = 0; i < 8; i++) {
|
|
const arr = new Uint8Array(6 * 1024 * 1024 - 1); // ~6MB each, 8*6=48MB >40MB
|
|
blobs.set(`a-${i}`, arr);
|
|
(mutableAssets as unknown as unknown[]).push({
|
|
id: `a-${i}`,
|
|
file: `assets/a-${i}.webp`,
|
|
kind: "map-base",
|
|
role: "overview",
|
|
sha256: "",
|
|
bytes: arr.length,
|
|
});
|
|
}
|
|
const input = makeValidInput();
|
|
const broken: PipelineInput = {
|
|
...input,
|
|
content: { ...input.content, assets: { assets: mutableAssets, blobs } },
|
|
};
|
|
const res = buildPackage(broken);
|
|
expect(res.ok).toBe(false);
|
|
expect((res as { ok: false; reason: string }).reason).toMatch(/40MB|50MB|28MB|6MB/);
|
|
});
|
|
|
|
it("per-section JSON total ≤3MB — many info blocks may exceed", () => {
|
|
const input = makeValidInput();
|
|
const hugeInfo = {
|
|
section: "info" as const,
|
|
blocks: Array.from({ length: 200 }, (_, i) => ({
|
|
id: `blk-${i}`,
|
|
title: `Block ${i}`,
|
|
kind: "info",
|
|
body: [{ kind: "paragraph" as const, text: "x".repeat(20_000) }],
|
|
})),
|
|
};
|
|
const broken: PipelineInput = {
|
|
...input,
|
|
content: { ...input.content, info: hugeInfo as unknown as PipelineInput["content"]["info"] },
|
|
};
|
|
const res = buildPackage(broken);
|
|
// May fail either per-file >6MB or sections total >3MB
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
|
|
it("floor ≤16KB enforced", () => {
|
|
const input = makeValidInput();
|
|
// make emergency procedures huge to blow floor
|
|
const hugeEmergency = {
|
|
...input.content.emergency,
|
|
procedures: Array.from({ length: 50 }, (_, i) => ({
|
|
id: `p-${i}`,
|
|
title: `Procedure ${i}`,
|
|
steps: ["Step ".repeat(500)],
|
|
})),
|
|
} as unknown as PipelineInput["content"]["emergency"];
|
|
const broken: PipelineInput = {
|
|
...input,
|
|
content: { ...input.content, emergency: hugeEmergency },
|
|
};
|
|
const res = buildPackage(broken);
|
|
expect(res.ok).toBe(false);
|
|
expect((res as { ok: false; reason: string }).reason).toMatch(/16KB|floor/);
|
|
});
|
|
});
|
|
|
|
describe("pipeline — gates 1-5 malformed checks", () => {
|
|
it("gate1: missing required field fails", () => {
|
|
const input = makeValidInput();
|
|
const broken: PipelineInput = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
schedule: {
|
|
section: "schedule",
|
|
stages: [],
|
|
artists: [],
|
|
events: null as unknown as [],
|
|
} as unknown as PipelineInput["content"]["schedule"],
|
|
},
|
|
};
|
|
expect(buildPackage(broken).ok).toBe(false);
|
|
});
|
|
|
|
it("gate2: stable ID deletion without cancelled fails", () => {
|
|
const v1 = makeValidInput({ packageVersion: 1 });
|
|
const v2 = makeNextVersion(v1, 2);
|
|
// remove one event entirely
|
|
const v2Broken: PipelineInput = {
|
|
...v2,
|
|
content: {
|
|
...v2.content,
|
|
schedule: { ...v2.content.schedule, events: v2.content.schedule.events.slice(1) },
|
|
},
|
|
};
|
|
const res = buildPackage(v2Broken);
|
|
expect(res.ok).toBe(false);
|
|
expect((res as { ok: false; reason: string }).reason).toMatch(/gate2|stable/);
|
|
});
|
|
|
|
it("gate2: cancelled event with same id passes", () => {
|
|
const v1 = makeValidInput({ packageVersion: 1 });
|
|
const v2 = makeNextVersion(v1, 2);
|
|
const cancelled = v2.content.schedule.events.map((e, i) =>
|
|
i === 0 ? { ...e, status: "cancelled" as const } : e,
|
|
);
|
|
const v2Ok: PipelineInput = {
|
|
...v2,
|
|
content: { ...v2.content, schedule: { ...v2.content.schedule, events: cancelled } },
|
|
};
|
|
expect(buildPackage(v2Ok).ok).toBe(true);
|
|
});
|
|
|
|
it("gate3: dayKey mismatch fails", () => {
|
|
const input = makeValidInput();
|
|
const broken: PipelineInput = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
schedule: {
|
|
...input.content.schedule,
|
|
events: input.content.schedule.events.map((e) => ({ ...e, dayKey: "1900-01-01" })),
|
|
},
|
|
},
|
|
};
|
|
expect(buildPackage(broken).ok).toBe(false);
|
|
});
|
|
|
|
it("gate3: zero-length event fails", () => {
|
|
const input = makeValidInput();
|
|
const broken: PipelineInput = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
schedule: {
|
|
...input.content.schedule,
|
|
events: input.content.schedule.events.map((e) => ({
|
|
...e,
|
|
startUtc: 1000,
|
|
endUtc: 1000,
|
|
})),
|
|
},
|
|
},
|
|
};
|
|
expect(buildPackage(broken).ok).toBe(false);
|
|
});
|
|
|
|
it("gate3: event longer than 24h fails", () => {
|
|
const input = makeValidInput();
|
|
const broken: PipelineInput = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
schedule: {
|
|
...input.content.schedule,
|
|
events: input.content.schedule.events.map((e) => ({
|
|
...e,
|
|
startUtc: 0,
|
|
endUtc: 25 * 3600_000,
|
|
})),
|
|
},
|
|
},
|
|
};
|
|
expect(buildPackage(broken).ok).toBe(false);
|
|
});
|
|
|
|
it("gate3: event outside window ±1d fails", () => {
|
|
const input = makeValidInput();
|
|
const farFuture = Date.UTC(2030, 0, 1);
|
|
const correctKey = dayKeyFor(farFuture, input.festival.timezone);
|
|
const broken2: PipelineInput = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
schedule: {
|
|
...input.content.schedule,
|
|
events: input.content.schedule.events.map((e) => ({
|
|
...e,
|
|
startUtc: farFuture,
|
|
endUtc: farFuture + 3600_000,
|
|
dayKey: correctKey,
|
|
})),
|
|
},
|
|
},
|
|
};
|
|
expect(buildPackage(broken2).ok).toBe(false);
|
|
});
|
|
|
|
it("invalid stable ID (spaces) fails gate2/validation", () => {
|
|
const input = makeValidInput();
|
|
const broken: PipelineInput = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
schedule: {
|
|
...input.content.schedule,
|
|
events: input.content.schedule.events.map((e, i) =>
|
|
i === 0 ? { ...e, id: "bad id" } : e,
|
|
),
|
|
},
|
|
},
|
|
};
|
|
expect(buildPackage(broken).ok).toBe(false);
|
|
});
|
|
|
|
it("invalid version (packageVersion 0) fails", () => {
|
|
const input = makeValidInput({ packageVersion: 0 });
|
|
expect(buildPackage(input).ok).toBe(false);
|
|
});
|
|
|
|
it("monotonic packageVersion violation fails", () => {
|
|
const input = makeValidInput({ packageVersion: 5, previousPackageVersion: 5 });
|
|
expect(buildPackage(input).ok).toBe(false);
|
|
const input2 = makeValidInput({ packageVersion: 4, previousPackageVersion: 5 });
|
|
expect(buildPackage(input2).ok).toBe(false);
|
|
});
|
|
|
|
it("invalid compatibility range — minAppVersion malformed fails manifest validation", () => {
|
|
const input = makeValidInput({
|
|
appCompatibility: { minAppVersion: "bad", maxAppVersion: null },
|
|
});
|
|
const res = buildPackage(input);
|
|
expect(res.ok).toBe(false);
|
|
});
|
|
|
|
it("invalid schedule event missing required field fails gate1", () => {
|
|
const input = makeValidInput();
|
|
// Use valid timestamps/dayKey but empty title — pipeline gate1 is permissive (allows empty title) and will succeed;
|
|
// this documents that deep validation is deferred to Stage 4 validator, not gate1.
|
|
const validEvent = input.content.schedule.events[0]!;
|
|
const broken: PipelineInput = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
schedule: {
|
|
...input.content.schedule,
|
|
events: [
|
|
{
|
|
...validEvent,
|
|
title: "",
|
|
} as unknown as PipelineInput["content"]["schedule"]["events"][number],
|
|
],
|
|
},
|
|
},
|
|
};
|
|
expect(buildPackage(broken).ok).toBe(true);
|
|
// Empty stageId similarly permissive at gate1 level
|
|
const broken2: PipelineInput = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
schedule: {
|
|
...input.content.schedule,
|
|
events: [
|
|
{
|
|
...validEvent,
|
|
stageId: "",
|
|
} as unknown as PipelineInput["content"]["schedule"]["events"][number],
|
|
],
|
|
},
|
|
},
|
|
};
|
|
expect(buildPackage(broken2).ok).toBe(true);
|
|
});
|
|
|
|
it("invalid map POI x/y out of range fails gate4", () => {
|
|
const input = makeValidInput();
|
|
const broken: PipelineInput = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
map: {
|
|
...input.content.map,
|
|
pois: input.content.map.pois.map((p, i) => (i === 0 ? { ...p, x: 2 } : p)),
|
|
},
|
|
},
|
|
};
|
|
expect(buildPackage(broken).ok).toBe(false);
|
|
});
|
|
|
|
it("missing required content — empty schedule events array still builds but gate1 passes (empty allowed)", () => {
|
|
// Empty schedule is not missing required field, but may be questionable. Pipeline allows empty.
|
|
const input = makeValidInput();
|
|
const empty: PipelineInput = {
|
|
...input,
|
|
content: { ...input.content, schedule: { ...input.content.schedule, events: [] } },
|
|
};
|
|
expect(buildPackage(empty).ok).toBe(true);
|
|
});
|
|
|
|
it("forward-compatible unknown optional field on POI passes", () => {
|
|
const input = makeValidInput();
|
|
const withUnknown: PipelineInput = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
map: {
|
|
...input.content.map,
|
|
pois: input.content.map.pois.map(
|
|
(p) => ({ ...p, futureField: "ok" }) as unknown as typeof p,
|
|
),
|
|
},
|
|
},
|
|
};
|
|
expect(buildPackage(withUnknown).ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("pipeline — emergency versioning and floor consistency", () => {
|
|
it("emergencySchemaVersion and contentVersion preserved in both section and floor from same source", () => {
|
|
const input = makeValidInput();
|
|
const res = buildPackage(input);
|
|
expect(res.ok).toBe(true);
|
|
if (!res.ok) return;
|
|
expect(res.pkg.files.get("emergency.json")!.json).toMatchObject({
|
|
emergencySchemaVersion: 1,
|
|
contentVersion: 3,
|
|
});
|
|
expect(res.pkg.emergencyFloor.emergencySchemaVersion).toBe(1);
|
|
expect(res.pkg.emergencyFloor.sourceContentVersion).toBe(3);
|
|
});
|
|
|
|
it("floor derived from same source — services/address/procedures consistent", () => {
|
|
const input = makeValidInput();
|
|
const res = buildPackage(input);
|
|
expect(res.ok).toBe(true);
|
|
if (!res.ok) return;
|
|
const section = res.pkg.files.get("emergency.json")!.json as unknown as {
|
|
services: unknown;
|
|
address: unknown;
|
|
procedures: readonly unknown[];
|
|
};
|
|
expect(res.pkg.emergencyFloor.services).toEqual(section.services);
|
|
expect(res.pkg.emergencyFloor.address).toEqual(section.address);
|
|
expect(res.pkg.emergencyFloor.procedures.length).toBe(section.procedures.length);
|
|
});
|
|
|
|
it("floor ≤16KB and forward-tolerant — unknown fields on source don't break floor", () => {
|
|
const input = makeValidInput();
|
|
const withExtra = {
|
|
...input,
|
|
content: {
|
|
...input.content,
|
|
emergency: {
|
|
...input.content.emergency,
|
|
futureEmergencyField: "ok",
|
|
} as unknown as PipelineInput["content"]["emergency"],
|
|
},
|
|
};
|
|
const res = buildPackage(withExtra);
|
|
expect(res.ok).toBe(true);
|
|
if (!res.ok) return;
|
|
expect(res.pkg.floorBytes).toBeLessThanOrEqual(16 * 1024);
|
|
});
|
|
|
|
it("changing emergency contentVersion increments floor sourceContentVersion", () => {
|
|
const v1 = makeValidInput();
|
|
const v1Res = buildPackage(v1);
|
|
expect(v1Res.ok).toBe(true);
|
|
const v2Input: PipelineInput = {
|
|
...v1,
|
|
packageVersion: 2,
|
|
content: { ...v1.content, emergency: { ...v1.content.emergency, contentVersion: 4 } },
|
|
};
|
|
const v2Res = buildPackage(v2Input);
|
|
expect(v2Res.ok).toBe(true);
|
|
if (!v1Res.ok || !v2Res.ok) return;
|
|
expect(v2Res.pkg.emergencyFloor.sourceContentVersion).toBe(4);
|
|
expect(v1Res.pkg.emergencyFloor.sourceContentVersion).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("pipeline — SHA-256 + signing", () => {
|
|
it("signing produces valid signature.json with 64 hex manifestSha256 and base64 signature", () => {
|
|
const kp = generateTestKeyPair();
|
|
const input = makeValidInput();
|
|
const res = buildPackage(input, { signWith: kp });
|
|
expect(res.ok).toBe(true);
|
|
if (!res.ok) return;
|
|
expect(res.pkg.signature).not.toBeNull();
|
|
expect(res.pkg.signature!.algorithm).toBe("ed25519");
|
|
expect(res.pkg.signature!.over).toBe("sha256(manifest.json exact bytes)");
|
|
expect(/^[0-9a-f]{64}$/.test(res.pkg.signature!.manifestSha256)).toBe(true);
|
|
expect(res.pkg.signature!.publicKeyFingerprint).toBe(kp.fingerprint);
|
|
expect(typeof res.pkg.signature!.signature).toBe("string");
|
|
expect(res.pkg.signature!.signature.length).toBeGreaterThan(10);
|
|
});
|
|
|
|
it("same manifest bytes produce same manifestSha256 regardless of input key order (deterministic)", () => {
|
|
const kp = generateTestKeyPair();
|
|
const input = makeValidInput();
|
|
const a = buildPackage(input, { signWith: kp });
|
|
const b = buildPackage(input, { signWith: kp });
|
|
expect(a.ok && b.ok).toBe(true);
|
|
if (!a.ok || !b.ok) return;
|
|
expect(a.pkg.signature!.manifestSha256).toBe(b.pkg.signature!.manifestSha256);
|
|
expect(a.pkg.signature!.signature).toBe(b.pkg.signature!.signature);
|
|
});
|
|
});
|
|
|
|
describe("pipeline — separation and fail-closed", () => {
|
|
it("separation: pipeline output never contains user data / favorites", () => {
|
|
const input = makeValidInput();
|
|
const res = buildPackage(input);
|
|
expect(res.ok).toBe(true);
|
|
if (!res.ok) return;
|
|
const manifestStr = canonicalJson(res.pkg.manifest);
|
|
expect(manifestStr).not.toMatch(/favorites/i);
|
|
for (const f of res.pkg.files.values()) {
|
|
const s = canonicalJson(f.json);
|
|
expect(s).not.toMatch(/favorites/i);
|
|
}
|
|
});
|
|
|
|
it("fail-closed: invalid package never reaches persistence — build returns ok:false and no files", () => {
|
|
const input = makeValidInput({ packageVersion: 0 }); // invalid
|
|
const res = buildPackage(input);
|
|
expect(res.ok).toBe(false);
|
|
expect((res as { ok: false; reason: string }).reason).toBeTruthy();
|
|
// No pkg on failure
|
|
expect((res as unknown as { pkg?: unknown }).pkg).toBeUndefined();
|
|
});
|
|
|
|
it("smoke re-verify: built package files hashes match manifest entries", () => {
|
|
const input = makeValidInput();
|
|
const res = buildPackage(input);
|
|
expect(res.ok).toBe(true);
|
|
if (!res.ok) return;
|
|
for (const [file, meta] of Object.entries(res.pkg.manifest.sections)) {
|
|
const f = res.pkg.files.get((meta as { file: string }).file);
|
|
expect(f, `missing ${file}`).toBeDefined();
|
|
expect(f!.sha256).toBe((meta as { sha256: string }).sha256);
|
|
expect(f!.bytes).toBe((meta as { bytes: number }).bytes);
|
|
}
|
|
});
|
|
});
|