// node --test scripts/ci/lib/portable-publish-policy.test.mjs import assert from "node:assert/strict"; import { createHash } from "node:crypto"; import { test } from "node:test"; import { createForgejoGenericRegistry } from "./forgejo-generic-registry.mjs"; import { decideAliasUpdate, hashPayloads, parsePublishedIndexVersion, planAliasPackage, planVersionedPackage, publishPortablePackages, } from "./portable-publish-policy.mjs"; const sha = (text) => createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex"); const payload = (name, text, contentType = "application/octet-stream") => ({ name, bytes: Buffer.from(text, "utf8"), contentType, }); const runtimeIndex = (version, generatedAt = "2026-09-20T00:00:00.000Z") => payload( "runtime.json", JSON.stringify({ schemaVersion: 1, version, generatedAt }), "application/json", ); const portableIndex = (version) => payload("portable.json", JSON.stringify({ version }), "application/json"); /** 메모리 registry. packages: Map> */ function fakeRegistry(initial = {}) { const packages = new Map( Object.entries(initial).map(([path, files]) => [path, new Map(Object.entries(files))]), ); const calls = []; return { packages, calls, async listFileHashes(versionPath) { calls.push(["list", versionPath]); const files = packages.get(versionPath); return new Map([...(files ?? new Map())].map(([name, text]) => [name, sha(text)])); }, async deleteVersion(versionPath) { calls.push(["delete", versionPath]); packages.delete(versionPath); }, async uploadFile(versionPath, file) { calls.push(["upload", versionPath, file.name]); const files = packages.get(versionPath) ?? new Map(); if (files.has(file.name)) throw new Error(`409 conflict ${versionPath}/${file.name}`); files.set(file.name, Buffer.from(file.bytes).toString("utf8")); packages.set(versionPath, files); }, async readTextFile(versionPath, name) { calls.push(["read", versionPath, name]); return packages.get(versionPath)?.get(name); }, }; } const publish = (registry, version, runtime, portable) => publishPortablePackages({ version, registry, log: () => {}, packages: [ { kind: "runtime", indexName: "runtime.json", payloads: runtime }, { kind: "portable", indexName: "portable.json", payloads: portable }, ], }); // ── 순수 정책 ───────────────────────────────────────────────────────────── test("planVersionedPackage aborts when a published file has different bytes", () => { const items = hashPayloads([payload("d3ro-runtime-sidecar.tar.gz.001", "rebuilt")]); const plan = planVersionedPackage({ items, remoteHashes: new Map([["d3ro-runtime-sidecar.tar.gz.001", sha("original")]]), }); assert.equal(plan.action, "abort"); assert.deepEqual( plan.conflicts.map((item) => item.name), ["d3ro-runtime-sidecar.tar.gz.001"], ); }); test("planVersionedPackage resumes a partial upload with only the missing files, in order", () => { const items = hashPayloads([ payload("a.7z.001", "one"), payload("a.7z.002", "two"), portableIndex("1.9.0"), ]); const plan = planVersionedPackage({ items, remoteHashes: new Map([["a.7z.001", sha("one")]]) }); assert.equal(plan.action, "upload"); assert.deepEqual( plan.uploads.map((item) => item.name), ["a.7z.002", "portable.json"], ); }); test("planVersionedPackage skips when every file already matches", () => { const items = hashPayloads([payload("a.7z.001", "one")]); assert.equal( planVersionedPackage({ items, remoteHashes: new Map([["a.7z.001", sha("one")]]) }).action, "skip", ); }); test("planAliasPackage replaces the whole alias when any file differs", () => { const items = hashPayloads([payload("x", "new")]); assert.deepEqual(planAliasPackage({ items, remoteHashes: new Map([["x", sha("old")]]) }), { action: "replace", deleteFirst: true, }); assert.deepEqual(planAliasPackage({ items, remoteHashes: new Map() }), { action: "replace", deleteFirst: false, }); assert.equal(planAliasPackage({ items, remoteHashes: new Map([["x", sha("new")]]) }).action, "skip"); }); test("parsePublishedIndexVersion reads a semver version or returns null", () => { assert.equal(parsePublishedIndexVersion('{"version":"1.9.1"}'), "1.9.1"); assert.equal(parsePublishedIndexVersion('{"version":"v2.0.0-beta.1"}'), "2.0.0-beta.1"); assert.equal(parsePublishedIndexVersion('{"schemaVersion":2}'), null); assert.equal(parsePublishedIndexVersion(""), null); assert.equal(parsePublishedIndexVersion("null"), null); }); test("decideAliasUpdate skips older versions and fails closed on an unreadable version", () => { assert.deepEqual(decideAliasUpdate({ publishingVersion: "1.9.0", publishedVersion: "1.9.1" }), { update: false, reason: "newer-published", abort: false, }); assert.equal(decideAliasUpdate({ publishingVersion: "1.9.1", publishedVersion: "1.9.0" }).update, true); assert.equal(decideAliasUpdate({ publishingVersion: "1.9.0", publishedVersion: undefined }).update, true); assert.deepEqual(decideAliasUpdate({ publishingVersion: "1.9.0", publishedVersion: null }), { update: false, abort: true, reason: "unreadable-feed", }); }); // ── 유스케이스: 레드팀 회귀 ──────────────────────────────────────────────── test("re-run with rebuilt runtime bytes never deletes runtime- nor touches runtime-latest", async () => { const published = { "runtime-1.9.0": { "d3ro-runtime-sidecar.tar.gz.001": "sidecar-original", "runtime.json": runtimeIndex("1.9.0").bytes.toString("utf8"), }, "runtime-latest": { "d3ro-runtime-sidecar.tar.gz.001": "sidecar-original", "runtime.json": runtimeIndex("1.9.0").bytes.toString("utf8"), }, }; const registry = fakeRegistry(published); await assert.rejects( publish( registry, "1.9.0", [payload("d3ro-runtime-sidecar.tar.gz.001", "sidecar-rebuilt"), runtimeIndex("1.9.0", "2026-09-21T00:00:00.000Z")], [payload("D3RO-Voice-1.9.0.7z.001", "vol"), portableIndex("1.9.0")], ), /runtime-1\.9\.0/, ); assert.equal(registry.calls.some(([op]) => op === "delete" || op === "upload"), false); assert.equal( registry.packages.get("runtime-1.9.0").get("d3ro-runtime-sidecar.tar.gz.001"), "sidecar-original", ); }); test("re-run with rebuilt portable volumes never replaces portable-", async () => { const registry = fakeRegistry({ "portable-1.9.0": { "D3RO-Voice-1.9.0.7z.001": "scoop-pinned" }, }); await assert.rejects( publish(registry, "1.9.0", [], [payload("D3RO-Voice-1.9.0.7z.001", "rebuilt"), portableIndex("1.9.0")]), /portable-1\.9\.0/, ); assert.equal(registry.calls.some(([op]) => op === "delete" || op === "upload"), false); }); test("re-run after a partial upload resumes the versioned package, then publishes the aliases", async () => { const registry = fakeRegistry({ "runtime-1.9.0": { "d3ro-runtime-sidecar.tar.gz.001": "p1" }, }); const result = await publish( registry, "1.9.0", [payload("d3ro-runtime-sidecar.tar.gz.001", "p1"), payload("d3ro-runtime-sidecar.tar.gz.002", "p2"), runtimeIndex("1.9.0")], [payload("D3RO-Voice-1.9.0.7z.001", "vol"), portableIndex("1.9.0")], ); assert.deepEqual(result.versioned, { runtime: "resumed", portable: "uploaded" }); assert.deepEqual(result.aliases, { runtime: "replaced", portable: "replaced" }); assert.equal(registry.calls.some(([op, path]) => op === "delete" && path.endsWith("-1.9.0")), false); assert.equal(registry.packages.get("runtime-latest").get("runtime.json"), runtimeIndex("1.9.0").bytes.toString("utf8")); }); test("versioned packages are all complete before any alias is touched", async () => { const registry = fakeRegistry(); await publish( registry, "1.9.1", [payload("d3ro-runtime-sidecar.tar.gz.001", "p"), runtimeIndex("1.9.1")], [payload("D3RO-Voice-1.9.1.7z.001", "v"), portableIndex("1.9.1")], ); const firstAliasCall = registry.calls.findIndex(([, path]) => path.endsWith("-latest")); const lastVersionedUpload = registry.calls.findLastIndex( ([op, path]) => op === "upload" && path.endsWith("-1.9.1"), ); assert.ok(firstAliasCall > lastVersionedUpload); }); test("an older tag re-run does not roll runtime-latest / portable-latest back", async () => { const newerRuntime = runtimeIndex("1.9.1").bytes.toString("utf8"); const newerPortable = portableIndex("1.9.1").bytes.toString("utf8"); const registry = fakeRegistry({ "runtime-latest": { "d3ro-runtime-sidecar.tar.gz.001": "p191", "runtime.json": newerRuntime }, "portable-latest": { "D3RO-Voice-1.9.1.7z.001": "v191", "portable.json": newerPortable }, }); const result = await publish( registry, "1.9.0", [payload("d3ro-runtime-sidecar.tar.gz.001", "p190"), runtimeIndex("1.9.0")], [payload("D3RO-Voice-1.9.0.7z.001", "v190"), portableIndex("1.9.0")], ); assert.deepEqual(result.aliases, { runtime: "newer-published", portable: "newer-published" }); assert.equal(registry.calls.some(([op, path]) => op !== "read" && op !== "list" && path.endsWith("-latest")), false); assert.equal(registry.packages.get("runtime-latest").get("runtime.json"), newerRuntime); assert.equal(registry.packages.get("portable-latest").get("portable.json"), newerPortable); }); test("an alias whose published version cannot be read is left untouched (fail-closed)", async () => { const registry = fakeRegistry({ "runtime-latest": { "runtime.json": '{"schemaVersion":2,"release":"2.0.0"}' }, }); await assert.rejects( publish(registry, "1.9.0", [payload("d3ro-runtime-sidecar.tar.gz.001", "p"), runtimeIndex("1.9.0")], []), /runtime-latest\/runtime\.json/, ); assert.equal(registry.calls.some(([op, path]) => op === "delete" && path === "runtime-latest"), false); }); test("a newer version replaces the alias atomically (delete then full upload)", async () => { const registry = fakeRegistry({ "runtime-latest": { "d3ro-runtime-sidecar.tar.gz.001": "p190", "runtime.json": runtimeIndex("1.9.0").bytes.toString("utf8") }, }); const result = await publish( registry, "1.9.1", [payload("d3ro-runtime-sidecar.tar.gz.001", "p191"), runtimeIndex("1.9.1")], [], ); assert.equal(result.aliases.runtime, "replaced"); assert.equal(registry.packages.get("runtime-latest").get("d3ro-runtime-sidecar.tar.gz.001"), "p191"); }); test("dry run performs no registry IO", async () => { const registry = fakeRegistry(); const lines = []; await publishPortablePackages({ version: "1.9.0", registry, dryRun: true, log: (line) => lines.push(line), packages: [{ kind: "portable", indexName: "portable.json", payloads: [portableIndex("1.9.0")] }], }); assert.equal(registry.calls.length, 0); assert.equal(lines.length, 2); }); // ── IO 어댑터 ───────────────────────────────────────────────────────────── function fakeFetch(routes) { const seen = []; const fetchImpl = async (url, init = {}) => { seen.push({ url, method: init.method ?? "GET" }); const key = `${init.method ?? "GET"} ${url.replace(/\?ts=\d+$/, "")}`; const route = routes[key]; if (!route) return new Response("not found", { status: 404 }); return new Response(route.body ?? "", { status: route.status ?? 200 }); }; return { fetchImpl, seen }; } const FEED = "https://feed.test/generic/d3ro-voice"; const API = "https://feed.test/api/v1/packages/d3ro-voice"; test("registry adapter maps 404 to empty/undefined and fails closed on other errors", async () => { const { fetchImpl } = fakeFetch({ [`GET ${API}/runtime-1.9.0/files`]: { body: JSON.stringify([{ name: "a", sha256: "h" }]) }, [`GET ${API}/runtime-latest/files`]: { status: 500 }, [`GET ${FEED}/runtime-latest/runtime.json`]: { status: 524 }, }); const registry = createForgejoGenericRegistry({ feedUrl: FEED, packageApiUrl: API, fetchImpl }); assert.deepEqual([...(await registry.listFileHashes("runtime-1.9.0"))], [["a", "h"]]); assert.equal((await registry.listFileHashes("portable-1.9.0")).size, 0); await assert.rejects(registry.listFileHashes("runtime-latest"), /HTTP 500/); assert.equal(await registry.readTextFile("portable-latest", "portable.json"), undefined); await assert.rejects(registry.readTextFile("runtime-latest", "runtime.json"), /HTTP 524/); }); test("registry adapter throws on a failed upload instead of exiting", async () => { const { fetchImpl } = fakeFetch({ [`PUT ${FEED}/portable-1.9.0/a.7z.001`]: { status: 413 } }); const registry = createForgejoGenericRegistry({ feedUrl: FEED, packageApiUrl: API, fetchImpl }); await assert.rejects( registry.uploadFile("portable-1.9.0", { ...payload("a.7z.001", "x"), sha256: sha("x") }), /HTTP 413/, ); }); test("the portable publisher routes every registry write through the policy use case", async () => { const { readFileSync } = await import("node:fs"); const source = readFileSync(new URL("../publish-portable-release.mjs", import.meta.url), "utf8"); assert.match(source, /publishPortablePackages\(/); assert.doesNotMatch(source, /method:\s*['"](?:PUT|DELETE)['"]/); assert.doesNotMatch(source, /async function publishBase/); });