返回 CodeWhale
deploy-preflight.test.ts
根目录 / web / lib / deploy-preflight.test.ts
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
159 lines TYPESCRIPT