| 1 | import { spawnSync } from "node:child_process"; |
| 2 | import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 3 | import { tmpdir } from "node:os"; |
| 4 | import { join } from "node:path"; |
| 5 | import { afterEach, describe, expect, it } from "vitest"; |
| 6 | |
| 7 | /** |
| 8 | * BOOTSTRAP_ADMINS on deploy: scripts/bootstrap-admins.sh (run by bootstrap.sh) and render-config.mjs's |
| 9 | * --admins, against a fake Cloudflare API (curl) on PATH. The deploy takes the list from the environment, |
| 10 | * else wrangler.jsonc, else keeps the deployed Worker's, and never logs the addresses. |
| 11 | */ |
| 12 | |
| 13 | const repo = new URL("../../../../", import.meta.url).pathname; |
| 14 | const LIVE = "first@example.com,second@example.com"; |
| 15 | |
| 16 | type State = { |
| 17 | /** The deployed Worker's bindings; null before the first deploy, "denied" for a token without Workers Scripts. */ |
| 18 | worker: Array<{ type: string; name: string; text?: string }> | null | "denied"; |
| 19 | }; |
| 20 | |
| 21 | // curl ... URL -H ...: answers from state.json, logs each call. |
| 22 | const FAKE_CURL = `#!/usr/bin/env node |
| 23 | const fs = require("node:fs"); |
| 24 | const dir = process.env.FAKE_DIR; |
| 25 | const state = JSON.parse(fs.readFileSync(dir + "/state.json", "utf8")); |
| 26 | const a = process.argv.slice(2); |
| 27 | const method = a.includes("-X") ? a[a.indexOf("-X") + 1] : "GET"; |
| 28 | const url = a.find((x) => x.startsWith("https://")); |
| 29 | fs.appendFileSync(dir + "/calls.log", JSON.stringify({ method, url }) + "\\n"); |
| 30 | const path = new URL(url).pathname.replace(/^\\/client\\/v4\\/accounts\\/acct/, ""); |
| 31 | const fail = (code, message) => ({ success: false, errors: [{ code, message }], result: null }); |
| 32 | let out; |
| 33 | if (path === "/workers/scripts/diffinery/settings") { |
| 34 | if (state.worker === "denied") out = fail(10000, "Authentication error"); |
| 35 | else out = state.worker ? { success: true, result: { bindings: state.worker } } : fail(10007, "This Worker does not exist on your account."); |
| 36 | } else out = fail(7003, "no route for " + path); |
| 37 | process.stdout.write(JSON.stringify(out)); |
| 38 | `; |
| 39 | |
| 40 | let dir = ""; |
| 41 | afterEach(() => { |
| 42 | if (dir) rmSync(dir, { recursive: true, force: true }); |
| 43 | dir = ""; |
| 44 | }); |
| 45 | |
| 46 | function tree(state: State, fileAdmins?: string): void { |
| 47 | dir = mkdtempSync(join(tmpdir(), "admins-")); |
| 48 | for (const d of ["scripts", "apps/diffinery", "bin"]) mkdirSync(join(dir, d), { recursive: true }); |
| 49 | for (const f of ["bootstrap-admins.sh", "jsonc.mjs", "render-config.mjs"]) copyFileSync(join(repo, "scripts", f), join(dir, "scripts", f)); |
| 50 | let cfg = readFileSync(join(repo, "apps/diffinery/wrangler.jsonc"), "utf8"); |
| 51 | if (fileAdmins !== undefined) cfg = cfg.replace(/"BOOTSTRAP_ADMINS": "[^"]*",(\s*"ACCESS_TEAM_DOMAIN": "",\s*"ACCESS_AUD": ""\s*\},\s*"d1_databases")/, `"BOOTSTRAP_ADMINS": ${JSON.stringify(fileAdmins)},$1`); |
| 52 | writeFileSync(join(dir, "apps/diffinery/wrangler.jsonc"), cfg); |
| 53 | writeFileSync(join(dir, "bin/curl"), FAKE_CURL); |
| 54 | chmodSync(join(dir, "bin/curl"), 0o755); |
| 55 | writeFileSync(join(dir, "state.json"), JSON.stringify(state)); |
| 56 | } |
| 57 | |
| 58 | function run(cmd: string[], extraEnv: Record<string, string | undefined> = {}) { |
| 59 | const env: Record<string, string> = { PATH: `${join(dir, "bin")}:${process.env.PATH}`, HOME: dir, FAKE_DIR: dir, CLOUDFLARE_API_TOKEN: "token", CLOUDFLARE_ACCOUNT_ID: "acct" }; |
| 60 | for (const [k, v] of Object.entries(extraEnv)) if (v === undefined) delete env[k]; |
| 61 | else env[k] = v; |
| 62 | const r = spawnSync(cmd[0]!, cmd.slice(1), { cwd: dir, encoding: "utf8", env }); |
| 63 | const calls = existsSync(join(dir, "calls.log")) ? readFileSync(join(dir, "calls.log"), "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l) as { method: string; url: string }) : []; |
| 64 | return { status: r.status, stdout: r.stdout, stderr: r.stderr, calls }; |
| 65 | } |
| 66 | |
| 67 | const admins = (env: Record<string, string | undefined> = {}) => { |
| 68 | const r = run(["bash", join(dir, "scripts/bootstrap-admins.sh")], env); |
| 69 | // The addresses go to render-config on stdout, never into the deploy log. |
| 70 | for (const email of [...LIVE.split(","), "ops@example.com", "file@example.com"]) expect(r.stderr).not.toContain(email); |
| 71 | return { ...r, list: r.stdout.trim() }; |
| 72 | }; |
| 73 | const live = [{ type: "plain_text", name: "BOOTSTRAP_ADMINS", text: LIVE }, { type: "plain_text", name: "PUBLIC_URL", text: "https://git.example.com" }, { type: "secret_text", name: "MASTER_KEY" }]; |
| 74 | |
| 75 | describe("scripts/bootstrap-admins.sh", () => { |
| 76 | it("takes BOOTSTRAP_ADMINS from the environment first, without asking Cloudflare", () => { |
| 77 | tree({ worker: live }, "file@example.com"); |
| 78 | const r = admins({ BOOTSTRAP_ADMINS: " ops@example.com, me@example.com " }); |
| 79 | expect(r.status).toBe(0); |
| 80 | expect(r.list).toBe("ops@example.com, me@example.com"); |
| 81 | expect(r.calls).toEqual([]); |
| 82 | expect(r.stderr).toContain("2 address(es), from the BOOTSTRAP_ADMINS environment variable"); |
| 83 | }); |
| 84 | |
| 85 | it("then a list kept in wrangler.jsonc", () => { |
| 86 | tree({ worker: live }, "file@example.com"); |
| 87 | const r = admins({ BOOTSTRAP_ADMINS: "" }); |
| 88 | expect(r.status).toBe(0); |
| 89 | expect(r.list).toBe("file@example.com"); |
| 90 | expect(r.calls).toEqual([]); |
| 91 | }); |
| 92 | |
| 93 | it("keeps the deployed Worker's list when neither sets one (the Deploy workflow)", () => { |
| 94 | tree({ worker: live }); |
| 95 | const r = admins(); |
| 96 | expect(r.status).toBe(0); |
| 97 | expect(r.list).toBe(LIVE); |
| 98 | expect(r.calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual(["GET /client/v4/accounts/acct/workers/scripts/diffinery/settings"]); |
| 99 | expect(r.stderr).toContain("2 address(es), from the deployed diffinery Worker"); |
| 100 | expect(r.stderr).not.toContain("::warning::"); |
| 101 | }); |
| 102 | |
| 103 | it("warns loudly on a brand-new instance with no list, and goes on with none", () => { |
| 104 | for (const worker of [null, [{ type: "plain_text", name: "BOOTSTRAP_ADMINS", text: "" }]]) { |
| 105 | tree({ worker }); |
| 106 | const r = admins(); |
| 107 | expect(r.status).toBe(0); |
| 108 | expect(r.list).toBe(""); |
| 109 | expect(r.stderr).toContain("::warning::BOOTSTRAP_ADMINS is empty"); |
| 110 | expect(r.stderr).toContain("nobody becomes a site administrator"); |
| 111 | rmSync(dir, { recursive: true, force: true }); |
| 112 | } |
| 113 | }); |
| 114 | |
| 115 | it("warns, without calling Cloudflare, when there are no credentials", () => { |
| 116 | tree({ worker: live }); |
| 117 | const r = admins({ CLOUDFLARE_API_TOKEN: undefined }); |
| 118 | expect(r.status).toBe(0); |
| 119 | expect(r.list).toBe(""); |
| 120 | expect(r.calls).toEqual([]); |
| 121 | expect(r.stderr).toContain("::warning::BOOTSTRAP_ADMINS is empty"); |
| 122 | }); |
| 123 | |
| 124 | it("stops the deploy when the Worker's settings can't be read, rather than drop its list", () => { |
| 125 | tree({ worker: "denied" }); |
| 126 | const r = admins(); |
| 127 | expect(r.status).toBe(1); |
| 128 | expect(r.list).toBe(""); |
| 129 | expect(r.stderr).toContain("::error::Could not read the diffinery Worker's settings to keep its BOOTSTRAP_ADMINS"); |
| 130 | expect(r.stderr).toContain("Workers Scripts → Edit"); |
| 131 | expect(r.stderr).toContain("10000: Authentication error"); |
| 132 | }); |
| 133 | }); |
| 134 | |
| 135 | describe("scripts/render-config.mjs --admins", () => { |
| 136 | const render = (args: string[]) => { |
| 137 | const r = run(["node", join(dir, "scripts/render-config.mjs"), "--d1", "db-id", "--public-url", "https://git.example.com", ...args]); |
| 138 | const cfg = r.status === 0 ? (JSON.parse(readFileSync(join(dir, "apps/diffinery/wrangler.deploy.jsonc"), "utf8").split("\n").slice(1).join("\n")) as { vars: Record<string, string> }) : null; |
| 139 | return { ...r, cfg }; |
| 140 | }; |
| 141 | |
| 142 | it("sets BOOTSTRAP_ADMINS, trimmed, and leaves wrangler.jsonc's value without the flag", () => { |
| 143 | tree({ worker: null }); |
| 144 | expect(render(["--admins", " a@example.com ,b@example.com,, "]).cfg!.vars.BOOTSTRAP_ADMINS).toBe("a@example.com,b@example.com"); |
| 145 | expect(render(["--admins", ""]).cfg!.vars.BOOTSTRAP_ADMINS).toBe(""); |
| 146 | expect(render([]).cfg!.vars.BOOTSTRAP_ADMINS).toBe(""); |
| 147 | rmSync(dir, { recursive: true, force: true }); |
| 148 | tree({ worker: null }, "file@example.com"); |
| 149 | expect(render([]).cfg!.vars.BOOTSTRAP_ADMINS).toBe("file@example.com"); |
| 150 | }); |
| 151 | |
| 152 | it("refuses an entry that isn't an email address", () => { |
| 153 | tree({ worker: null }); |
| 154 | const r = render(["--admins", "a@example.com,diffy"]); |
| 155 | expect(r.status).not.toBe(0); |
| 156 | expect(r.stderr).toContain('not an email address: "diffy"'); |
| 157 | }); |
| 158 | |
| 159 | it("ships no personal addresses: the repository's wrangler.jsonc leaves the list empty", () => { |
| 160 | tree({ worker: null }); |
| 161 | const r = render([]); |
| 162 | expect(r.cfg!.vars.BOOTSTRAP_ADMINS).toBe(""); |
| 163 | expect(JSON.stringify(r.cfg)).not.toContain("diffinery.com"); |
| 164 | }); |
| 165 | }); |