diff --git a/packages/cli/src/__tests__/digitalocean-token.test.ts b/packages/cli/src/__tests__/digitalocean-token.test.ts index e0d329129..ae4b6a119 100644 --- a/packages/cli/src/__tests__/digitalocean-token.test.ts +++ b/packages/cli/src/__tests__/digitalocean-token.test.ts @@ -88,34 +88,34 @@ describe("doApi 401 OAuth recovery", () => { it("attempts OAuth recovery on 401 before throwing", async () => { state.token = "expired-token"; - let callCount = 0; + let apiCalls = 0; + let oauthChecks = 0; globalThis.fetch = mock((url: string | URL | Request) => { - callCount++; const urlStr = String(url); - // First call: the actual API call returning 401 - if (callCount === 1) { + // OAuth connectivity check — fail it so tryDoOAuth returns null quickly + // (avoids starting a real Bun.serve OAuth server) + if (urlStr.includes("cloud.digitalocean.com")) { + oauthChecks++; + return Promise.reject(new Error("network unavailable")); + } + // DigitalOcean API calls — return 401 + if (urlStr.includes("api.digitalocean.com")) { + apiCalls++; return Promise.resolve( new Response("Unauthorized", { status: 401, }), ); } - // Second call: OAuth connectivity check — fail it so tryDoOAuth returns null quickly - // (avoids starting a real Bun.serve OAuth server) - if (urlStr.includes("cloud.digitalocean.com")) { - return Promise.reject(new Error("network unavailable")); - } - return Promise.resolve( - new Response("Unauthorized", { - status: 401, - }), - ); + // Ignore unrelated calls (e.g. telemetry flush) + return Promise.resolve(new Response("ok")); }); // OAuth recovery fails (connectivity check fails), so doApi throws the 401 await expect(doApi("GET", "/account", undefined, 1)).rejects.toThrow("DigitalOcean API error 401"); - // Verify recovery was attempted: 1 API call + 1 connectivity check = 2 - expect(callCount).toBe(2); + // Verify recovery was attempted: 1 API call + 1 connectivity check + expect(apiCalls).toBe(1); + expect(oauthChecks).toBe(1); }); it("succeeds after OAuth recovery provides a new token", async () => { diff --git a/packages/cli/src/__tests__/export.test.ts b/packages/cli/src/__tests__/export.test.ts index 6a4510655..a6b406e01 100644 --- a/packages/cli/src/__tests__/export.test.ts +++ b/packages/cli/src/__tests__/export.test.ts @@ -1,5 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; -import { writeFileSync } from "node:fs"; +import { execSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { mockClackPrompts } from "./test-helpers"; const clackMocks = mockClackPrompts(); @@ -538,3 +541,130 @@ describe("cmdExport", () => { expect(exitSpy).not.toHaveBeenCalled(); }); }); + +// ── E2E: redact pass actually works at runtime ──────────────────────────────── +// +// This test exercises the generated bash against a real temp git repo to catch +// runtime quoting/escaping bugs like the sed delimiter regression in #3384. +// It is purely local (no network, no subprocess cloud calls) and deterministic. + +describe("export redact pass (e2e bash execution)", () => { + const redactOpts = { + spawnMd: "---\nname: test\n---\n", + readmeTemplate: "# __NAME__\n", + gitignore: "node_modules/\n", + cloud: "hetzner", + steps: "github", + visibility: "private" as const, + resultPath: "/dev/null", + allowRedact: true, + }; + + /** + * Extract the SECRET_REGEX, REDACT_PLACEHOLDER definitions and the + * while-read redact loop from the generated script. We build a + * self-contained bash snippet that: defines the vars, receives a file + * list as $1, and runs the sed replacements. + */ + function extractRedactSnippet(): string { + const full = buildExportScript(redactOpts); + + const regexMatch = full.match(/^SECRET_REGEX='[^']*'/m); + if (!regexMatch) { + throw new Error("Could not extract SECRET_REGEX from generated script"); + } + + const placeholderMatch = full.match(/^REDACT_PLACEHOLDER='[^']*'/m); + if (!placeholderMatch) { + throw new Error("Could not extract REDACT_PLACEHOLDER from generated script"); + } + + return [ + "#!/bin/bash", + "set -eo pipefail", + regexMatch[0], + placeholderMatch[0], + 'FILE_LIST="$1"', + "while IFS= read -r f; do", + ' [ -z "$f" ] && continue', + ' sed -i -E "s#${SECRET_REGEX}#${REDACT_PLACEHOLDER}#g" "$f"', + 'done <<< "$FILE_LIST"', + ].join("\n"); + } + + // Synthetic secrets — one per regex family in SECRET_REGEX. + const syntheticSecrets: Record = { + openrouter: "sk-or-v1-abcdef1234567890abcdef", + anthropic: "sk-ant-api03-12_abcdefghijklmnopqrstu", + openai: "sk-proj-ABCDEFGHIJKLMNOPQRSTUv", + github: "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij", + aws: "AKIA0123456789ABCDEF", + hetzner: "hcloud_abcdefghijklmnopqrstuvwx", + digitalocean: "dop_v1_abcdef0123456789abcdef0123456789ab", + pem: "-----BEGIN PRIVATE KEY-----", + }; + + const REDACT = "***REDACTED-BY-SPAWN-EXPORT***"; + + /** Create a temp dir with git init, write a file, stage it, run the redact + * snippet against it, and return the file contents after redaction. */ + function runRedactOn(filename: string, content: string): string { + const dir = mkdtempSync(join(tmpdir(), "spawn-redact-e2e-")); + const filePath = join(dir, filename); + writeFileSync(filePath, content); + execSync("git init -q -b main", { + cwd: dir, + }); + execSync("git add -A", { + cwd: dir, + }); + const snippetPath = join(dir, "_redact.sh"); + writeFileSync(snippetPath, extractRedactSnippet(), { + mode: 0o755, + }); + execSync(`bash "${snippetPath}" "${filePath}"`, { + cwd: dir, + }); + const result = readFileSync(filePath, "utf8"); + execSync(`rm -rf "${dir}"`); + return result; + } + + it("redacts every secret family in a staged file", () => { + const lines = Object.entries(syntheticSecrets).map(([family, secret]) => `${family}: ${secret}`); + const after = runRedactOn("leaky.env", lines.join("\n") + "\n"); + for (const [family, secret] of Object.entries(syntheticSecrets)) { + expect(after).not.toContain(secret); + expect(after).toContain(`${family}: ${REDACT}`); + } + }); + + it("leaves non-secret content untouched", () => { + const innocentContent = + [ + "DATABASE_URL=postgres://localhost:5432/mydb", + "NODE_ENV=production", + "PORT=3000", + "some normal code here", + 'const x = "hello world";', + ].join("\n") + "\n"; + const after = runRedactOn("config.ts", innocentContent); + expect(after).toBe(innocentContent); + }); + + it("handles multiple secrets on the same line", () => { + const multiLine = "KEY1=sk-or-v1-abcdef1234567890abcdef KEY2=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij\n"; + const after = runRedactOn("multi.env", multiLine); + expect(after).not.toContain("sk-or-v1-"); + expect(after).not.toContain("ghp_"); + const count = (after.match(/\*\*\*REDACTED-BY-SPAWN-EXPORT\*\*\*/g) ?? []).length; + expect(count).toBe(2); + }); + + it("handles PEM block with algorithm prefix", () => { + const pemContent = "-----BEGIN RSA PRIVATE KEY-----\nMIIE...base64data\n-----END RSA PRIVATE KEY-----\n"; + const after = runRedactOn("key.pem", pemContent); + expect(after).not.toContain("-----BEGIN RSA PRIVATE KEY-----"); + expect(after).toContain(REDACT); + }); +}); diff --git a/packages/cli/src/__tests__/hetzner-cov.test.ts b/packages/cli/src/__tests__/hetzner-cov.test.ts index ed1c050f1..9b21665fd 100644 --- a/packages/cli/src/__tests__/hetzner-cov.test.ts +++ b/packages/cli/src/__tests__/hetzner-cov.test.ts @@ -585,47 +585,46 @@ describe("hetzner/createServer", () => { }, }, }; - let callCount = 0; - global.fetch = mock(() => { - callCount++; - if (callCount <= 1) { - // Token validation - return Promise.resolve( - new Response( - JSON.stringify({ - servers: [], - }), - ), - ); + let hetznerCalls = 0; + let postServerAttempts = 0; + global.fetch = mock((url: string | URL | Request, opts?: RequestInit) => { + const urlStr = String(url); + // Ignore non-Hetzner calls (e.g. telemetry flush) + if (!urlStr.includes("api.hetzner.cloud")) { + return Promise.resolve(new Response("ok")); } - if (callCount <= 2) { - // SSH keys - return Promise.resolve( - new Response( - JSON.stringify({ - ssh_keys: [], - }), - ), - ); + hetznerCalls++; + const method = opts?.method ?? "GET"; + // POST /servers — first attempt fails, retry succeeds + if (method === "POST" && urlStr.includes("/servers")) { + postServerAttempts++; + if (postServerAttempts === 1) { + return Promise.resolve( + new Response( + JSON.stringify({ + error: { + code: "resource_limit_exceeded", + message: "primary_ip_limit", + }, + }), + { + status: 403, + }, + ), + ); + } + return Promise.resolve(new Response(JSON.stringify(serverResp))); } - if (callCount <= 3) { - // First create attempt — resource_limit_exceeded (HTTP 403) + // DELETE /primary_ips — cleanup orphaned IP + if (method === "DELETE" && urlStr.includes("/primary_ips/")) { return Promise.resolve( - new Response( - JSON.stringify({ - error: { - code: "resource_limit_exceeded", - message: "primary_ip_limit", - }, - }), - { - status: 403, - }, - ), + new Response("", { + status: 204, + }), ); } - if (callCount <= 4) { - // List primary IPs for cleanup + // GET /primary_ips — list IPs for cleanup + if (urlStr.includes("/primary_ips")) { return Promise.resolve( new Response( JSON.stringify({ @@ -645,40 +644,81 @@ describe("hetzner/createServer", () => { ), ); } - if (callCount <= 5) { - // Delete orphaned IP 100 + // GET /ssh_keys + if (urlStr.includes("/ssh_keys")) { return Promise.resolve( - new Response("", { - status: 204, - }), + new Response( + JSON.stringify({ + ssh_keys: [], + }), + ), ); } - // Retry create — success - return Promise.resolve(new Response(JSON.stringify(serverResp))); + // GET /servers (token validation) + if (urlStr.includes("/servers")) { + return Promise.resolve( + new Response( + JSON.stringify({ + servers: [], + }), + ), + ); + } + return Promise.resolve(new Response(JSON.stringify({}))); }); const { ensureHcloudToken, createServer } = await import("../hetzner/hetzner"); await ensureHcloudToken(); const conn = await createServer("test-retry", "cx23", "fsn1"); expect(conn.ip).toBe("10.0.0.5"); - // Should have called: token(1), ssh_keys(2), create-fail(3), list-ips(4), delete-ip(5), create-ok(6) - expect(callCount).toBeGreaterThanOrEqual(6); + // Should have called: token, ssh_keys, create-fail, list-ips, delete-ip, create-ok + expect(hetznerCalls).toBeGreaterThanOrEqual(6); }); it("throws with guidance when resource limit hit and no orphaned IPs to clean", async () => { process.env.HCLOUD_TOKEN = "test-token"; - let callCount = 0; - global.fetch = mock(() => { - callCount++; - if (callCount <= 1) { + let postServerAttempts = 0; + global.fetch = mock((url: string | URL | Request, opts?: RequestInit) => { + const urlStr = String(url); + // Ignore non-Hetzner calls (e.g. telemetry flush) + if (!urlStr.includes("api.hetzner.cloud")) { + return Promise.resolve(new Response("ok")); + } + const method = opts?.method ?? "GET"; + // POST /servers — always fail with resource_limit_exceeded + if (method === "POST" && urlStr.includes("/servers")) { + postServerAttempts++; return Promise.resolve( new Response( JSON.stringify({ - servers: [], + error: { + code: "resource_limit_exceeded", + message: "primary_ip_limit", + }, }), + { + status: 403, + }, ), ); } - if (callCount <= 2) { + // GET /primary_ips — all attached (none orphaned) + if (urlStr.includes("/primary_ips")) { + return Promise.resolve( + new Response( + JSON.stringify({ + primary_ips: [ + { + id: 100, + ip: "1.2.3.4", + assignee_id: 42, + }, + ], + }), + ), + ); + } + // GET /ssh_keys + if (urlStr.includes("/ssh_keys")) { return Promise.resolve( new Response( JSON.stringify({ @@ -687,36 +727,17 @@ describe("hetzner/createServer", () => { ), ); } - if (callCount <= 3) { - // Create fails with resource_limit_exceeded + // GET /servers (token validation) + if (urlStr.includes("/servers")) { return Promise.resolve( new Response( JSON.stringify({ - error: { - code: "resource_limit_exceeded", - message: "primary_ip_limit", - }, + servers: [], }), - { - status: 403, - }, ), ); } - // List primary IPs — all attached (none orphaned) - return Promise.resolve( - new Response( - JSON.stringify({ - primary_ips: [ - { - id: 100, - ip: "1.2.3.4", - assignee_id: 42, - }, - ], - }), - ), - ); + return Promise.resolve(new Response(JSON.stringify({}))); }); const { ensureHcloudToken, createServer } = await import("../hetzner/hetzner"); await ensureHcloudToken();