184 lines
6.8 KiB
JavaScript
184 lines
6.8 KiB
JavaScript
// 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/,
|
|
);
|
|
});
|