322 lines
14 KiB
JavaScript
322 lines
14 KiB
JavaScript
// 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<versionPath, Map<name, text>> */
|
|
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("<html>"), 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-<version> 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-<version>", 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/);
|
|
});
|