| 1 | /** |
| 2 | * The whole service, on Node, opening builds for g1t Actions jobs |
| 3 | * (`start_from_workflow`): a D1 made of node:sqlite with the service's |
| 4 | * migrations, the other services as fakes that answer by method, and |
| 5 | * Cloudflare's API as a recorded fetch. See moves.flow.test.ts. |
| 6 | */ |
| 7 | |
| 8 | import assert from "node:assert/strict"; |
| 9 | import { readdirSync, readFileSync } from "node:fs"; |
| 10 | import { registerHooks } from "node:module"; |
| 11 | import { DatabaseSync } from "node:sqlite"; |
| 12 | import { beforeEach, test } from "node:test"; |
| 13 | |
| 14 | registerHooks({ |
| 15 | resolve(specifier, context, next) { |
| 16 | try { |
| 17 | return next(specifier, context); |
| 18 | } catch (error) { |
| 19 | if (specifier.startsWith(".")) return next(`${specifier}.ts`, context); |
| 20 | throw error; |
| 21 | } |
| 22 | }, |
| 23 | }); |
| 24 | const { default: worker } = await import("./index.ts"); |
| 25 | |
| 26 | function d1(db: DatabaseSync) { |
| 27 | const value = (v: unknown) => (v === undefined ? null : typeof v === "boolean" ? Number(v) : v); |
| 28 | const statement = (sql: string, params: unknown[] = []) => ({ |
| 29 | sql, |
| 30 | params, |
| 31 | bind: (...args: unknown[]) => statement(sql, args.map(value)), |
| 32 | async first<T>() { |
| 33 | return (db.prepare(sql).get(...(params as never[])) as T) ?? null; |
| 34 | }, |
| 35 | async all<T>() { |
| 36 | return { results: db.prepare(sql).all(...(params as never[])) as T[], success: true }; |
| 37 | }, |
| 38 | async run() { |
| 39 | const r = db.prepare(sql).run(...(params as never[])); |
| 40 | return { success: true, meta: { changes: Number(r.changes) } }; |
| 41 | }, |
| 42 | }); |
| 43 | return { |
| 44 | prepare: (sql: string) => statement(sql), |
| 45 | async batch(statements: ReturnType<typeof statement>[]) { |
| 46 | db.exec("BEGIN"); |
| 47 | try { |
| 48 | const out = statements.map((s) => ({ results: db.prepare(s.sql).all(...(s.params as never[])) })); |
| 49 | db.exec("COMMIT"); |
| 50 | return out; |
| 51 | } catch (error) { |
| 52 | db.exec("ROLLBACK"); |
| 53 | throw error; |
| 54 | } |
| 55 | }, |
| 56 | }; |
| 57 | } |
| 58 | |
| 59 | const REPO = "rep_web"; |
| 60 | const PROJECT = "prj_web"; |
| 61 | const SHA = "4b8d0f2a6c1e3579bd02468ace13579bdf02468a"; |
| 62 | |
| 63 | function world() { |
| 64 | const sqlite = new DatabaseSync(":memory:"); |
| 65 | const dir = new URL("../migrations/", import.meta.url); |
| 66 | for (const file of readdirSync(dir).sort()) sqlite.exec(readFileSync(new URL(file, dir), "utf8")); |
| 67 | const state = { |
| 68 | sqlite, |
| 69 | run: { id: "run_1", event: "push", ref: "refs/heads/main", sha: SHA, pull: null as number | null, status: "in_progress", actor: "ada" }, |
| 70 | jobStatus: "in_progress", |
| 71 | approval: null as null | { state: string }, |
| 72 | pull: { status: "open", headCommit: SHA, branch: "feature" as string | null, fork: null as null | { namespace: string; name: string } }, |
| 73 | collaborator: "write", |
| 74 | plan: true, |
| 75 | limit: "ok", |
| 76 | /** What other services were asked, as `SERVICE method`. */ |
| 77 | calls: [] as string[], |
| 78 | /** What `resolve_settings` was asked for. */ |
| 79 | resolved: [] as { environment: string; trusted: boolean }[], |
| 80 | }; |
| 81 | const project = () => ({ |
| 82 | id: PROJECT, |
| 83 | workspace: "acme", |
| 84 | slug: "web", |
| 85 | name: "web", |
| 86 | primary: true, |
| 87 | source: { kind: "hosted", repoId: REPO, repo: { namespace: "acme", name: "web" }, defaultBranch: "main", rootDir: "site" }, |
| 88 | }); |
| 89 | const ok = (value: unknown) => ({ ok: true, value }); |
| 90 | const service = (name: string, answer: (method: string, args: any) => unknown) => ({ |
| 91 | async fetch(input: string | Request, init?: RequestInit) { |
| 92 | const url = typeof input === "string" ? input : input.url; |
| 93 | const method = new URL(url).pathname.split("/").pop()!; |
| 94 | state.calls.push(`${name} ${method}`); |
| 95 | const args = init?.body ? JSON.parse(String(init.body)) : {}; |
| 96 | return Response.json((await answer(method, args)) ?? ok(null)); |
| 97 | }, |
| 98 | }); |
| 99 | const env = { |
| 100 | DB: d1(sqlite), |
| 101 | CLOUDFLARE_API_TOKEN: "test", |
| 102 | CLOUDFLARE_ACCOUNT_ID: "acct", |
| 103 | DISPATCH_NAMESPACE: "g1t-deployments", |
| 104 | SITE: "https://g1t.sh", |
| 105 | REPOS: service("REPOS", (method) => { |
| 106 | if (method === "get") return ok({ id: REPO, namespace: "acme", name: "web", isPrivate: true, defaultBranch: "main" }); |
| 107 | return ok([]); |
| 108 | }), |
| 109 | PROJECTS: service("PROJECTS", (method) => (method === "by_repo" ? [project()] : ok(project()))), |
| 110 | IDENTITY: service("IDENTITY", (method, args) => { |
| 111 | if (method === "get_workspace") return { id: `wsp_${args.slug}`, slug: args.slug }; |
| 112 | if (method === "collaborator_permission") return ok({ role: state.collaborator }); |
| 113 | return ok(null); |
| 114 | }), |
| 115 | BILLING: service("BILLING", (method) => { |
| 116 | if (method === "has_feature") return state.plan ? ok(true) : { ok: false, error: { code: "payment_required", message: "Deployments need the Team plan." } }; |
| 117 | if (method === "check_limit") return ok({ state: state.limit, message: state.limit === "stopped" ? "The workspace reached its spend limit." : null }); |
| 118 | if (method === "prices") return { prices: [] }; |
| 119 | return ok(null); |
| 120 | }), |
| 121 | WORK: service("WORK", (method) => { |
| 122 | if (method === "get_pull") return ok({ pull: { ...state.pull, number: 9, author: { username: "eve", kind: "user" } } }); |
| 123 | return ok(null); |
| 124 | }), |
| 125 | ACTIONS: service("ACTIONS", (method, args) => { |
| 126 | if (method === "run") { |
| 127 | return ok({ run: state.run, jobs: [{ id: "job_1", status: state.jobStatus }], notes: [], approval: state.approval }); |
| 128 | } |
| 129 | if (method === "resolve_settings") { |
| 130 | state.resolved.push({ environment: args.environment, trusted: args.trusted }); |
| 131 | return { secrets: { API_KEY: "s3cret-value" }, variables: { PUBLIC_URL: "https://example.com" } }; |
| 132 | } |
| 133 | return ok(null); |
| 134 | }), |
| 135 | RUNNER: service("RUNNER", () => ok(true)), |
| 136 | }; |
| 137 | return { state, env }; |
| 138 | } |
| 139 | |
| 140 | let w: ReturnType<typeof world>; |
| 141 | const realFetch = globalThis.fetch; |
| 142 | |
| 143 | beforeEach(() => { |
| 144 | w = world(); |
| 145 | globalThis.fetch = (async (input: string | Request) => { |
| 146 | const url = new URL(typeof input === "string" ? input : input.url); |
| 147 | w.state.calls.push(`CLOUDFLARE ${url.pathname}`); |
| 148 | if (url.pathname.endsWith("/graphql")) return Response.json({ data: { viewer: { accounts: [{}] } } }); |
| 149 | return Response.json({ success: true, result: { jwt: "done", buckets: [] }, result_info: { total_pages: 1 } }); |
| 150 | }) as typeof fetch; |
| 151 | w.state.sqlite |
| 152 | .prepare( |
| 153 | `INSERT INTO settings (project_id, repo_id, workspace, slug, enabled, previews, production, idle_days, updated_at) |
| 154 | VALUES (?, ?, 'acme', 'web', 1, 1, 1, 7, '2026-10-05T00:00:00Z')`, |
| 155 | ) |
| 156 | .run(PROJECT, REPO); |
| 157 | }); |
| 158 | |
| 159 | process.on("exit", () => { |
| 160 | globalThis.fetch = realFetch; |
| 161 | }); |
| 162 | |
| 163 | const jobActor = (scopes: string[] = ["contents:read", "deployments:write"]) => ({ |
| 164 | id: "wsp_acme", |
| 165 | username: "acme", |
| 166 | kind: "workspace", |
| 167 | verified: true, |
| 168 | workspaces: [{ slug: "acme", role: "member" }], |
| 169 | token: { token_id: "tok_job", repo: "acme/web", job: { run_id: "run_1", job_id: "job_1" }, scopes }, |
| 170 | }); |
| 171 | |
| 172 | async function post(path: string, body: unknown): Promise<any> { |
| 173 | const response = await worker.fetch( |
| 174 | new Request(`https://deployments${path}`, { method: "POST", body: JSON.stringify(body) }), |
| 175 | w.env as never, |
| 176 | {} as never, |
| 177 | ); |
| 178 | return response.json(); |
| 179 | } |
| 180 | |
| 181 | const open = (body: Record<string, unknown> = {}) => |
| 182 | post("/rpc/start_from_workflow", { actor: jobActor(), repo: { namespace: "acme", name: "web" }, ...body }); |
| 183 | |
| 184 | const rows = (sql: string, ...params: unknown[]) => |
| 185 | (w.state.sqlite.prepare(sql).all(...(params as never[])) as any[]).map((row) => ({ ...row })); |
| 186 | |
| 187 | test("a push to the production branch opens a production build the job runs, with no sandbox and nothing reserved", async () => { |
| 188 | const answer = await open(); |
| 189 | assert.equal(answer.ok, true, JSON.stringify(answer)); |
| 190 | const build = answer.value; |
| 191 | assert.equal(build.kind, "production"); |
| 192 | assert.equal(build.project, "web"); |
| 193 | assert.equal(build.commit, SHA); |
| 194 | assert.equal(build.url, "https://web-acme.g1t.page"); |
| 195 | assert.equal(build.trusted, true); |
| 196 | assert.equal(build.root_dir, "site"); |
| 197 | assert.deepEqual(build.build_secrets, { API_KEY: "s3cret-value" }); |
| 198 | assert.deepEqual(build.build_env, { PUBLIC_URL: "https://example.com" }); |
| 199 | assert.match(build.token, /^[0-9a-f]{64}$/); |
| 200 | assert.deepEqual(rows("SELECT kind, status, run_id, job_id, created_by, trusted FROM deployments"), [ |
| 201 | { kind: "production", status: "queued", run_id: "run_1", job_id: "job_1", created_by: "ada", trusted: 1 }, |
| 202 | ]); |
| 203 | // The job runs it: no sandbox, and no compute reserved for it. |
| 204 | assert.ok(!w.state.calls.includes("RUNNER start_deploy")); |
| 205 | assert.ok(!w.state.calls.some((call) => call === "BILLING reserve" || call === "BILLING entitlements")); |
| 206 | // The commit's status says it is building. |
| 207 | assert.ok(w.state.calls.includes("WORK set_commit_status")); |
| 208 | }); |
| 209 | |
| 210 | test("the job reports with the build's token, and its seconds are not charged again", async () => { |
| 211 | const { value: build } = await open(); |
| 212 | assert.equal((await post(`/jobs/${build.deploy_id}/started`, { token: build.token })).ok, true); |
| 213 | const finished = await post(`/jobs/${build.deploy_id}/finish`, { |
| 214 | token: build.token, |
| 215 | worker: { compatibilityDate: "2026-09-26", vars: {}, notFoundHandling: "single-page-application" }, |
| 216 | completionJwt: "done", |
| 217 | buildSeconds: 42, |
| 218 | detected: "static", |
| 219 | }); |
| 220 | assert.equal(finished.ok, true, JSON.stringify(finished)); |
| 221 | assert.equal(rows("SELECT error FROM deployments")[0].error, null); |
| 222 | assert.deepEqual(rows("SELECT status, build_seconds, detected FROM deployments"), [{ status: "ready", build_seconds: 42, detected: "static" }]); |
| 223 | assert.deepEqual(rows("SELECT script, kind FROM apps"), [{ script: "web-acme", kind: "production" }]); |
| 224 | assert.ok(!w.state.calls.includes("BILLING charge_feature")); |
| 225 | assert.deepEqual(rows("SELECT build_seconds FROM meters WHERE build_seconds > 0"), []); |
| 226 | // The token was for that build alone, until it finished. |
| 227 | assert.equal((await post(`/jobs/${build.deploy_id}/started`, { token: build.token })).ok, false); |
| 228 | }); |
| 229 | |
| 230 | test("a sandbox's build is still charged by the second; a workflow job's never is", async () => { |
| 231 | // A classic build: what a push opens, built by g1t's sandbox. |
| 232 | const { createHash } = await import("node:crypto"); |
| 233 | const token = "f".repeat(64); |
| 234 | w.state.sqlite |
| 235 | .prepare( |
| 236 | `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha, script, status, |
| 237 | token_hash, trusted, created_by, created_at) |
| 238 | VALUES ('dpl_sandbox', ?, 'acme', 'web', ?, 'acme/web', 'preview', 'other', NULL, ?, 'web-git-other-acme', 'building', ?, 1, 'g1t', '2026-10-12T00:00:00Z')`, |
| 239 | ) |
| 240 | .run(PROJECT, REPO, SHA, createHash("sha256").update(token).digest("hex")); |
| 241 | const finish = { worker: { compatibilityDate: "2026-09-26", vars: {} }, completionJwt: "done", buildSeconds: 30 }; |
| 242 | assert.equal((await post("/jobs/dpl_sandbox/finish", { token, ...finish })).ok, true); |
| 243 | assert.equal(w.state.calls.filter((call) => call === "BILLING charge_feature").length, 1); |
| 244 | assert.deepEqual(rows("SELECT build_seconds FROM meters"), [{ build_seconds: 30 }]); |
| 245 | // A workflow job's build of the same project, finished and then failed: not charged. |
| 246 | const { value: done } = await open(); |
| 247 | assert.equal((await post(`/jobs/${done.deploy_id}/finish`, { token: done.token, ...finish })).ok, true); |
| 248 | const { value: failed } = await open(); |
| 249 | await post(`/jobs/${failed.deploy_id}/fail`, { token: failed.token, message: "npm run build failed", buildSeconds: 99 }); |
| 250 | assert.equal(w.state.calls.filter((call) => call === "BILLING charge_feature").length, 1); |
| 251 | assert.deepEqual(rows("SELECT build_seconds FROM meters"), [{ build_seconds: 30 }]); |
| 252 | // What runs after the build is the same app as any: in apps, which the sweep meters. |
| 253 | assert.deepEqual(rows("SELECT script, kind FROM apps ORDER BY script"), [ |
| 254 | { script: "web-acme", kind: "production" }, |
| 255 | { script: "web-git-other-acme", kind: "preview" }, |
| 256 | ]); |
| 257 | }); |
| 258 | |
| 259 | test("another branch builds its preview; a pull request's run builds the pull request's", async () => { |
| 260 | w.state.run = { ...w.state.run, ref: "refs/heads/feature/x" }; |
| 261 | const branch = (await open()).value; |
| 262 | assert.deepEqual([branch.kind, branch.branch, branch.number], ["preview", "feature/x", null]); |
| 263 | w.state.run = { ...w.state.run, event: "pull_request", ref: "refs/pull/9/merge", pull: 9 }; |
| 264 | const pull = (await open()).value; |
| 265 | assert.deepEqual([pull.kind, pull.branch, pull.number, pull.trusted], ["preview", "feature", 9, true]); |
| 266 | assert.deepEqual(w.state.resolved.map((r) => r.environment), ["preview", "preview"]); |
| 267 | }); |
| 268 | |
| 269 | test("a pull request from a fork, or one that had to be approved, gets no build secrets", async () => { |
| 270 | w.state.run = { ...w.state.run, event: "pull_request", ref: "refs/pull/9/merge", pull: 9 }; |
| 271 | w.state.pull = { ...w.state.pull, branch: null, fork: { namespace: "eve", name: "web" } }; |
| 272 | const fork = (await open()).value; |
| 273 | assert.deepEqual([fork.branch, fork.trusted, fork.build_secrets], ["pr-9", false, {}]); |
| 274 | w.state.pull = { ...w.state.pull, branch: "feature", fork: null }; |
| 275 | w.state.approval = { state: "approved" }; |
| 276 | const approved = (await open()).value; |
| 277 | assert.deepEqual([approved.trusted, approved.build_secrets], [false, {}]); |
| 278 | assert.deepEqual(w.state.resolved.map((r) => r.trusted), [false, false]); |
| 279 | }); |
| 280 | |
| 281 | test("it is refused without a job's write token, after the job, without the plan, or with previews off", async () => { |
| 282 | const refused = async (body: Record<string, unknown>) => (await open(body)).error?.code; |
| 283 | assert.equal(await refused({ actor: jobActor(["deployments:read"]) }), "forbidden"); |
| 284 | assert.equal(await refused({ actor: { ...jobActor(), token: { token_id: "tok_person", scopes: null } } }), "forbidden"); |
| 285 | assert.equal(await refused({ repo: { namespace: "acme", name: "api" } }), "forbidden"); |
| 286 | assert.equal(await refused({ project: "docs" }), "not_found"); |
| 287 | w.state.jobStatus = "completed"; |
| 288 | assert.equal(await refused({}), "conflict"); |
| 289 | w.state.jobStatus = "in_progress"; |
| 290 | w.state.run = { ...w.state.run, ref: "refs/tags/v1.0.0" }; |
| 291 | assert.equal(await refused({}), "invalid"); |
| 292 | w.state.run = { ...w.state.run, ref: "refs/heads/main" }; |
| 293 | w.state.plan = false; |
| 294 | const unpaid = await open(); |
| 295 | assert.deepEqual([unpaid.ok, unpaid.error.code], [false, "payment_required"]); |
| 296 | assert.deepEqual(rows("SELECT status FROM deployments"), [{ status: "skipped" }]); |
| 297 | w.state.plan = true; |
| 298 | w.state.limit = "stopped"; |
| 299 | const stopped = await open(); |
| 300 | assert.deepEqual([stopped.ok, stopped.error.code, stopped.error.message], [false, "payment_required", "The workspace reached its spend limit."]); |
| 301 | w.state.limit = "ok"; |
| 302 | w.state.sqlite.prepare("UPDATE settings SET previews = 0").run(); |
| 303 | w.state.run = { ...w.state.run, ref: "refs/heads/feature" }; |
| 304 | assert.equal(await refused({}), "conflict"); |
| 305 | assert.ok(!w.state.calls.includes("RUNNER start_deploy")); |
| 306 | }); |