import { createHash } from "node:crypto"; import { describe, expect, it } from "vitest"; import { resolveServiceWorkerBuildInput } from "../../scripts/lib/service-worker-build-input.ts"; import { canonicalStaticManifestBytes, type StaticAssetRow, } from "../../src/contracts/service-worker-static-manifest.ts"; const digest = (character: string) => `sha256:${character.repeat(64)}`; /** SW-05. The gate recomputes this from the shared canonical bytes. */ function setDigestFor(rows: readonly StaticAssetRow[]): string { return `sha256:${createHash("sha256") .update(canonicalStaticManifestBytes(rows)) .digest("hex")}`; } const ASSET_ROWS: readonly StaticAssetRow[] = Object.freeze([ Object.freeze({ url: "/assets/app.0123456789abcdef.js", sha256: digest("c"), bytes: 128, contentType: "text/javascript", }), ]); describe("service worker build input", () => { const selection = { mode: "ACTIVE" as const, scriptPath: "service-worker.js" as const, handlers: ["PWA_STATIC_ASSETS" as const], }; const assets = { schemaVersion: 1 as const, buildId: "build-1", releaseId: "release-1", setDigest: setDigestFor(ASSET_ROWS), assets: [...ASSET_ROWS], }; it("rejects a direct worker build when ACTIVE selection or generated inputs are absent", () => { expect(() => resolveServiceWorkerBuildInput({ selection: null, assets, contractSet: { setDigest: digest("b") }, runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, buildId: "build-1", releaseId: "release-1", }), ).toThrow(/ACTIVE/u); expect(() => resolveServiceWorkerBuildInput({ selection, assets: null, contractSet: { setDigest: digest("b") }, runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, buildId: "build-1", releaseId: "release-1", }), ).toThrow(/asset manifest/u); }); it("rejects stale generated identity instead of compiling a mismatched worker", () => { expect(() => resolveServiceWorkerBuildInput({ selection, assets: { ...assets, buildId: "old-build" }, contractSet: { setDigest: digest("b") }, runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, buildId: "build-1", releaseId: "release-1", }), ).toThrow(/identity/u); }); it("rejects WEB_PUSH selection until its product-owned worker contribution exists", () => { expect(() => resolveServiceWorkerBuildInput({ selection: { ...selection, handlers: ["WEB_PUSH"] }, assets, contractSet: { setDigest: digest("b") }, runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, buildId: "build-1", releaseId: "release-1", }), ).toThrow(/WEB_PUSH.*contribution/u); }); it("returns only fully matched, digest-bearing generated inputs", () => { expect( resolveServiceWorkerBuildInput({ selection, assets, contractSet: { setDigest: digest("b") }, runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, buildId: "build-1", releaseId: "release-1", }), ).toMatchObject({ assets, handlers: ["PWA_STATIC_ASSETS"], contractSetDigest: digest("b"), releaseManifestUrl: "/release-manifest.json", }); }); it("rejects asset row or canonical set-digest tampering at build input", () => { const base = { selection, contractSet: { setDigest: digest("b") }, runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, buildId: "build-1", releaseId: "release-1", }; const tampered: readonly Readonly<{ label: string; assets: unknown; }>[] = [ { label: "stale set digest", assets: { ...assets, setDigest: digest("a") }, }, { label: "tampered byte length", assets: { ...assets, assets: [{ ...ASSET_ROWS[0]!, bytes: 129 }], }, }, { label: "cross-origin url", assets: { ...assets, assets: [ { ...ASSET_ROWS[0]!, url: "https://evil.example/a.js" }, ], }, }, { label: "dot segment", assets: { ...assets, assets: [{ ...ASSET_ROWS[0]!, url: "/assets/../a.js" }], }, }, { label: "extension and content type mismatch", assets: { ...assets, assets: [{ ...ASSET_ROWS[0]!, contentType: "text/css" }], }, }, { label: "unknown row field", assets: { ...assets, assets: [{ ...ASSET_ROWS[0]!, extra: "smuggled" }], }, }, { label: "duplicate url", assets: { ...assets, assets: [ASSET_ROWS[0]!, ASSET_ROWS[0]!], }, }, ]; for (const entry of tampered) { expect( () => resolveServiceWorkerBuildInput({ ...base, assets: entry.assets as never, }), entry.label, ).toThrow(TypeError); } }); it("accepts generator-shaped output unchanged", () => { expect(() => resolveServiceWorkerBuildInput({ selection, assets, contractSet: { setDigest: digest("b") }, runtimeConfig: { RELEASE_MANIFEST_URL: "/release-manifest.json" }, buildId: "build-1", releaseId: "release-1", }), ).not.toThrow(); }); });