| 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 | * scripts/turnstile.sh (run by bootstrap.sh), its use in ensure-secrets.sh, and render-config.mjs's |
| 9 | * TURNSTILE_SITE_KEY, against a fake Cloudflare API (curl) and a fake wrangler (pnpm) on PATH. |
| 10 | */ |
| 11 | |
| 12 | const repo = new URL("../../../../", import.meta.url).pathname; |
| 13 | const SECRET = "0x4AAAAAAAsecret-key-of-the-widget"; |
| 14 | |
| 15 | type Widget = { sitekey: string; secret: string; name: string; domains: string[]; mode: string }; |
| 16 | type State = { |
| 17 | /** Every Turnstile call answers "Authentication error", as for a token without Turnstile Edit. */ |
| 18 | denied?: boolean; |
| 19 | /** Only creating widgets and reading a widget's secret fail. */ |
| 20 | readOnly?: boolean; |
| 21 | widgets: Widget[]; |
| 22 | /** The deployed Worker's bindings, or null before the first deploy. */ |
| 23 | worker: Array<{ type: string; name: string; text?: string }> | null; |
| 24 | /** Names wrangler secret list returns. */ |
| 25 | secrets: string[]; |
| 26 | }; |
| 27 | |
| 28 | // curl -sS -X METHOD URL [-H ...] [--data BODY]: answers from state.json, logs each call. |
| 29 | const FAKE_CURL = `#!/usr/bin/env node |
| 30 | const fs = require("node:fs"); |
| 31 | const dir = process.env.FAKE_DIR; |
| 32 | const state = JSON.parse(fs.readFileSync(dir + "/state.json", "utf8")); |
| 33 | const a = process.argv.slice(2); |
| 34 | const method = a[a.indexOf("-X") + 1] ?? "GET"; |
| 35 | const url = a.find((x) => x.startsWith("https://")); |
| 36 | const body = a.includes("--data") ? a[a.indexOf("--data") + 1] : null; |
| 37 | fs.appendFileSync(dir + "/calls.log", JSON.stringify({ method, url, body }) + "\\n"); |
| 38 | const path = new URL(url).pathname.replace(/^\\/client\\/v4\\/accounts\\/acct/, ""); |
| 39 | const fail = (code, message) => ({ success: false, errors: [{ code, message }], result: null }); |
| 40 | let out; |
| 41 | if (path.startsWith("/challenges/widgets")) { |
| 42 | const key = path.split("/")[3]; |
| 43 | if (state.denied) out = fail(10000, "Authentication error"); |
| 44 | else if (method === "GET" && !key) out = { success: true, result: state.widgets.map(({ secret, ...w }) => w) }; |
| 45 | else if (state.readOnly) out = fail(10000, "Authentication error"); |
| 46 | else if (method === "POST") { |
| 47 | const w = { ...JSON.parse(body), sitekey: "0x4AAAAAAAnew", secret: "${SECRET}" }; |
| 48 | state.widgets.push(w); |
| 49 | fs.writeFileSync(dir + "/state.json", JSON.stringify(state)); |
| 50 | out = { success: true, result: w }; |
| 51 | } else { |
| 52 | const w = state.widgets.find((x) => x.sitekey === key); |
| 53 | out = w ? { success: true, result: w } : fail(10404, "not found"); |
| 54 | } |
| 55 | } else if (path === "/workers/scripts/diffinery/settings") { |
| 56 | out = state.worker ? { success: true, result: { bindings: state.worker } } : fail(10007, "This Worker does not exist on your account."); |
| 57 | } else out = fail(7003, "no route for " + path); |
| 58 | process.stdout.write(JSON.stringify(out)); |
| 59 | `; |
| 60 | |
| 61 | // pnpm --dir APP exec wrangler secret (list|put NAME) ...: logs each put with what came on stdin. |
| 62 | const FAKE_PNPM = `#!/usr/bin/env node |
| 63 | const fs = require("node:fs"); |
| 64 | const dir = process.env.FAKE_DIR; |
| 65 | const state = JSON.parse(fs.readFileSync(dir + "/state.json", "utf8")); |
| 66 | const a = process.argv.slice(process.argv.indexOf("wrangler") + 1); |
| 67 | if (a[0] === "secret" && a[1] === "list") process.stdout.write(JSON.stringify(state.secrets.map((name) => ({ name, type: "secret_text" })))); |
| 68 | else if (a[0] === "secret" && a[1] === "put") { |
| 69 | const value = fs.readFileSync(0, "utf8"); |
| 70 | fs.appendFileSync(dir + "/puts.log", JSON.stringify({ name: a[2], value, args: a.slice(3) }) + "\\n"); |
| 71 | console.log("Success! Uploaded secret " + a[2]); |
| 72 | } else { console.error("fake pnpm: unexpected " + a.join(" ")); process.exit(1); } |
| 73 | `; |
| 74 | |
| 75 | let dir = ""; |
| 76 | afterEach(() => { |
| 77 | if (dir) rmSync(dir, { recursive: true, force: true }); |
| 78 | dir = ""; |
| 79 | }); |
| 80 | |
| 81 | function tree(state: State, wranglerVars: Record<string, string> = {}): string { |
| 82 | dir = mkdtempSync(join(tmpdir(), "turnstile-")); |
| 83 | for (const d of ["scripts", "apps/diffinery", "bin"]) mkdirSync(join(dir, d), { recursive: true }); |
| 84 | for (const f of ["turnstile.sh", "ensure-secrets.sh", "jsonc.mjs", "render-config.mjs"]) copyFileSync(join(repo, "scripts", f), join(dir, "scripts", f)); |
| 85 | const cfg = readFileSync(join(repo, "apps/diffinery/wrangler.jsonc"), "utf8"); |
| 86 | const vars = Object.entries(wranglerVars).map(([k, v]) => `\n ${JSON.stringify(k)}: ${JSON.stringify(v)},`).join(""); |
| 87 | writeFileSync(join(dir, "apps/diffinery/wrangler.jsonc"), cfg.replace(/"vars": \{/, `"vars": {${vars}`)); |
| 88 | writeFileSync(join(dir, "bin/curl"), FAKE_CURL); |
| 89 | writeFileSync(join(dir, "bin/pnpm"), FAKE_PNPM); |
| 90 | chmodSync(join(dir, "bin/curl"), 0o755); |
| 91 | chmodSync(join(dir, "bin/pnpm"), 0o755); |
| 92 | writeFileSync(join(dir, "state.json"), JSON.stringify(state)); |
| 93 | return dir; |
| 94 | } |
| 95 | |
| 96 | function run(cmd: string[], extraEnv: Record<string, string> = {}) { |
| 97 | const r = spawnSync(cmd[0]!, cmd.slice(1), { |
| 98 | cwd: dir, |
| 99 | encoding: "utf8", |
| 100 | env: { PATH: `${join(dir, "bin")}:${process.env.PATH}`, HOME: dir, FAKE_DIR: dir, CLOUDFLARE_API_TOKEN: "token", CLOUDFLARE_ACCOUNT_ID: "acct", DIFFINERY_DOMAIN: "git.example.com", ...extraEnv }, |
| 101 | }); |
| 102 | const lines = (f: string) => (existsSync(join(dir, f)) ? readFileSync(join(dir, f), "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l)) : []); |
| 103 | const calls = lines("calls.log") as Array<{ method: string; url: string; body: string | null }>; |
| 104 | const puts = lines("puts.log") as Array<{ name: string; value: string; args: string[] }>; |
| 105 | // Whatever happens, the secret key never reaches the log. |
| 106 | expect(r.stdout + r.stderr).not.toContain(SECRET); |
| 107 | return { status: r.status, stdout: r.stdout.trim(), stderr: r.stderr, calls, puts }; |
| 108 | } |
| 109 | |
| 110 | const turnstile = () => run(["bash", join(dir, "scripts/turnstile.sh")]); |
| 111 | const widget = (sitekey = "0x4AAAAAAAold"): Widget => ({ sitekey, secret: SECRET, name: "diffinery git.example.com", domains: ["git.example.com"], mode: "managed" }); |
| 112 | const PERMISSION = "Account → Turnstile → Edit"; |
| 113 | |
| 114 | describe("scripts/turnstile.sh", () => { |
| 115 | it("creates the widget when there is none, and prints its site key", () => { |
| 116 | tree({ widgets: [{ ...widget("0x4AAAAAAAother"), name: "someone else's" }], worker: null, secrets: [] }); |
| 117 | const r = turnstile(); |
| 118 | expect(r.status).toBe(0); |
| 119 | expect(r.stdout).toBe("0x4AAAAAAAnew"); |
| 120 | expect(r.calls.map((c) => `${c.method} ${new URL(c.url).pathname}${new URL(c.url).search}`)).toEqual([ |
| 121 | "GET /client/v4/accounts/acct/challenges/widgets?per_page=1000", |
| 122 | "POST /client/v4/accounts/acct/challenges/widgets", |
| 123 | "GET /client/v4/accounts/acct/workers/scripts/diffinery/settings", |
| 124 | ]); |
| 125 | expect(JSON.parse(r.calls[1]!.body!)).toEqual({ name: "diffinery git.example.com", domains: ["git.example.com"], mode: "managed" }); |
| 126 | // The Worker isn't deployed yet: ensure-secrets.sh sets TURNSTILE_SECRET after the deploy. |
| 127 | expect(r.puts).toEqual([]); |
| 128 | expect(r.stderr).toContain("ensure-secrets.sh sets TURNSTILE_SECRET"); |
| 129 | }); |
| 130 | |
| 131 | it("reuses the widget it finds by name, and leaves a secret that goes with it alone", () => { |
| 132 | tree({ widgets: [widget()], worker: [{ type: "plain_text", name: "TURNSTILE_SITE_KEY", text: "0x4AAAAAAAold" }, { type: "secret_text", name: "TURNSTILE_SECRET" }], secrets: [] }); |
| 133 | const r = turnstile(); |
| 134 | expect(r.status).toBe(0); |
| 135 | expect(r.stdout).toBe("0x4AAAAAAAold"); |
| 136 | expect(r.calls.some((c) => c.method === "POST")).toBe(false); |
| 137 | expect(r.puts).toEqual([]); |
| 138 | expect(r.stderr).not.toContain("::warning::"); |
| 139 | }); |
| 140 | |
| 141 | it("leaves a missing TURNSTILE_SECRET to ensure-secrets.sh", () => { |
| 142 | tree({ widgets: [widget()], worker: [{ type: "plain_text", name: "TURNSTILE_SITE_KEY", text: "0x4AAAAAAAold" }], secrets: [] }); |
| 143 | const r = turnstile(); |
| 144 | expect(r.stdout).toBe("0x4AAAAAAAold"); |
| 145 | expect(r.puts).toEqual([]); |
| 146 | }); |
| 147 | |
| 148 | it("replaces a TURNSTILE_SECRET that goes with another site key before the deploy", () => { |
| 149 | for (const deployed of ["0x4AAAAAAAgone", undefined]) { |
| 150 | tree({ widgets: [widget()], worker: [...(deployed ? [{ type: "plain_text", name: "TURNSTILE_SITE_KEY", text: deployed }] : []), { type: "secret_text", name: "TURNSTILE_SECRET" }], secrets: [] }); |
| 151 | const r = turnstile(); |
| 152 | expect(r.status).toBe(0); |
| 153 | expect(r.stdout).toBe("0x4AAAAAAAold"); |
| 154 | expect(r.puts).toEqual([{ name: "TURNSTILE_SECRET", value: SECRET, args: ["--name", "diffinery"] }]); |
| 155 | expect(r.calls.some((c) => c.method === "GET" && c.url.endsWith("/challenges/widgets/0x4AAAAAAAold"))).toBe(true); |
| 156 | rmSync(dir, { recursive: true, force: true }); |
| 157 | } |
| 158 | }); |
| 159 | |
| 160 | it("without the permission, warns naming it, prints no site key and exits 0", () => { |
| 161 | tree({ denied: true, widgets: [], worker: null, secrets: [] }); |
| 162 | const r = turnstile(); |
| 163 | expect(r.status).toBe(0); |
| 164 | expect(r.stdout).toBe(""); |
| 165 | expect(r.stderr).toContain("::warning::Could not read the account's Turnstile widgets"); |
| 166 | expect(r.stderr).toContain(PERMISSION); |
| 167 | expect(r.stderr).toContain("10000: Authentication error"); |
| 168 | expect(r.puts).toEqual([]); |
| 169 | }); |
| 170 | |
| 171 | it("with read-only access, warns when it can't create the widget", () => { |
| 172 | tree({ readOnly: true, widgets: [], worker: null, secrets: [] }); |
| 173 | const r = turnstile(); |
| 174 | expect(r.status).toBe(0); |
| 175 | expect(r.stdout).toBe(""); |
| 176 | expect(r.stderr).toContain('::warning::Could not create the Turnstile widget "diffinery git.example.com"'); |
| 177 | expect(r.stderr).toContain(PERMISSION); |
| 178 | }); |
| 179 | |
| 180 | it("deploys no new site key when a secret that goes with another widget can't be replaced", () => { |
| 181 | tree({ readOnly: true, widgets: [widget()], worker: [{ type: "plain_text", name: "TURNSTILE_SITE_KEY", text: "0x4AAAAAAAgone" }, { type: "secret_text", name: "TURNSTILE_SECRET" }], secrets: [] }); |
| 182 | const r = turnstile(); |
| 183 | expect(r.status).toBe(0); |
| 184 | expect(r.stdout).toBe(""); |
| 185 | expect(r.puts).toEqual([]); |
| 186 | expect(r.stderr).toContain("::warning::Could not read the secret key of Turnstile widget 0x4AAAAAAAold"); |
| 187 | expect(r.stderr).toContain("TURNSTILE_SECRET goes with another Turnstile widget and couldn't be replaced"); |
| 188 | }); |
| 189 | |
| 190 | it("leaves keys set in wrangler.jsonc alone", () => { |
| 191 | tree({ widgets: [], worker: null, secrets: [] }, { TURNSTILE_SITE_KEY: "0x4AAAAAAAmine" }); |
| 192 | const r = turnstile(); |
| 193 | expect(r.status).toBe(0); |
| 194 | expect(r.stdout).toBe(""); |
| 195 | expect(r.calls).toEqual([]); |
| 196 | }); |
| 197 | }); |
| 198 | |
| 199 | describe("scripts/ensure-secrets.sh and TURNSTILE_SECRET", () => { |
| 200 | const deployConfig = (vars: Record<string, string>) => |
| 201 | writeFileSync(join(dir, "apps/diffinery/wrangler.deploy.jsonc"), `// Generated by scripts/render-config.mjs; do not edit.\n${JSON.stringify({ name: "diffinery", vars })}\n`); |
| 202 | const ensure = () => run(["bash", join(dir, "scripts/ensure-secrets.sh")]); |
| 203 | |
| 204 | it("sets it from the deployed site key's widget when it's missing", () => { |
| 205 | tree({ widgets: [widget()], worker: [], secrets: ["MASTER_KEY", "OIDC_PRIVATE_KEY"] }); |
| 206 | deployConfig({ TURNSTILE_SITE_KEY: "0x4AAAAAAAold" }); |
| 207 | const r = ensure(); |
| 208 | expect(r.status).toBe(0); |
| 209 | expect(r.puts).toEqual([{ name: "TURNSTILE_SECRET", value: SECRET, args: ["-c", "wrangler.deploy.jsonc"] }]); |
| 210 | expect(r.stdout).toContain("set TURNSTILE_SECRET"); |
| 211 | }); |
| 212 | |
| 213 | it("leaves it alone when it exists, or when no site key was deployed", () => { |
| 214 | tree({ widgets: [widget()], worker: [], secrets: ["MASTER_KEY", "OIDC_PRIVATE_KEY", "TURNSTILE_SECRET"] }); |
| 215 | deployConfig({ TURNSTILE_SITE_KEY: "0x4AAAAAAAold" }); |
| 216 | expect(ensure().puts).toEqual([]); |
| 217 | rmSync(dir, { recursive: true, force: true }); |
| 218 | tree({ widgets: [widget()], worker: [], secrets: ["MASTER_KEY", "OIDC_PRIVATE_KEY"] }); |
| 219 | deployConfig({}); |
| 220 | const r = ensure(); |
| 221 | expect(r.puts).toEqual([]); |
| 222 | expect(r.calls).toEqual([]); |
| 223 | }); |
| 224 | |
| 225 | it("warns, naming the permission, and goes on when it can't read the widget's secret", () => { |
| 226 | tree({ denied: true, widgets: [], worker: [], secrets: ["MASTER_KEY", "OIDC_PRIVATE_KEY"] }); |
| 227 | deployConfig({ TURNSTILE_SITE_KEY: "0x4AAAAAAAold" }); |
| 228 | const r = ensure(); |
| 229 | expect(r.status).toBe(0); |
| 230 | expect(r.puts).toEqual([]); |
| 231 | expect(r.stderr).toContain(PERMISSION); |
| 232 | expect(r.stderr).toContain("::warning::TURNSTILE_SECRET is not set"); |
| 233 | }); |
| 234 | }); |
| 235 | |
| 236 | describe("scripts/render-config.mjs and TURNSTILE_SITE_KEY", () => { |
| 237 | const render = (env: Record<string, string>) => { |
| 238 | const r = run(["node", join(dir, "scripts/render-config.mjs"), "--d1", "db-id", "--public-url", "https://git.example.com"], env); |
| 239 | expect(r.status, r.stderr).toBe(0); |
| 240 | return JSON.parse(readFileSync(join(dir, "apps/diffinery/wrangler.deploy.jsonc"), "utf8").split("\n").slice(1).join("\n")) as { vars: Record<string, string> }; |
| 241 | }; |
| 242 | |
| 243 | it("deploys the site key bootstrap passes, unless wrangler.jsonc sets its own", () => { |
| 244 | tree({ widgets: [], worker: null, secrets: [] }); |
| 245 | expect(render({ TURNSTILE_SITE_KEY: "0x4AAAAAAAold" }).vars.TURNSTILE_SITE_KEY).toBe("0x4AAAAAAAold"); |
| 246 | expect(render({}).vars.TURNSTILE_SITE_KEY).toBeUndefined(); |
| 247 | rmSync(dir, { recursive: true, force: true }); |
| 248 | tree({ widgets: [], worker: null, secrets: [] }, { TURNSTILE_SITE_KEY: "0x4AAAAAAAmine" }); |
| 249 | expect(render({ TURNSTILE_SITE_KEY: "0x4AAAAAAAold" }).vars.TURNSTILE_SITE_KEY).toBe("0x4AAAAAAAmine"); |
| 250 | // Keys don't open sign-up: render-config never sets SIGNUP. |
| 251 | expect(render({ TURNSTILE_SITE_KEY: "0x4AAAAAAAold" }).vars.SIGNUP).toBeUndefined(); |
| 252 | }); |
| 253 | }); |