| 1 | import { spawnSync } from "node:child_process"; |
| 2 | import { readFileSync } from "node:fs"; |
| 3 | import { describe, expect, it } from "vitest"; |
| 4 | |
| 5 | const script = new URL("../scripts/check-cloudflare-deploy-env.mjs", import.meta.url); |
| 6 | |
| 7 | function run(overrides: Record<string, string>, args: string[] = []) { |
| 8 | return spawnSync(process.execPath, [script.pathname, ...args], { |
| 9 | encoding: "utf8", |
| 10 | env: { |
| 11 | ...process.env, |
| 12 | GITHUB_ACTIONS: "", |
| 13 | GITHUB_EVENT_NAME: "", |
| 14 | GITHUB_REF: "", |
| 15 | GITHUB_SHA: "", |
| 16 | CLOUDFLARE_ACCOUNT_ID: "", |
| 17 | CLOUDFLARE_API_TOKEN: "", |
| 18 | ...overrides, |
| 19 | }, |
| 20 | }); |
| 21 | } |
| 22 | |
| 23 | describe("Cloudflare deploy preflight", () => { |
| 24 | it("reports intentionally withheld credentials without deploying", () => { |
| 25 | const result = run({}, ["--preflight"]); |
| 26 | |
| 27 | expect(result.status).toBe(0); |
| 28 | expect(result.stdout).toContain("credentialState\":\"withheld"); |
| 29 | expect(result.stdout).toContain("deploymentStarted\":false"); |
| 30 | }); |
| 31 | |
| 32 | it("rejects malformed supplied values even in credential-free preflight mode", () => { |
| 33 | const result = run( |
| 34 | { |
| 35 | CLOUDFLARE_ACCOUNT_ID: "not-an-account-id", |
| 36 | CLOUDFLARE_API_TOKEN: "not-a-token", |
| 37 | }, |
| 38 | ["--preflight"], |
| 39 | ); |
| 40 | |
| 41 | expect(result.status).toBe(1); |
| 42 | expect(result.stderr).toContain("malformed credential placeholders"); |
| 43 | expect(result.stdout).toContain("credentialState\":\"invalid"); |
| 44 | }); |
| 45 | |
| 46 | it("keeps the normal deploy check fail-closed when credentials are missing", () => { |
| 47 | const result = run({}); |
| 48 | |
| 49 | expect(result.status).toBe(1); |
| 50 | expect(result.stderr).toContain("Cloudflare deploy configuration is incomplete"); |
| 51 | }); |
| 52 | |
| 53 | it("requires an exact manual-main context inside GitHub Actions", () => { |
| 54 | const result = run({ |
| 55 | GITHUB_ACTIONS: "true", |
| 56 | GITHUB_EVENT_NAME: "push", |
| 57 | GITHUB_REF: "refs/heads/main", |
| 58 | GITHUB_SHA: "a".repeat(40), |
| 59 | CLOUDFLARE_ACCOUNT_ID: "a".repeat(32), |
| 60 | CLOUDFLARE_API_TOKEN: "token-" + "b".repeat(32), |
| 61 | }); |
| 62 | |
| 63 | expect(result.status).toBe(1); |
| 64 | expect(result.stderr).toContain("workflow_dispatch on refs/heads/main"); |
| 65 | }); |
| 66 | |
| 67 | it("rejects a dispatch on a non-main ref", () => { |
| 68 | const result = run({ |
| 69 | GITHUB_ACTIONS: "true", |
| 70 | GITHUB_EVENT_NAME: "workflow_dispatch", |
| 71 | GITHUB_REF: "refs/heads/release", |
| 72 | GITHUB_SHA: "a".repeat(40), |
| 73 | CLOUDFLARE_ACCOUNT_ID: "a".repeat(32), |
| 74 | CLOUDFLARE_API_TOKEN: "token-" + "b".repeat(32), |
| 75 | }); |
| 76 | |
| 77 | expect(result.status).toBe(1); |
| 78 | expect(result.stderr).toContain("workflow_dispatch on refs/heads/main"); |
| 79 | }); |
| 80 | |
| 81 | it("rejects a dispatch without an exact 40-hex revision", () => { |
| 82 | const result = run({ |
| 83 | GITHUB_ACTIONS: "true", |
| 84 | GITHUB_EVENT_NAME: "workflow_dispatch", |
| 85 | GITHUB_REF: "refs/heads/main", |
| 86 | GITHUB_SHA: "main", |
| 87 | CLOUDFLARE_ACCOUNT_ID: "a".repeat(32), |
| 88 | CLOUDFLARE_API_TOKEN: "token-" + "b".repeat(32), |
| 89 | }); |
| 90 | |
| 91 | expect(result.status).toBe(1); |
| 92 | expect(result.stderr).toContain("exact SHA"); |
| 93 | }); |
| 94 | |
| 95 | it("accepts a manual dispatch on main at an exact SHA", () => { |
| 96 | const result = run({ |
| 97 | GITHUB_ACTIONS: "true", |
| 98 | GITHUB_EVENT_NAME: "workflow_dispatch", |
| 99 | GITHUB_REF: "refs/heads/main", |
| 100 | GITHUB_SHA: "a".repeat(40), |
| 101 | CLOUDFLARE_ACCOUNT_ID: "a".repeat(32), |
| 102 | CLOUDFLARE_API_TOKEN: "token-" + "b".repeat(32), |
| 103 | }); |
| 104 | |
| 105 | expect(result.status).toBe(0); |
| 106 | expect(result.stdout).toContain("Cloudflare deploy environment is present"); |
| 107 | }); |
| 108 | }); |
| 109 | |
| 110 | // Minimal, dependency-free reader for the two-space-indented job blocks in |
| 111 | // .github/workflows/web.yml. A real YAML parser is not a web dependency, and |
| 112 | // this file only needs the `on:` triggers plus the deploy job's `if:` guard. |
| 113 | function readWebWorkflow() { |
| 114 | const path = new URL("../../.github/workflows/web.yml", import.meta.url); |
| 115 | return readFileSync(path, "utf8"); |
| 116 | } |
| 117 | |
| 118 | function jobBlock(source: string, job: string) { |
| 119 | const lines = source.split("\n"); |
| 120 | const start = lines.findIndex((line) => line === ` ${job}:`); |
| 121 | expect(start, `job ${job} not found in web.yml`).toBeGreaterThanOrEqual(0); |
| 122 | const rest = lines.slice(start + 1); |
| 123 | const end = rest.findIndex((line) => /^ {2}\S/.test(line)); |
| 124 | return (end === -1 ? rest : rest.slice(0, end)).join("\n"); |
| 125 | } |
| 126 | |
| 127 | describe("web workflow deploy trigger contract", () => { |
| 128 | const workflow = readWebWorkflow(); |
| 129 | const deploy = jobBlock(workflow, "deploy"); |
| 130 | |
| 131 | it("still runs lint on pushes and pull requests", () => { |
| 132 | expect(workflow).toContain(" push:\n branches: [master, main]"); |
| 133 | expect(workflow).toContain(" pull_request:\n branches: [master, main]"); |
| 134 | expect(workflow).toContain(" workflow_dispatch:"); |
| 135 | expect(jobBlock(workflow, "lint")).not.toContain("if:"); |
| 136 | }); |
| 137 | |
| 138 | it("gates deploy on a manual dispatch of main only", () => { |
| 139 | const guard = deploy |
| 140 | .slice(deploy.indexOf("if:")) |
| 141 | .split("\n") |
| 142 | .slice(0, 3) |
| 143 | .join(" ") |
| 144 | .replace(/\s+/g, " "); |
| 145 | |
| 146 | expect(guard).toContain("github.event_name == 'workflow_dispatch'"); |
| 147 | expect(guard).toContain("github.ref == 'refs/heads/main'"); |
| 148 | // The preflight script fails closed on any non-dispatch event, so a push |
| 149 | // trigger here could only ever produce a red deploy job (#4907). |
| 150 | expect(guard).not.toContain("'push'"); |
| 151 | expect(deploy).toContain("needs: lint"); |
| 152 | }); |
| 153 | |
| 154 | it("checks out the exact dispatched revision before deploying", () => { |
| 155 | expect(deploy).toContain("ref: ${{ github.sha }}"); |
| 156 | expect(deploy).toContain('--expected-revision "$GITHUB_SHA"'); |
| 157 | }); |
| 158 | }); |
| 159 |