123import { readFileSync } from "node:fs";4import { fileURLToPath } from "node:url";5import { dirname, join, resolve } from "node:path";6import { afterEach, describe, expect, it } from "vitest";78import {9 buildCommand,10 safeEnv,11 releaseGateEnv,12 cwsUploadEnv,13 amoSignEnv,14 resolveBin,15 preflight,16 packageChromeZip,17 uploadChrome,18 signFirefox,19 CWS_SECRET_NAMES,20 AMO_SECRET_NAMES,21 type CommandSpec,22 type Runner,23} from "./release-lib";2425const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");26const ALL_SECRET_NAMES = [...CWS_SECRET_NAMES, ...AMO_SECRET_NAMES];2728const HELD_ENV: Record<string, string | undefined> = {};2930function setEnv(name: string, val: string) {31 HELD_ENV[name] = process.env[name];32 process.env[name] = val;33}3435function restoreEnv() {36 for (const [name, val] of Object.entries(HELD_ENV)) {37 if (val === undefined) delete process.env[name];38 else process.env[name] = val;39 }40 for (const key of Object.keys(HELD_ENV)) delete HELD_ENV[key];41}4243afterEach(restoreEnv);44454647describe("PB-26-211: argv-based runner — no shell, no npx", () => {48 it("buildCommand returns a CommandSpec with array args, never a string", () => {49 const spec = buildCommand("/bin/echo", ["hello", "world"]);50 expect(Array.isArray(spec.args)).toBe(true);51 expect(spec.args).toEqual(["hello", "world"]);52 expect(typeof spec.executable).toBe("string");53 });5455 it("no release/CI script source contains execSync or npx", () => {56 const scripts = [57 "scripts/release.ts",58 "scripts/release-firefox.ts",59 "scripts/release-lib.ts",60 "scripts/ci.ts",61 ];62 for (const p of scripts) {63 const src = readFileSync(join(ROOT, p), "utf8");64 expect(src, `${p} must not use execSync`).not.toContain("execSync");65 expect(src, `${p} must not invoke npx`).not.toMatch(/\bnpx\b/);66 }67 });6869 it("no release script builds commands via string concatenation (.join)", () => {70 const scripts = [71 "scripts/release.ts",72 "scripts/release-firefox.ts",73 "scripts/release-lib.ts",74 ];75 for (const p of scripts) {76 const src = readFileSync(join(ROOT, p), "utf8");77 expect(src, `${p} must not join command strings`).not.toContain("].join(");78 }79 });8081 it("fake runner captures argv — no captured spec uses a shell or npx", () => {82 const captured: CommandSpec[] = [];83 const fake: Runner = (exec, args, opts) => {84 captured.push(buildCommand(exec, args, opts));85 };8687 for (const name of ALL_SECRET_NAMES) setEnv(name, "secret-value");8889 packageChromeZip("9.9.9", fake);90 uploadChrome("9.9.9", fake);91 signFirefox("/tmp/fake-source.zip", fake);9293 expect(captured.length).toBeGreaterThanOrEqual(3);94 for (const spec of captured) {95 expect(spec.executable).not.toBe("npx");96 expect(spec.args).not.toContain("npx");97 expect(Array.isArray(spec.args)).toBe(true);98 }99 });100});101102103104describe("PB-26-211: CI/build child environments contain no store credentials", () => {105 it("safeEnv strips all CWS and AMO secret names", () => {106 for (const name of ALL_SECRET_NAMES) setEnv(name, "leak-value");107108 const env = safeEnv();109 for (const name of ALL_SECRET_NAMES) {110 expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);111 }112 });113114 it("safeEnv preserves non-secret environment variables", () => {115 setEnv("PATH", "/usr/bin:/bin");116 setEnv("HOME", "/tmp/test-home");117118 const env = safeEnv();119 expect(env.PATH).toBe("/usr/bin:/bin");120 expect(env.HOME).toBe("/tmp/test-home");121 });122123 it("safeEnv strips build-behavior overrides from release children", () => {124 for (const [name, value] of Object.entries({125 BUILD_TARGET: "firefox",126 PEACH_E2E_PASSKEYS: "1",127 PEACH_DEBUG: "1",128 DEBUG: "vite:*",129 NODE_ENV: "development",130 MODE: "development",131 VITE_MODE: "development",132 CI: "true",133 })) setEnv(name, value);134135 const env = safeEnv();136 for (const name of [137 "BUILD_TARGET",138 "PEACH_E2E_PASSKEYS",139 "PEACH_DEBUG",140 "DEBUG",141 "NODE_ENV",142 "MODE",143 "VITE_MODE",144 "CI",145 ]) expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);146 });147148 it("releaseGateEnv opts into only the intended release gate", () => {149 setEnv("PEACH_RELEASE_BROWSER_GATE", "0");150 setEnv("BUILD_TARGET", "firefox");151152 const env = releaseGateEnv();153 expect(env.PEACH_RELEASE_BROWSER_GATE).toBe("1");154 expect(env).not.toHaveProperty("BUILD_TARGET");155 });156});157158159160describe("PB-26-211: Chrome and AMO credentials are disjoint", () => {161 it("cwsUploadEnv contains CWS credentials but no AMO credentials", () => {162 for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);163164 const env = cwsUploadEnv();165166 167 expect(env.CLIENT_ID).toBe("val-CWS_CLIENT_ID");168 expect(env.CLIENT_SECRET).toBe("val-CWS_CLIENT_SECRET");169 expect(env.REFRESH_TOKEN).toBe("val-CWS_REFRESH_TOKEN");170 expect(env.PUBLISHER_ID).toBe("val-PUBLISHER_ID");171172 173 expect(env).not.toHaveProperty("AMO_JWT_ISSUER");174 expect(env).not.toHaveProperty("AMO_JWT_SECRET");175 expect(env).not.toHaveProperty("WEB_EXT_API_KEY");176 expect(env).not.toHaveProperty("WEB_EXT_API_SECRET");177 });178179 it("amoSignEnv contains AMO credentials but no CWS credentials", () => {180 for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);181182 const env = amoSignEnv();183184 185 expect(env.WEB_EXT_API_KEY).toBe("val-AMO_JWT_ISSUER");186 expect(env.WEB_EXT_API_SECRET).toBe("val-AMO_JWT_SECRET");187188 189 expect(env).not.toHaveProperty("CWS_CLIENT_ID");190 expect(env).not.toHaveProperty("CWS_CLIENT_SECRET");191 expect(env).not.toHaveProperty("CWS_REFRESH_TOKEN");192 expect(env).not.toHaveProperty("CWS_EXTENSION_ID");193 expect(env).not.toHaveProperty("PUBLISHER_ID");194 expect(env).not.toHaveProperty("CLIENT_ID");195 expect(env).not.toHaveProperty("CLIENT_SECRET");196 expect(env).not.toHaveProperty("REFRESH_TOKEN");197 });198});199200201202describe("PB-26-211: paths with shell metacharacters remain one argv element", () => {203 it("buildCommand treats a metacharacter path as a single arg", () => {204 const evilPath = "/tmp/$(whoami); echo pwned";205 const spec = buildCommand("/bin/echo", [evilPath]);206 expect(spec.args).toHaveLength(1);207 expect(spec.args[0]).toBe(evilPath);208 });209210 it("spawnSync with argv arrays does not execute shell injections", () => {211 const { spawnSync } = require("node:child_process");212 213 const evilPath = "/tmp/$(whoami)";214 const result = spawnSync("/bin/echo", [evilPath], {215 stdout: "pipe",216 stderr: "pipe",217 shell: false,218 });219 const out = result.stdout?.toString().trim();220 expect(out).toBe(evilPath);221 222 expect(out).toContain("$(whoami)");223 });224});225226227228describe("PB-26-211: missing local binary fails before any network access", () => {229 it("resolveBin throws when the tool is not in node_modules/.bin", () => {230 expect(() => resolveBin("nonexistent-tool-xyz-12345")).toThrow(231 /not found/,232 );233 });234235 it("resolveBin throws before returning a path, preventing network calls", () => {236 let threw = false;237 try {238 resolveBin("another-missing-tool-67890");239 } catch {240 threw = true;241 }242 expect(threw).toBe(true);243 });244});245246247248describe("PB-26-211: preflight rejects non-exact release-tool versions", () => {249 it("preflight passes when release-critical deps are exact", () => {250 251 252 expect(() => preflight()).not.toThrow();253 });254255 it("package.json has exact versions for release-critical dev deps", () => {256 const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));257 const devDeps = pkg.devDependencies ?? {};258 const critical = [259 "chrome-webstore-upload-cli",260 "web-ext",261 "typescript",262 "@playwright/test",263 ];264 for (const dep of critical) {265 const v = devDeps[dep];266 expect(v, `${dep} must be in devDependencies`).toBeDefined();267 expect(v, `${dep} must not use ^ or ~`).not.toMatch(/^[~^]/);268 }269 });270});