// node --test scripts/ci/lib/immutable-package-guard.test.mjs import assert from "node:assert/strict"; import { test } from "node:test"; import { assertNoImmutableConflicts, classifyImmutableAssets, readRemotePackageDigests, uploadImmutableAsset, } from "./immutable-package-guard.mjs"; const SHA_A = "a".repeat(64); const SHA_B = "b".repeat(64); const FILES_API = "https://git.example.test/api/v1/packages/o/generic/d3ro-voice/1.9.1/files"; const ASSET_URL = "https://git.example.test/api/packages/o/generic/d3ro-voice/1.9.1/D3RO-Voice-Setup-1.9.1.exe"; /** * Forgejo generic registry의 실측 동작을 흉내 낸다: * HEAD → 405, 이미 있는 경로에 PUT → 409, 파일 목록 API → sha256. */ function fakeRegistry(existing) { const calls = []; const store = new Map(Object.entries(existing)); const fetchImpl = async (url, init = {}) => { const method = init.method ?? "GET"; calls.push({ method, url }); if (method === "HEAD") return new Response(null, { status: 405 }); if (url.endsWith("/files")) { if (store.size === 0) return new Response("not found", { status: 404 }); const body = [...store.entries()].map(([name, sha256]) => ({ name, sha256 })); return new Response(JSON.stringify(body), { status: 200 }); } const name = decodeURIComponent(url.split("/").pop()); if (method === "PUT") { if (store.has(name)) return new Response("conflict", { status: 409 }); store.set(name, SHA_A); return new Response(null, { status: 201 }); } if (method === "DELETE") { store.delete(name); return new Response(null, { status: 204 }); } return new Response("unexpected", { status: 500 }); }; return { fetchImpl, calls, store }; } test("classifyImmutableAssets splits new, identical and conflicting assets by sha256", () => { const plan = classifyImmutableAssets({ localFiles: [ { name: "new.exe", sha256: SHA_A }, { name: "same.exe", sha256: SHA_A.toUpperCase() }, { name: "resigned.exe", sha256: SHA_A }, ], remoteDigests: new Map([ ["same.exe", SHA_A], ["resigned.exe", SHA_B], ]), }); assert.deepEqual(plan.upload.map((file) => file.name), ["new.exe"]); assert.deepEqual(plan.identical.map((file) => file.name), ["same.exe"]); assert.deepEqual(plan.conflicting, [{ name: "resigned.exe", sha256: SHA_A, remoteSha256: SHA_B }]); }); test("same-size re-signed installer is a conflict, not 'verified existing'", () => { // 예전 가드는 content-length만 비교했다: 같은 크기의 재서명 바이너리를 통과시켰다. const plan = classifyImmutableAssets({ localFiles: [{ name: "D3RO-Voice-Setup-1.9.1.exe", sha256: SHA_A }], remoteDigests: new Map([["D3RO-Voice-Setup-1.9.1.exe", SHA_B]]), }); assert.equal(plan.conflicting.length, 1); assert.throws( () => assertNoImmutableConflicts({ tag: "v1.9.1", conflicting: plan.conflicting }), /v1\.9\.1 is already published with different bytes.*Releases are immutable/, ); }); test("assertNoImmutableConflicts is a no-op when nothing conflicts", () => { assert.doesNotThrow(() => assertNoImmutableConflicts({ tag: "v1.9.1", conflicting: [] })); }); test("readRemotePackageDigests detects published assets even though the registry rejects HEAD", async () => { const registry = fakeRegistry({ "D3RO-Voice-Setup-1.9.1.exe": SHA_B.toUpperCase() }); const digests = await readRemotePackageDigests({ filesApiUrl: FILES_API, fetchImpl: registry.fetchImpl }); assert.equal(digests.get("D3RO-Voice-Setup-1.9.1.exe"), SHA_B); assert.ok(registry.calls.every((call) => call.method !== "HEAD")); }); test("readRemotePackageDigests treats 404 as an unpublished version", async () => { const registry = fakeRegistry({}); const digests = await readRemotePackageDigests({ filesApiUrl: FILES_API, fetchImpl: registry.fetchImpl }); assert.equal(digests.size, 0); }); test("readRemotePackageDigests fails closed on other HTTP errors and bad payloads", async () => { await assert.rejects( readRemotePackageDigests({ filesApiUrl: FILES_API, fetchImpl: async () => new Response("boom", { status: 502 }), }), /HTTP 502/, ); await assert.rejects( readRemotePackageDigests({ filesApiUrl: FILES_API, fetchImpl: async () => new Response(JSON.stringify({ oops: true }), { status: 200 }), }), /expected an array/, ); }); test("uploadImmutableAsset uploads a new asset with a single PUT", async () => { const registry = fakeRegistry({}); const outcome = await uploadImmutableAsset({ url: ASSET_URL, name: "D3RO-Voice-Setup-1.9.1.exe", sha256: SHA_A, body: "bytes", fetchImpl: registry.fetchImpl, readRemoteDigest: async () => undefined, }); assert.equal(outcome, "uploaded"); assert.deepEqual(registry.calls.map((call) => call.method), ["PUT"]); }); test("uploadImmutableAsset never DELETEs an existing immutable asset on 409 (re-run with re-signed bytes)", async () => { const registry = fakeRegistry({ "D3RO-Voice-Setup-1.9.1.exe": SHA_B }); await assert.rejects( uploadImmutableAsset({ url: ASSET_URL, name: "D3RO-Voice-Setup-1.9.1.exe", sha256: SHA_A, body: "resigned-bytes", fetchImpl: registry.fetchImpl, readRemoteDigest: async () => SHA_B, }), /HTTP 409.*never overwritten/, ); assert.ok(registry.calls.every((call) => call.method !== "DELETE")); assert.equal(registry.store.get("D3RO-Voice-Setup-1.9.1.exe"), SHA_B); }); test("uploadImmutableAsset accepts a 409 when the remote bytes are identical (concurrent retry)", async () => { const registry = fakeRegistry({ "D3RO-Voice-Setup-1.9.1.exe": SHA_A }); const outcome = await uploadImmutableAsset({ url: ASSET_URL, name: "D3RO-Voice-Setup-1.9.1.exe", sha256: SHA_A, body: "bytes", fetchImpl: registry.fetchImpl, readRemoteDigest: async () => SHA_A.toUpperCase(), }); assert.equal(outcome, "verified-existing"); assert.ok(registry.calls.every((call) => call.method !== "DELETE")); }); test("uploadImmutableAsset fails closed on 409 when the remote digest cannot be read", async () => { const registry = fakeRegistry({ "D3RO-Voice-Setup-1.9.1.exe": SHA_A }); await assert.rejects( uploadImmutableAsset({ url: ASSET_URL, name: "D3RO-Voice-Setup-1.9.1.exe", sha256: SHA_A, body: "bytes", fetchImpl: registry.fetchImpl, readRemoteDigest: async () => undefined, }), /\(unknown\)/, ); assert.ok(registry.calls.every((call) => call.method !== "DELETE")); }); test("uploadImmutableAsset surfaces non-409 failures", async () => { await assert.rejects( uploadImmutableAsset({ url: ASSET_URL, name: "x.exe", sha256: SHA_A, body: "bytes", fetchImpl: async () => new Response("too large", { status: 413 }), readRemoteDigest: async () => undefined, }), /HTTP 413 too large/, ); });