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 cwsUploadEnv,12 amoSignEnv,13 resolveBin,14 preflight,15 packageChromeZip,16 uploadChrome,17 signFirefox,18 CWS_SECRET_NAMES,19 AMO_SECRET_NAMES,20 type CommandSpec,21 type Runner,22} from "./release-lib";2324const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");25const ALL_SECRET_NAMES = [...CWS_SECRET_NAMES, ...AMO_SECRET_NAMES];2627const HELD_ENV: Record<string, string | undefined> = {};2829function setEnv(name: string, val: string) {30 HELD_ENV[name] = process.env[name];31 process.env[name] = val;32}3334function restoreEnv() {35 for (const [name, val] of Object.entries(HELD_ENV)) {36 if (val === undefined) delete process.env[name];37 else process.env[name] = val;38 }39 for (const key of Object.keys(HELD_ENV)) delete HELD_ENV[key];40}4142afterEach(restoreEnv);43444546describe("PB-26-211: argv-based runner — no shell, no npx", () => {47 it("buildCommand returns a CommandSpec with array args, never a string", () => {48 const spec = buildCommand("/bin/echo", ["hello", "world"]);49 expect(Array.isArray(spec.args)).toBe(true);50 expect(spec.args).toEqual(["hello", "world"]);51 expect(typeof spec.executable).toBe("string");52 });5354 it("no release/CI script source contains execSync or npx", () => {55 const scripts = [56 "scripts/release.ts",57 "scripts/release-firefox.ts",58 "scripts/release-lib.ts",59 "scripts/ci.ts",60 ];61 for (const p of scripts) {62 const src = readFileSync(join(ROOT, p), "utf8");63 expect(src, `${p} must not use execSync`).not.toContain("execSync");64 expect(src, `${p} must not invoke npx`).not.toMatch(/\bnpx\b/);65 }66 });6768 it("no release script builds commands via string concatenation (.join)", () => {69 const scripts = [70 "scripts/release.ts",71 "scripts/release-firefox.ts",72 "scripts/release-lib.ts",73 ];74 for (const p of scripts) {75 const src = readFileSync(join(ROOT, p), "utf8");76 expect(src, `${p} must not join command strings`).not.toContain("].join(");77 }78 });7980 it("fake runner captures argv — no captured spec uses a shell or npx", () => {81 const captured: CommandSpec[] = [];82 const fake: Runner = (exec, args, opts) => {83 captured.push(buildCommand(exec, args, opts));84 };8586 for (const name of ALL_SECRET_NAMES) setEnv(name, "secret-value");8788 packageChromeZip("9.9.9", fake);89 uploadChrome("9.9.9", fake);90 signFirefox("/tmp/fake-source.zip", fake);9192 expect(captured.length).toBeGreaterThanOrEqual(3);93 for (const spec of captured) {94 expect(spec.executable).not.toBe("npx");95 expect(spec.args).not.toContain("npx");96 expect(Array.isArray(spec.args)).toBe(true);97 }98 });99});100101102103describe("PB-26-211: CI/build child environments contain no store credentials", () => {104 it("safeEnv strips all CWS and AMO secret names", () => {105 for (const name of ALL_SECRET_NAMES) setEnv(name, "leak-value");106107 const env = safeEnv();108 for (const name of ALL_SECRET_NAMES) {109 expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);110 }111 });112113 it("safeEnv preserves non-secret environment variables", () => {114 setEnv("PATH", "/usr/bin:/bin");115 setEnv("HOME", "/tmp/test-home");116117 const env = safeEnv();118 expect(env.PATH).toBe("/usr/bin:/bin");119 expect(env.HOME).toBe("/tmp/test-home");120 });121122 it("safeEnv strips build-behavior overrides from release children", () => {123 for (const [name, value] of Object.entries({124 BUILD_TARGET: "firefox",125 PEACH_E2E_PASSKEYS: "1",126 PEACH_DEBUG: "1",127 DEBUG: "vite:*",128 NODE_ENV: "development",129 MODE: "development",130 VITE_MODE: "development",131 CI: "true",132 })) setEnv(name, value);133134 const env = safeEnv();135 for (const name of [136 "BUILD_TARGET",137 "PEACH_E2E_PASSKEYS",138 "PEACH_DEBUG",139 "DEBUG",140 "NODE_ENV",141 "MODE",142 "VITE_MODE",143 "CI",144 ]) expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);145 });146147 it("safeEnv strips the release browser gate from release children", () => {148 setEnv("PEACH_RELEASE_BROWSER_GATE", "0");149 setEnv("BUILD_TARGET", "firefox");150151 const env = safeEnv();152 expect(env).not.toHaveProperty("PEACH_RELEASE_BROWSER_GATE");153 expect(env).not.toHaveProperty("BUILD_TARGET");154 });155});156157158159describe("PB-26-211: Chrome and AMO credentials are disjoint", () => {160 it("cwsUploadEnv contains CWS credentials but no AMO credentials", () => {161 for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);162163 const env = cwsUploadEnv();164165 166 expect(env.CLIENT_ID).toBe("val-CWS_CLIENT_ID");167 expect(env.CLIENT_SECRET).toBe("val-CWS_CLIENT_SECRET");168 expect(env.REFRESH_TOKEN).toBe("val-CWS_REFRESH_TOKEN");169 expect(env.PUBLISHER_ID).toBe("val-PUBLISHER_ID");170171 172 expect(env).not.toHaveProperty("AMO_JWT_ISSUER");173 expect(env).not.toHaveProperty("AMO_JWT_SECRET");174 expect(env).not.toHaveProperty("WEB_EXT_API_KEY");175 expect(env).not.toHaveProperty("WEB_EXT_API_SECRET");176 });177178 it("amoSignEnv contains AMO credentials but no CWS credentials", () => {179 for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);180181 const env = amoSignEnv();182183 184 expect(env.WEB_EXT_API_KEY).toBe("val-AMO_JWT_ISSUER");185 expect(env.WEB_EXT_API_SECRET).toBe("val-AMO_JWT_SECRET");186187 188 expect(env).not.toHaveProperty("CWS_CLIENT_ID");189 expect(env).not.toHaveProperty("CWS_CLIENT_SECRET");190 expect(env).not.toHaveProperty("CWS_REFRESH_TOKEN");191 expect(env).not.toHaveProperty("CWS_EXTENSION_ID");192 expect(env).not.toHaveProperty("PUBLISHER_ID");193 expect(env).not.toHaveProperty("CLIENT_ID");194 expect(env).not.toHaveProperty("CLIENT_SECRET");195 expect(env).not.toHaveProperty("REFRESH_TOKEN");196 });197});198199200201describe("PB-26-211: paths with shell metacharacters remain one argv element", () => {202 it("buildCommand treats a metacharacter path as a single arg", () => {203 const evilPath = "/tmp/$(whoami); echo pwned";204 const spec = buildCommand("/bin/echo", [evilPath]);205 expect(spec.args).toHaveLength(1);206 expect(spec.args[0]).toBe(evilPath);207 });208209 it("spawnSync with argv arrays does not execute shell injections", () => {210 const { spawnSync } = require("node:child_process");211 212 const evilPath = "/tmp/$(whoami)";213 const result = spawnSync("/bin/echo", [evilPath], {214 stdout: "pipe",215 stderr: "pipe",216 shell: false,217 });218 const out = result.stdout?.toString().trim();219 expect(out).toBe(evilPath);220 221 expect(out).toContain("$(whoami)");222 });223});224225226227describe("PB-26-211: missing local binary fails before any network access", () => {228 it("resolveBin throws when the tool is not in node_modules/.bin", () => {229 expect(() => resolveBin("nonexistent-tool-xyz-12345")).toThrow(230 /not found/,231 );232 });233234 it("resolveBin throws before returning a path, preventing network calls", () => {235 let threw = false;236 try {237 resolveBin("another-missing-tool-67890");238 } catch {239 threw = true;240 }241 expect(threw).toBe(true);242 });243});244245246247describe("PB-26-211: preflight rejects non-exact release-tool versions", () => {248 it("preflight passes when release-critical deps are exact", () => {249 250 251 expect(() => preflight()).not.toThrow();252 });253254 it("package.json has exact versions for release-critical dev deps", () => {255 const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));256 const devDeps = pkg.devDependencies ?? {};257 const critical = [258 "chrome-webstore-upload-cli",259 "web-ext",260 "typescript",261 "@playwright/test",262 ];263 for (const dep of critical) {264 const v = devDeps[dep];265 expect(v, `${dep} must be in devDependencies`).toBeDefined();266 expect(v, `${dep} must not use ^ or ~`).not.toMatch(/^[~^]/);267 }268 });269});