import { execFile } from "node:child_process"; import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { type LocalTrain, loadReleaseTrain, normalizeRegistry, type PublicationRecord, PUBLISH_TOKEN_VARIABLE, packumentUrl, publicationOrder, publishPackageTrain, } from "../scripts/publish-package-train.js"; /** * The publication tool against a disposable Gitea 1.27.2 (scripts/test-package-publication.sh), * configured as the real registry is meant to be: public package owners anyone may read from, and a * publisher team that alone may publish. Each scenario uses its own owner, so they never interfere. */ const execFileAsync = promisify(execFile); /** Set by scripts/test-package-publication.sh for the disposable registry it started. */ const setting = (name: string): string => process.env[name] ?? ""; const origin = setting("EKKLESIA_TEST_REGISTRY_ORIGIN"); const releaseDirectory = setting("EKKLESIA_TEST_RELEASE"); const publishToken = setting("EKKLESIA_TEST_PUBLISH_TOKEN"); const outsiderToken = setting("EKKLESIA_TEST_OUTSIDER_TOKEN"); const [mainOwner, burnedOwner, tagOwner, layoutOwner] = setting("EKKLESIA_TEST_OWNERS").split(" "); if (!origin || !releaseDirectory || !publishToken || !outsiderToken || !layoutOwner) { throw new Error("Run this suite with scripts/test-package-publication.sh."); } const registryOf = (owner: string | undefined) => normalizeRegistry(`${origin}/api/packages/${owner}/npm/`, true); let train: LocalTrain; let order: string[]; const scratch: string[] = []; beforeAll(async () => { const manifest = JSON.parse( await readFile(path.join(releaseDirectory, "ekklesia-package-train.json"), "utf8"), ) as { version: string; source: { revision: string } }; train = await loadReleaseTrain(releaseDirectory, { revision: manifest.source.revision, version: manifest.version, }); order = publicationOrder(train.packages).map((entry) => entry.entry.name); }); afterAll(async () => { await Promise.all(scratch.splice(0).map((dir) => rm(dir, { force: true, recursive: true }))); }); function publish( owner: string | undefined, hooks?: Parameters[0]["hooks"], ) { return publishPackageTrain({ allowHttp: true, registry: registryOf(owner), releaseDirectory, requirePublicRead: true, revision: train.manifest.source.revision, token: publishToken, version: train.manifest.version, ...(hooks === undefined ? {} : { hooks }), }); } /** What anyone, signed in or not, gets for a package: its packument, or undefined on 404. */ async function anonymousPackument(owner: string | undefined, name: string) { const response = await fetch(packumentUrl(registryOf(owner), name)); if (response.status === 404) return undefined; expect(response.status).toBe(200); return (await response.json()) as { versions: Record; "dist-tags": Record; }; } async function anonymousBytes(url: string): Promise { const response = await fetch(url); expect(response.status).toBe(200); return Buffer.from(await response.arrayBuffer()); } function local(name: string) { const found = train.packages.find((entry) => entry.entry.name === name); if (!found) throw new Error(name); return found; } /** `npm publish ` outside the tool, as an operator or an attacker might run it. */ async function npmPublish(owner: string | undefined, file: string, token: string): Promise { const home = await mkdtemp(path.join(tmpdir(), "ekklesia-npm-")); scratch.push(home); const registry = registryOf(owner); const url = new URL(registry); await writeFile( path.join(home, "npmrc"), `registry=${registry}\n//${url.host}${url.pathname}:_authToken=\${${PUBLISH_TOKEN_VARIABLE}}\n`, ); try { await execFileAsync( "npm", ["publish", file, "--tag", "manual", "--access", "public", "--ignore-scripts"], { cwd: home, env: { [PUBLISH_TOKEN_VARIABLE]: token, HOME: home, PATH: process.env.PATH ?? "", npm_config_globalconfig: path.join(home, "none"), npm_config_userconfig: path.join(home, "npmrc"), }, }, ); return "published"; } catch (error) { return String((error as { stderr?: unknown }).stderr ?? "failed"); } } describe("the registry's access model", () => { it("lets anyone search and read package metadata, with no account at all", async () => { const search = await fetch(`${registryOf(mainOwner)}-/v1/search?text=ekklesia`); expect(search.status).toBe(200); // An absent package is 404 to an anonymous reader, not 401: reading is open. expect(await anonymousPackument(mainOwner, "@ekklesia/contracts")).toBeUndefined(); }); it("refuses publishing without credentials, and from a user outside the publisher team", async () => { const contracts = local("@ekklesia/contracts").path; expect(await npmPublish(mainOwner, contracts, "")).toMatch(/ENEEDAUTH|E401|401/u); expect(await npmPublish(mainOwner, contracts, outsiderToken)).toMatch(/E401|E403|40[13]/u); expect(await anonymousPackument(mainOwner, "@ekklesia/contracts")).toBeUndefined(); }); }); describe("publishing a train, interrupted and resumed", () => { let record: PublicationRecord; it("run 1: stops before the sixth package, with five published and no final tag", async () => { await expect( publish(mainOwner, { beforePublish: (_name, index) => { if (index === 5) throw new Error("injected failure before the sixth package"); }, }), ).rejects.toThrow("injected failure"); for (const [index, name] of order.entries()) { const packument = await anonymousPackument(mainOwner, name); if (index < 5) { const version = packument?.versions[train.manifest.version]; expect(version, name).toBeDefined(); expect(packument?.["dist-tags"].latest, name).toBeUndefined(); expect(packument?.["dist-tags"]["ekklesia-staging"], name).toBe(train.manifest.version); expect((await anonymousBytes(version?.dist.tarball ?? "")).equals(local(name).data)).toBe( true, ); } else { expect(packument, name).toBeUndefined(); } } }); it("run 2: verifies the five as they are, publishes the other four, and only then moves latest", async () => { record = await publish(mainOwner); expect(record.packages.map((entry) => entry.name)).toEqual(order); expect(record.packages.map((entry) => entry.outcome)).toEqual([ ...Array(5).fill("already-present"), ...Array(4).fill("published"), ]); expect(record.packages.every((entry) => entry.finalTag === "moved")).toBe(true); expect(record.packageTrain).toEqual({ manifestSha256: train.manifestSha256, revision: train.manifest.source.revision, version: train.manifest.version, }); expect(JSON.stringify(record)).not.toContain(publishToken); }); it("serves every package anonymously, byte for byte the release artifact", async () => { for (const name of order) { const packument = await anonymousPackument(mainOwner, name); const version = packument?.versions[train.manifest.version]; expect(packument?.["dist-tags"].latest, name).toBe(train.manifest.version); expect(version?.dist.integrity, name).toBe(local(name).entry.integrity); expect( (await anonymousBytes(version?.dist.tarball ?? "")).equals(local(name).data), name, ).toBe(true); const entry = record.packages.find((candidate) => candidate.name === name); expect(entry?.registry.sha256).toBe(local(name).entry.sha256); expect(entry?.registry.publicRead).toBe(true); } }); it("run 3: a rerun of a published train changes nothing", async () => { const again = await publish(mainOwner); expect(again.packages.every((entry) => entry.outcome === "already-present")).toBe(true); expect(again.packages.every((entry) => entry.finalTag === "already-current")).toBe(true); }); it("the registry itself refuses a second upload of a published version", async () => { const name = "@ekklesia/module-sdk"; const packument = await anonymousPackument(mainOwner, name); const tarballUrl = packument?.versions[train.manifest.version]?.dist.tarball ?? ""; // A raw upload, past npm's own client-side check, with the publisher's own token. const { data, entry, packageJson } = local(name); const response = await fetch(packumentUrl(registryOf(mainOwner), name), { body: JSON.stringify({ _attachments: { [path.basename(new URL(tarballUrl).pathname)]: { content_type: "application/octet-stream", data: data.toString("base64"), length: data.length, }, }, _id: name, "dist-tags": { manual: entry.version }, name, versions: { [entry.version]: { ...packageJson, dist: { integrity: entry.integrity } } }, }), headers: { authorization: `Bearer ${publishToken}`, "content-type": "application/json" }, method: "PUT", }); expect(response.status).toBe(409); expect((await anonymousBytes(tarballUrl)).equals(data)).toBe(true); }); }); describe("a version already published with other bytes", () => { it("stops the train for good at that package, deleting and overwriting nothing", async () => { const poisonedName = "@ekklesia/module-sdk"; const index = order.indexOf(poisonedName); expect(index).toBeGreaterThan(0); expect(index).toBeLessThan(order.length - 1); // Same name and version, other bytes: the release tarball with one extra file. const work = await mkdtemp(path.join(tmpdir(), "ekklesia-burned-")); scratch.push(work); await execFileAsync("tar", ["-xzf", local(poisonedName).path, "-C", work]); await writeFile(path.join(work, "package", "UNEXPECTED"), "not the release\n"); const poisoned = path.join(work, "poisoned.tgz"); await execFileAsync("tar", ["-czf", poisoned, "-C", work, "package"]); expect(await npmPublish(burnedOwner, poisoned, publishToken)).toBe("published"); const poisonedBytes = await readFile(poisoned); await expect(publish(burnedOwner)).rejects.toMatchObject({ code: "BURNED_VERSION" }); for (const [position, name] of order.entries()) { const packument = await anonymousPackument(burnedOwner, name); if (position < index) { expect(packument?.versions[train.manifest.version], name).toBeDefined(); } else if (position === index) { const url = packument?.versions[train.manifest.version]?.dist.tarball ?? ""; expect((await anonymousBytes(url)).equals(poisonedBytes)).toBe(true); } else { expect(packument, `${name} must not be published after the stop`).toBeUndefined(); } expect(packument?.["dist-tags"].latest, name).toBeUndefined(); } // A retry stops in exactly the same place, and still changes nothing. await expect(publish(burnedOwner)).rejects.toMatchObject({ code: "BURNED_VERSION" }); expect(await anonymousPackument(burnedOwner, order[index + 1] ?? "")).toBeUndefined(); }); }); describe("moving the final tag, interrupted and resumed", () => { it("leaves the train unfinished when moving latest fails part way", async () => { await expect( publish(tagOwner, { beforeTag: (_name, index) => { if (index === 4) throw new Error("injected failure before the fifth tag"); }, }), ).rejects.toThrow("injected failure"); for (const [index, name] of order.entries()) { const packument = await anonymousPackument(tagOwner, name); expect(packument?.versions[train.manifest.version], name).toBeDefined(); expect(packument?.["dist-tags"].latest, name).toBe( index < 4 ? train.manifest.version : undefined, ); } }); it("a retry republishes nothing and moves only the tags still missing", async () => { const record = await publish(tagOwner); expect(record.packages.every((entry) => entry.outcome === "already-present")).toBe(true); expect(record.packages.map((entry) => entry.finalTag)).toEqual([ ...Array(4).fill("already-current"), ...Array(5).fill("moved"), ]); for (const name of order) { expect((await anonymousPackument(tagOwner, name))?.["dist-tags"].latest).toBe( train.manifest.version, ); } }); }); describe("the publish workflow's own invocation", () => { it("publishes from the Actions artifact layout, given a relative release path from another directory", async () => { // Exactly as .github/workflows/publish-packages.yml runs it: the artifact unpacked under // `package-train/` in the job's workspace, and `--release "$(find package-train …)"`. const workspace = await mkdtemp(path.join(tmpdir(), "ekklesia-publish-workspace-")); scratch.push(workspace); const releaseName = path.basename(releaseDirectory); await mkdir(path.join(workspace, "package-train"), { recursive: true }); await cp(releaseDirectory, path.join(workspace, "package-train", releaseName), { recursive: true, }); const record = path.join(workspace, "publication-record.json"); const { stdout } = await execFileAsync( process.execPath, [ path.resolve("scripts/publish-package-train.ts"), "--release", `package-train/${releaseName}`, "--registry", registryOf(layoutOwner), "--version", train.manifest.version, "--revision", train.manifest.source.revision, "--manifest-sha256", train.manifestSha256, "--record", record, "--require-public-read", "--allow-http", ], { cwd: workspace, env: { [PUBLISH_TOKEN_VARIABLE]: publishToken, PATH: process.env.PATH ?? "" }, }, ); expect(stdout).toContain( `publishing ${path.join(workspace, "package-train", releaseName, local("@ekklesia/contracts").entry.file)}`, ); expect(stdout).not.toContain(publishToken); const published = JSON.parse(await readFile(record, "utf8")) as PublicationRecord; expect(published.packages.map((entry) => entry.outcome)).toEqual(Array(9).fill("published")); for (const name of order) { const version = (await anonymousPackument(layoutOwner, name))?.versions[ train.manifest.version ]; expect( (await anonymousBytes(version?.dist.tarball ?? "")).equals(local(name).data), name, ).toBe(true); } }); });