openclaw/test/scripts/plugin-npm-registry-readback.test.ts
Peter Steinberger ee98dce3bc
fix(release): defer plugin npm visibility to parent verification (#152438)
* fix(release): preserve plugin publish success during registry lag

Record published, visibility pending only after accepted plugin npm publication in a full parent release. The parent retains final registry authority; standalone repairs and identity, byte, malformed-selector, and ahead-selector conflicts remain strict. Reuse selector classification and remove the duplicate bootstrap selector readback. Related: #152176.

* fix(release): bind final plugin readback to publication evidence

Verify consumed qualification and planning receipts at the parent, including retained attempts. Require actual registry tarball integrity and archive identity on fresh-parent resumes that skip already-published versions; retain exact artifact byte checks for publisher jobs.

* fix(release): preserve qualified readback across failed child retries

Reconcile retained failed publishers with a verified newer skip plan and require their exact successful qualification-upload step. Keep original artifact byte verification. Register the dynamic verifier entrypoint and its isolated test inventory for CI.
2026-09-19 02:32:05 -07:00

145 lines
5.1 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function readback(fault: string, deferVisibility = true, route = "npm-oidc") {
const root = tempDirs.make("plugin-npm-readback-");
const bytes = Buffer.from("qualified plugin bytes");
const tarball = join(root, "plugin.tgz");
const preload = join(root, "registry.mjs");
const summary = join(root, "summary.md");
const name = "@openclaw/demo";
const version = "2026.9.2-beta.1";
const packument = {
name,
"dist-tags": { beta: version },
versions: {
[version]: {
name,
version,
dist: {
integrity: `sha512-${createHash("sha512").update(bytes).digest("base64")}`,
shasum: createHash("sha1").update(bytes).digest("hex"),
tarball: "https://registry.npmjs.org/@openclaw/demo/-/demo.tgz",
},
},
},
};
writeFileSync(tarball, bytes);
writeFileSync(summary, "");
writeFileSync(
preload,
`const packument = ${JSON.stringify(packument)};
const fault = ${JSON.stringify(fault)};
const bytes = Buffer.from(${JSON.stringify(bytes.toString())});
if (fault === "missing-version") packument.versions = {};
if (fault === "integrity") packument.versions[${JSON.stringify(version)}].dist.shasum = "0".repeat(40);
if (fault === "selector") packument["dist-tags"].beta = "2026.9.1-beta.1";
if (fault === "missing-selector") delete packument["dist-tags"].beta;
if (fault === "ahead-selector") packument["dist-tags"].beta = "2026.9.3-beta.1";
if (fault === "malformed-selector") packument["dist-tags"].beta = {};
if (fault === "package-name") packument.versions[${JSON.stringify(version)}].name = "@openclaw/other";
if (fault === "package-version") packument.versions[${JSON.stringify(version)}].version = "2026.9.2-beta.2";
globalThis.fetch = async (url, init) => {
if (!url.startsWith("https://registry.npmjs.org/") || (init?.method ?? "GET") !== "GET") {
throw new Error("Unexpected registry mutation or destination");
}
if (fault === "unavailable") return new Response(null, {status: 503});
if (fault === "forbidden") return new Response(null, {status: 403});
if (fault === "missing-package") return new Response(null, {status: 404});
if (url.endsWith(".tgz")) {
if (fault === "missing-tarball") return new Response(null, {status: 404});
if (fault === "oversized") return new Response(Buffer.concat([bytes, bytes]));
return new Response(fault === "bytes" ? Buffer.alloc(bytes.length) : bytes);
}
return Response.json(fault === "malformed" ? {} : packument);
};
`,
);
const result = spawnSync(
process.execPath,
[
"--import",
preload,
"scripts/plugin-npm-prepared-release.mjs",
"registry",
"--package-name",
name,
"--version",
version,
"--publish-tag",
"beta",
"--route",
route,
"--tarball",
tarball,
"--allow-missing",
"false",
"--defer-visibility",
String(deferVisibility),
],
{
encoding: "utf8",
timeout: 30_000,
env: {
...process.env,
GITHUB_STEP_SUMMARY: summary,
OPENCLAW_NPM_READBACK_TIMEOUT_MS: "100",
},
},
);
return { ...result, summary: readFileSync(summary, "utf8") };
}
describe("plugin npm postpublish visibility", () => {
it.each([
"missing-version",
"missing-package",
"missing-tarball",
"unavailable",
"selector",
"missing-selector",
])("records %s as published with visibility pending for the parent verifier", (fault) => {
const result = readback(fault);
expect(result.status, result.stderr).toBe(0);
expect(result.stderr).toContain("published, visibility pending");
expect(result.summary).toContain("published, visibility pending");
expect(result.summary).toContain("parent's final registry verification");
});
it.each([
["missing-version", false, "npm-oidc"],
["missing-tarball", false, "npm-token-bootstrap"],
["missing-version", true, "npm-readback"],
["selector", false, "npm-oidc"],
["selector", true, "npm-readback"],
...[
"integrity",
"bytes",
"ahead-selector",
"malformed-selector",
"package-name",
"package-version",
"oversized",
"malformed",
"forbidden",
].map((fault) => [fault, true, "npm-oidc"] as const),
] as const)("keeps %s strict (defer=%s, route=%s)", (fault, defer, route) => {
const result = readback(fault, defer, route);
expect(result.status, result.stderr).toBe(1);
expect(result.summary).toBe("");
expect(result.stderr).not.toContain("published, visibility pending");
});
it("still reports verified bytes and selector when registry readback succeeds", () => {
const result = readback("none");
expect(result.status, result.stderr).toBe(0);
expect(result.stdout).toContain("verified exact published bytes and selector");
expect(result.summary).toBe("");
});
});