| 1 | // End-to-end Agent setup (lib/agent-setup.ts, web/agent-setup.tsx) against a real `wrangler dev` Worker: a |
| 2 | // person generates a token on Settings → Agent setup, and a fresh machine (its own HOME, nothing installed) |
| 3 | // runs the hosted setup script with only the page's environment variables, as a Claude Code cloud |
| 4 | // environment does. |
| 5 | // |
| 6 | // pnpm --filter @diffinery/app exec node --test test/e2e/agent-setup.test.mjs |
| 7 | // |
| 8 | // Covers: the page (people with write access in a browser only), the script (no secret in it, a 404 for a |
| 9 | // name that can't be a repository), the CLI install, git and gh wired to the environment with no token on |
| 10 | // disk, the clone, the self-test and the Check turning green, a second run skipping the download, failure |
| 11 | // lines that say what to change, the token reaching only its repository (not even a public one), merging |
| 12 | // only what a person approved and never a workflow change, turning merging off, and revoke, regenerate |
| 13 | // and renew. gh only talks HTTPS, so a stand-in gh (below) does what gh does with GH_ENTERPRISE_TOKEN and |
| 14 | // GH_HOST; the real gh runs against diffinery.com in the live check. |
| 15 | import { spawn, execFileSync } from "node:child_process"; |
| 16 | import { createHash, randomBytes } from "node:crypto"; |
| 17 | import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; |
| 18 | import { tmpdir } from "node:os"; |
| 19 | import { join, dirname, resolve } from "node:path"; |
| 20 | import { fileURLToPath } from "node:url"; |
| 21 | import { test, before, after } from "node:test"; |
| 22 | import assert from "node:assert/strict"; |
| 23 | import { migratedState } from "./d1-state.mjs"; |
| 24 | import { freePort } from "./ports.mjs"; |
| 25 | |
| 26 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 27 | const work = mkdtempSync(join(tmpdir(), "diffinery-agent-setup-")); |
| 28 | let base; |
| 29 | let host; |
| 30 | let dev; |
| 31 | let devLog = ""; |
| 32 | |
| 33 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 34 | const tok = (name) => `dfy_${name}${randomBytes(12).toString("hex")}`; |
| 35 | const T = { ada: tok("ada"), bob: tok("bob"), wes: tok("wes") }; |
| 36 | const S = { ada: `ada-session-${randomBytes(12).toString("hex")}`, bob: `bob-session-${randomBytes(12).toString("hex")}`, wes: `wes-session-${randomBytes(12).toString("hex")}` }; |
| 37 | |
| 38 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 39 | async function until(what, fn, timeoutMs = 120_000) { |
| 40 | const end = Date.now() + timeoutMs; |
| 41 | while (Date.now() < end) { |
| 42 | const v = await fn(); |
| 43 | if (v) return v; |
| 44 | await sleep(500); |
| 45 | } |
| 46 | assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); |
| 47 | } |
| 48 | |
| 49 | async function api(method, path, body, as) { |
| 50 | const headers = { "content-type": "application/json" }; |
| 51 | if (as) headers.authorization = `Bearer ${as}`; |
| 52 | const res = await fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); |
| 53 | const text = await res.text(); |
| 54 | let json = null; |
| 55 | try { |
| 56 | json = text ? JSON.parse(text) : null; |
| 57 | } catch { |
| 58 | json = text; |
| 59 | } |
| 60 | return { status: res.status, json }; |
| 61 | } |
| 62 | async function ok(method, path, body, as) { |
| 63 | const r = await api(method, path, body, as); |
| 64 | assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); |
| 65 | return r.json; |
| 66 | } |
| 67 | const b64 = (s) => Buffer.from(s).toString("base64"); |
| 68 | |
| 69 | async function page(session, path) { |
| 70 | const res = await fetch(`${base}${path}`, { headers: session ? { cookie: `__Host-dfy_session=${session}` } : {}, redirect: "manual" }); |
| 71 | return { status: res.status, location: res.headers.get("location"), text: await res.text() }; |
| 72 | } |
| 73 | /** A form post as `session`, with the CSRF pair from the page at `from`. */ |
| 74 | async function submit(session, from, action, fields = {}) { |
| 75 | const cookie = `__Host-dfy_session=${session}`; |
| 76 | const res = await fetch(`${base}${from}`, { headers: { cookie } }); |
| 77 | const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1]; |
| 78 | const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1]; |
| 79 | assert.ok(csrf && pair, `no CSRF pair on ${from} (${res.status})`); |
| 80 | const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } }); |
| 81 | return { status: r.status, location: r.headers.get("location"), text: await r.text(), flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")) }; |
| 82 | } |
| 83 | const unescape = (s) => s.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); |
| 84 | /** The Environment variables box's text. */ |
| 85 | const envBox = (html) => unescape(/<pre[^>]*id="agent-env"[^>]*>([\s\S]*?)<\/pre>/.exec(html)?.[1] ?? "").trim(); |
| 86 | |
| 87 | /** |
| 88 | * A stand-in for the GitHub CLI: what gh does with GH_ENTERPRISE_TOKEN and GH_HOST (it needs HTTPS, and this |
| 89 | * server is plain HTTP). `gh auth login` refuses while the token is in the environment, as gh does. |
| 90 | */ |
| 91 | function fakeGh(dir) { |
| 92 | mkdirSync(dir, { recursive: true }); |
| 93 | writeFileSync( |
| 94 | join(dir, "gh"), |
| 95 | `#!/usr/bin/env node |
| 96 | const { execFileSync } = require("node:child_process"); |
| 97 | const [cmd, sub, ...rest] = process.argv.slice(2); |
| 98 | if (cmd === "--version") { console.log("gh version 2.0.0 (stand-in)"); process.exit(0); } |
| 99 | const token = process.env.GH_ENTERPRISE_TOKEN; |
| 100 | if (cmd === "auth") { if (token) { console.error("The value of the GH_ENTERPRISE_TOKEN environment variable is being used for authentication."); process.exit(1); } process.exit(0); } |
| 101 | const arg = (n) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : undefined; }; |
| 102 | let host = arg("--hostname") ?? process.env.GH_HOST; |
| 103 | let path; |
| 104 | if (cmd === "api" && sub === "user") path = "/api/v3/user"; |
| 105 | else if (cmd === "pr" && sub === "list") { |
| 106 | // In a clone gh finds the repository from its remote, on a host it has a token for (GH_HOST with an environment token). |
| 107 | const remote = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim(); |
| 108 | const u = new URL(remote); |
| 109 | if (u.host !== process.env.GH_HOST) { console.error("none of the git remotes configured for this repository point to a known GitHub host"); process.exit(1); } |
| 110 | path = "/api/v3/repos" + u.pathname.replace(/\\.git$/, "") + "/pulls?state=all"; |
| 111 | } else { console.error("stand-in gh: unsupported " + cmd + " " + sub); process.exit(2); } |
| 112 | if (!token || !host) { console.error("To get started with GitHub CLI, please run: gh auth login"); process.exit(4); } |
| 113 | fetch("http://" + host + path, { headers: { authorization: "token " + token } }).then(async (r) => { |
| 114 | const j = await r.json(); |
| 115 | if (!r.ok) { console.error("HTTP " + r.status + ": " + j.message); process.exit(1); } |
| 116 | console.log(cmd === "api" ? j.login : JSON.stringify(j.map((p) => ({ number: p.number })))); |
| 117 | }); |
| 118 | `, |
| 119 | ); |
| 120 | chmodSync(join(dir, "gh"), 0o755); |
| 121 | } |
| 122 | |
| 123 | /** A fresh machine: its own HOME, git with no global config, a bin directory on PATH to link diffinery from, and the stand-in gh. */ |
| 124 | function machine(name) { |
| 125 | const root = join(work, name); |
| 126 | const home = join(root, "home"); |
| 127 | const bin = join(root, "bin"); |
| 128 | const ghDir = join(root, "gh"); |
| 129 | const where = join(root, "work"); |
| 130 | for (const d of [home, bin, where]) mkdirSync(d, { recursive: true }); |
| 131 | fakeGh(ghDir); |
| 132 | const env = { |
| 133 | HOME: home, |
| 134 | PATH: `${bin}:${ghDir}:${process.env.PATH}`, |
| 135 | DIFFINERY_LINK_DIR: bin, |
| 136 | DIFFINERY_CLONE_DIR: where, |
| 137 | GIT_CONFIG_NOSYSTEM: "1", |
| 138 | NO_COLOR: "1", |
| 139 | LANG: "C.UTF-8", |
| 140 | }; |
| 141 | return { root, home, bin, where, env }; |
| 142 | } |
| 143 | |
| 144 | /** |
| 145 | * Runs a command without blocking the event loop (a blocked loop leaves fetch a keep-alive connection the |
| 146 | * server already closed). Resolves with its exit code and output; `check` rejects on a non-zero exit. |
| 147 | */ |
| 148 | function sh(cmd, args, opts = {}, check = false) { |
| 149 | return new Promise((res, rej) => { |
| 150 | const p = spawn(cmd, args, { ...opts, stdio: ["ignore", "pipe", "pipe"] }); |
| 151 | let stdout = ""; |
| 152 | let stderr = ""; |
| 153 | p.stdout.on("data", (d) => (stdout += d)); |
| 154 | p.stderr.on("data", (d) => (stderr += d)); |
| 155 | const timer = setTimeout(() => p.kill("SIGKILL"), 180_000); |
| 156 | p.on("error", rej); |
| 157 | p.on("close", (code) => { |
| 158 | clearTimeout(timer); |
| 159 | if (check && code !== 0) rej(new Error(`${cmd} ${args.join(" ")} exited ${code}: ${stderr}`)); |
| 160 | else res({ code, stdout, stderr, out: `${stdout}${stderr}` }); |
| 161 | }); |
| 162 | }); |
| 163 | } |
| 164 | |
| 165 | /** Runs the setup line from the page, as a setup script field would. */ |
| 166 | const runSetup = (m, extra) => sh("bash", ["-c", `curl -fsSL ${base}/ada/zutrix/agent-setup.sh | bash`], { cwd: m.root, env: { ...m.env, ...extra } }); |
| 167 | |
| 168 | const auth = (token) => ["-c", `http.extraHeader=Authorization: Basic ${b64(`x-token:${token}`)}`]; |
| 169 | const git = async (cwd, args, token, env = {}) => (await sh("git", [...auth(token), ...args], { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env } }, true)).stdout; |
| 170 | const gitFails = async (cwd, args, token) => { |
| 171 | const r = await sh("git", [...auth(token), ...args], { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }); |
| 172 | assert.notEqual(r.code, 0, `git ${args.join(" ")} should fail`); |
| 173 | return r.stderr; |
| 174 | }; |
| 175 | |
| 176 | before(async () => { |
| 177 | const port = await freePort(); |
| 178 | base = `http://127.0.0.1:${port}`; |
| 179 | host = `127.0.0.1:${port}`; |
| 180 | const persist = join(work, "state"); |
| 181 | const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 182 | migratedState(APP, persist); |
| 183 | const at = new Date().toISOString(); |
| 184 | const later = new Date(Date.now() + 30 * 86400_000).toISOString(); |
| 185 | const account = (id, login) => `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('${id}', '${login}', 'user', '${login}', '${login}@example.com', 'active', '${at}')`; |
| 186 | const token = (id, acct, t) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${acct}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '["admin"]', '${later}', '${at}')`; |
| 187 | const session = (acct, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${acct}', 'passkey', '${at}', '${at}', '${later}')`; |
| 188 | wrangler( |
| 189 | "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", |
| 190 | [account("u-ada", "ada"), account("u-bob", "bob"), account("u-wes", "wes"), token("t-ada", "u-ada", T.ada), token("t-bob", "u-bob", T.bob), token("t-wes", "u-wes", T.wes), session("u-ada", S.ada), session("u-bob", S.bob), session("u-wes", S.wes)].join("; "), |
| 191 | ); |
| 192 | dev = spawn( |
| 193 | "pnpm", |
| 194 | ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], |
| 195 | { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, |
| 196 | ); |
| 197 | dev.stdout.on("data", (d) => (devLog += d)); |
| 198 | dev.stderr.on("data", (d) => (devLog += d)); |
| 199 | await until("wrangler dev", async () => { |
| 200 | try { |
| 201 | return (await fetch(`${base}/healthz`)).ok; |
| 202 | } catch { |
| 203 | return false; |
| 204 | } |
| 205 | }); |
| 206 | // ada's private repository zutrix (bob writes to it, wes reads it), another of hers, and bob's public one. |
| 207 | for (const [name, t, visibility] of [["zutrix", T.ada, "private"], ["other", T.ada, "private"], ["pub", T.bob, "public"]]) { |
| 208 | await ok("POST", "/user/repos", { name, visibility }, t); |
| 209 | await ok("PUT", `/repos/${t === T.ada ? "ada" : "bob"}/${name}/contents/README.md`, { message: "init", content: b64(`# ${name}\n`) }, t); |
| 210 | } |
| 211 | await ok("PUT", "/repos/ada/zutrix/collaborators/bob", { permission: "write" }, T.ada); |
| 212 | await ok("PUT", "/repos/ada/zutrix/collaborators/wes", { permission: "read" }, T.ada); |
| 213 | // Being added makes an invitation: bob and wes accept theirs. |
| 214 | for (const t of [T.bob, T.wes]) for (const inv of await ok("GET", "/user/repository_invitations", undefined, t)) await ok("PATCH", `/user/repository_invitations/${inv.id}`, undefined, t); |
| 215 | }); |
| 216 | |
| 217 | after(() => { |
| 218 | if (dev?.pid) { |
| 219 | try { |
| 220 | process.kill(-dev.pid, "SIGTERM"); |
| 221 | } catch {} |
| 222 | } |
| 223 | if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 224 | }); |
| 225 | |
| 226 | const PAGE = "/ada/zutrix/settings/agent-setup"; |
| 227 | let token; |
| 228 | |
| 229 | test("the page: for people with write access, in a browser", async () => { |
| 230 | const anon = await page(null, PAGE); |
| 231 | assert.equal(anon.status, 302, "signed out: off to sign in"); |
| 232 | assert.match(anon.location ?? "", /\/login/); |
| 233 | assert.equal((await page(S.wes, PAGE)).status, 403, "read access isn't enough"); |
| 234 | const res = await fetch(`${base}${PAGE}`, { headers: { authorization: `Bearer ${T.ada}` } }); |
| 235 | assert.equal(res.status, 403, "a token never sees the page"); |
| 236 | const p = await page(S.ada, PAGE); |
| 237 | assert.equal(p.status, 200); |
| 238 | for (const s of ["Agent setup", "Generate agent token", "Not connected yet", `curl -fsSL ${base}/ada/zutrix/agent-setup.sh | bash`, `GH_HOST=${host}`, "DIFFINERY_REPO=ada/zutrix", "Network secrets"]) assert.ok(unescape(p.text).includes(s), s); |
| 239 | // bob has write access but isn't an admin: Settings opens Agent setup, and it's the only tab. |
| 240 | const b = await page(S.bob, PAGE); |
| 241 | assert.equal(b.status, 200); |
| 242 | assert.ok(b.text.includes('href="/ada/zutrix/settings/agent-setup"')); |
| 243 | assert.ok(!b.text.includes('href="/ada/zutrix/settings/branches"')); |
| 244 | assert.equal((await page(S.bob, "/ada/zutrix/settings")).status, 403); |
| 245 | }); |
| 246 | |
| 247 | test("generating a token makes ada's agent for this repository, and fills it in once", async () => { |
| 248 | const r = await submit(S.ada, PAGE, `${PAGE}/generate`, { name: "Claude Code cloud · e2e" }); |
| 249 | assert.equal(r.status, 200, r.text.slice(0, 500)); |
| 250 | const env = envBox(r.text); |
| 251 | token = /^DIFFINERY_TOKEN=(dfy_[A-Za-z0-9]+)$/m.exec(env)?.[1]; |
| 252 | assert.ok(token, env); |
| 253 | assert.equal(env, [`DIFFINERY_TOKEN=${token}`, `GH_ENTERPRISE_TOKEN=${token}`, `GH_HOST=${host}`, "DIFFINERY_REPO=ada/zutrix", `DIFFINERY_URL=${base}`].join("\n")); |
| 254 | const me = await ok("GET", "/user", undefined, token); |
| 255 | assert.equal(me.login, "zutrix-agent"); |
| 256 | assert.equal(me.type, "Bot"); |
| 257 | // Shown once: the page masks it afterwards. |
| 258 | const again = await page(S.ada, PAGE); |
| 259 | assert.ok(!again.text.includes(token)); |
| 260 | assert.match(envBox(again.text), new RegExp(`^DIFFINERY_TOKEN=${token.slice(0, 10)}…$`, "m")); |
| 261 | assert.ok(again.text.includes("Claude Code cloud · e2e")); |
| 262 | }); |
| 263 | |
| 264 | test("the hosted script holds no secret, and only answers for names a repository can have", async () => { |
| 265 | const res = await fetch(`${base}/ada/zutrix/agent-setup.sh`); |
| 266 | assert.equal(res.status, 200); |
| 267 | const s = await res.text(); |
| 268 | assert.match(s, new RegExp(`^server=${base.replace(/[.]/g, "\\.")}$`, "m")); |
| 269 | assert.match(s, /^repo=ada\/zutrix$/m); |
| 270 | assert.match(s, /^cli_sha256=[0-9a-f]{64}$/m); |
| 271 | assert.doesNotMatch(s, /dfy_[A-Za-z0-9]{10}/); |
| 272 | assert.equal((await fetch(`${base}/no-such-owner/whatever/agent-setup.sh`)).status, 200, "says nothing about whether the repository exists"); |
| 273 | assert.equal((await fetch(`${base}/ada/${encodeURIComponent("$(id)")}/agent-setup.sh`)).status, 404); |
| 274 | }); |
| 275 | |
| 276 | test("a fresh machine with only the page's three boxes: everything works and the Check turns green", async () => { |
| 277 | const m = machine("fresh"); |
| 278 | const env = Object.fromEntries(envBox((await page(S.ada, PAGE)).text).split("\n").map((l) => l.split("="))); |
| 279 | Object.assign(env, { DIFFINERY_TOKEN: token, GH_ENTERPRISE_TOKEN: token }); |
| 280 | const first = await runSetup(m, env); |
| 281 | assert.equal(first.code, 0, first.out); |
| 282 | assert.ok(!first.out.includes(token), "never prints the token"); |
| 283 | assert.match(first.out, /✓ diffinery CLI: installed diffinery \d/); |
| 284 | assert.ok(!first.out.includes("sign in through your browser"), "no advice meant for people"); |
| 285 | for (const s of ["✓ server:", "✓ diffinery on PATH:", "✓ DIFFINERY_TOKEN: acts as zutrix-agent (agent)", "✓ repository: ada/zutrix with write access", "✓ git:", "✓ clone: cloned into", "✓ gh: gh api acts as zutrix-agent", "✓ gh in the clone:", "Ready:"]) assert.ok(first.out.includes(s), `${s}\n${first.out}`); |
| 286 | const all = { ...m.env, ...env }; |
| 287 | // The acceptance checks, as the session would run them: no path prefix, no prompt. |
| 288 | assert.match((await sh("bash", ["-c", "diffinery --version"], { env: all }, true)).stdout, /0\.\d/); |
| 289 | assert.match((await sh("bash", ["-c", "diffinery whoami"], { env: all }, true)).stdout, /zutrix-agent/); |
| 290 | const fresh = join(m.root, "second-clone"); |
| 291 | await sh("git", ["clone", "-q", `${base}/ada/zutrix.git`, fresh], { env: { ...all, GIT_TERMINAL_PROMPT: "0" } }, true); |
| 292 | assert.ok(existsSync(join(fresh, "README.md"))); |
| 293 | assert.equal((await sh("gh", ["api", "user", "--hostname", host, "--jq", ".login"], { env: all }, true)).stdout.trim(), "zutrix-agent"); |
| 294 | await sh("gh", ["pr", "list", "--state", "all"], { cwd: join(m.where, "zutrix"), env: all }, true); |
| 295 | // No token anywhere on disk: git and gh read it from the environment. |
| 296 | const files = (dir) => readdirSync(dir, { recursive: true }).map((f) => join(dir, String(f))).filter((f) => statSync(f).isFile() && statSync(f).size < 5_000_000); |
| 297 | for (const f of files(m.home)) assert.ok(!readFileSync(f, "utf8").includes(token), `${f} holds the token`); |
| 298 | // The Check, on the page and in the API. |
| 299 | const check = await ok("GET", "/repos/ada/zutrix/diffinery/agent-setup", undefined, T.ada); |
| 300 | assert.equal(check.check.ok, true, JSON.stringify(check)); |
| 301 | assert.equal(check.check.account, "zutrix-agent"); |
| 302 | assert.equal(check.check.token.name, "Claude Code cloud · e2e"); |
| 303 | const fragment = await page(S.ada, `${PAGE}/check`); |
| 304 | assert.match(fragment.text, /Connected\./); |
| 305 | |
| 306 | // Every session start: the CLI isn't downloaded again, and the clone is brought up to date. |
| 307 | await ok("PUT", "/repos/ada/zutrix/contents/NEWS.md", { message: "news", content: b64("news\n") }, T.ada); |
| 308 | const started = Date.now(); |
| 309 | const second = await runSetup(m, env); |
| 310 | assert.equal(second.code, 0, second.out); |
| 311 | assert.ok(second.out.includes("diffinery CLI: already the current build"), second.out); |
| 312 | assert.ok(second.out.includes("(up to date)"), second.out); |
| 313 | assert.ok(existsSync(join(m.where, "zutrix", "NEWS.md"))); |
| 314 | assert.ok(Date.now() - started < 30_000, `second run took ${Date.now() - started} ms`); |
| 315 | }); |
| 316 | |
| 317 | test("failures say what to change, and a missing token doesn't fail the session", async () => { |
| 318 | const m = machine("broken"); |
| 319 | const missing = await runSetup(m, {}); |
| 320 | assert.equal(missing.code, 0, missing.out); |
| 321 | assert.match(missing.out, /DIFFINERY_TOKEN is not set: generate a token on .*\/ada\/zutrix\/settings\/agent-setup/); |
| 322 | const noHost = await runSetup(m, { DIFFINERY_TOKEN: token, GH_ENTERPRISE_TOKEN: token, DIFFINERY_NO_CLONE: "1" }); |
| 323 | assert.equal(noHost.code, 1, noHost.out); |
| 324 | assert.ok(noHost.out.includes(`✗ GH_HOST: not set: add GH_HOST=${host} to the environment variables`), noHost.out); |
| 325 | const mismatch = await runSetup(m, { DIFFINERY_TOKEN: token, GH_ENTERPRISE_TOKEN: T.bob, GH_HOST: host, DIFFINERY_NO_CLONE: "1" }); |
| 326 | assert.equal(mismatch.code, 1); |
| 327 | assert.ok(mismatch.out.includes("GH_ENTERPRISE_TOKEN isn't the same token as DIFFINERY_TOKEN"), mismatch.out); |
| 328 | assert.ok(!mismatch.out.includes(T.bob) && !mismatch.out.includes(token)); |
| 329 | const refused = await runSetup(m, { DIFFINERY_TOKEN: "dfy_notarealtoken0000", DIFFINERY_NO_CLONE: "1" }); |
| 330 | assert.equal(refused.code, 1); |
| 331 | assert.ok(refused.out.includes("refused (revoked or expired)"), refused.out); |
| 332 | // A failed run is the Check now, saying what failed. |
| 333 | const check = await ok("GET", "/repos/ada/zutrix/diffinery/agent-setup", undefined, T.ada); |
| 334 | assert.equal(check.check.ok, false); |
| 335 | assert.ok(check.check.checks.some((l) => l.name === "gh" && !l.ok)); |
| 336 | }); |
| 337 | |
| 338 | test("the token reaches only its repository", async () => { |
| 339 | assert.equal((await api("GET", "/repos/ada/other", undefined, token)).status, 404); |
| 340 | assert.equal((await api("GET", "/repos/bob/pub", undefined, token)).status, 404, "not even a public one"); |
| 341 | assert.deepEqual((await ok("GET", "/user/repos", undefined, token)).map((r) => r.full_name), ["ada/zutrix"]); |
| 342 | const dir = mkdtempSync(join(work, "iso-")); |
| 343 | assert.match(await gitFails(dir, ["clone", "-q", `${base}/ada/other.git`, join(dir, "o")], token), /not found|401|403|404|Authentication/i); |
| 344 | await git(dir, ["clone", "-q", `${base}/ada/zutrix.git`, join(dir, "z")], token); |
| 345 | await git(join(dir, "z"), ["checkout", "-q", "-b", "feature"], token); |
| 346 | writeFileSync(join(dir, "z", "x.txt"), "x\n"); |
| 347 | await git(join(dir, "z"), ["add", "."], token, { GIT_AUTHOR_NAME: "a", GIT_AUTHOR_EMAIL: "a@x", GIT_COMMITTER_NAME: "a", GIT_COMMITTER_EMAIL: "a@x" }); |
| 348 | await git(join(dir, "z"), ["commit", "-qm", "x"], token, { GIT_AUTHOR_NAME: "a", GIT_AUTHOR_EMAIL: "a@x", GIT_COMMITTER_NAME: "a", GIT_COMMITTER_EMAIL: "a@x" }); |
| 349 | assert.match(await gitFails(join(dir, "z"), ["push", "-q", `${base}/ada/other.git`, "feature"], token), /not found|denied|403|404/i); |
| 350 | assert.match(await gitFails(join(dir, "z"), ["push", "-q", "origin", "HEAD:main"], token), /agents may not push/); |
| 351 | await git(join(dir, "z"), ["push", "-q", "origin", "feature"], token); |
| 352 | // Nor can it mint anything, or change account settings. |
| 353 | assert.equal((await api("POST", "/diffinery/tokens", { name: "x" }, token)).status, 403); |
| 354 | assert.equal((await api("PUT", "/repos/ada/zutrix/actions/secrets/X", { encrypted_value: "x" }, token)).status, 403); |
| 355 | }); |
| 356 | |
| 357 | test("merging: only what a person approved, never a workflow change, and off when ada says so", async () => { |
| 358 | const pr = await ok("POST", "/repos/ada/zutrix/pulls", { title: "Add x", head: "feature", base: "main" }, token); |
| 359 | const merge = () => api("PUT", `/repos/ada/zutrix/pulls/${pr.number}/merge`, { merge_method: "squash" }, token); |
| 360 | const before = await merge(); |
| 361 | assert.equal(before.status, 405, JSON.stringify(before.json)); |
| 362 | assert.match(before.json.message, /needs 1 more approving review\(s\) of its latest commit/); |
| 363 | await ok("POST", `/repos/ada/zutrix/pulls/${pr.number}/reviews`, { event: "APPROVE" }, T.ada); |
| 364 | const merged = await merge(); |
| 365 | assert.equal(merged.status, 200, JSON.stringify(merged.json)); |
| 366 | |
| 367 | // A change to a workflow file needs a person, approved or not. |
| 368 | const wf = await ok("POST", "/repos/ada/zutrix/git/refs", { ref: "refs/heads/wf", sha: (await ok("GET", "/repos/ada/zutrix/git/ref/heads/main", undefined, T.ada)).object.sha }, T.ada); |
| 369 | assert.ok(wf); |
| 370 | await ok("PUT", "/repos/ada/zutrix/contents/.github/workflows/ci.yml", { message: "ci", content: b64("on: push\njobs: {}\n"), branch: "wf" }, T.ada); |
| 371 | const wfPr = await ok("POST", "/repos/ada/zutrix/pulls", { title: "CI", head: "wf", base: "main" }, T.ada); |
| 372 | await ok("POST", `/repos/ada/zutrix/pulls/${wfPr.number}/reviews`, { event: "APPROVE" }, T.bob); |
| 373 | const wfMerge = await api("PUT", `/repos/ada/zutrix/pulls/${wfPr.number}/merge`, {}, token); |
| 374 | assert.equal(wfMerge.status, 405); |
| 375 | assert.match(wfMerge.json.message, /changes workflow file `\.github\/workflows\/ci\.yml`; a person must merge it/); |
| 376 | const wfAuto = await api("PUT", `/repos/ada/zutrix/pulls/${wfPr.number}/auto-merge`, {}, token); |
| 377 | assert.equal(wfAuto.status, 422, JSON.stringify(wfAuto.json)); |
| 378 | |
| 379 | // Merging off: the agent rule is back. |
| 380 | await ok("PUT", "/repos/ada/zutrix/contents/y.txt", { message: "y", content: b64("y\n"), branch: "wf" }, T.ada); |
| 381 | const off = await submit(S.ada, PAGE, `${PAGE}/merge`, {}); |
| 382 | assert.equal(off.status, 303); |
| 383 | assert.match(off.flash, /can no longer merge/); |
| 384 | const plain = await ok("POST", "/repos/ada/zutrix/git/refs", { ref: "refs/heads/plain", sha: (await ok("GET", "/repos/ada/zutrix/git/ref/heads/main", undefined, T.ada)).object.sha }, T.ada); |
| 385 | assert.ok(plain); |
| 386 | await ok("PUT", "/repos/ada/zutrix/contents/z.txt", { message: "z", content: b64("z\n"), branch: "plain" }, T.ada); |
| 387 | const plainPr = await ok("POST", "/repos/ada/zutrix/pulls", { title: "z", head: "plain", base: "main" }, T.ada); |
| 388 | await ok("POST", `/repos/ada/zutrix/pulls/${plainPr.number}/reviews`, { event: "APPROVE" }, T.bob); |
| 389 | const refused = await api("PUT", `/repos/ada/zutrix/pulls/${plainPr.number}/merge`, {}, token); |
| 390 | assert.equal(refused.status, 405); |
| 391 | assert.match(refused.json.message, /agents may not merge into this branch/); |
| 392 | const on = await submit(S.ada, PAGE, `${PAGE}/merge`, { mayMerge: "1" }); |
| 393 | assert.match(on.flash, /can merge pull requests a person approved/); |
| 394 | assert.equal((await api("PUT", `/repos/ada/zutrix/pulls/${plainPr.number}/merge`, {}, token)).status, 200); |
| 395 | }); |
| 396 | |
| 397 | test("renew, revoke and regenerate; an admin turns off someone else's agent", async () => { |
| 398 | const html = (await page(S.ada, PAGE)).text; |
| 399 | const id = /\/settings\/agent-setup\/tokens\/([^/]+)\/renew/.exec(html)?.[1]; |
| 400 | assert.ok(id); |
| 401 | const renewed = await submit(S.ada, PAGE, `${PAGE}/tokens/${id}/renew`); |
| 402 | assert.match(renewed.flash, /Renewed: the token works until/); |
| 403 | const regen = await submit(S.ada, PAGE, `${PAGE}/tokens/${id}/regenerate`); |
| 404 | assert.equal(regen.status, 200); |
| 405 | const next = /^DIFFINERY_TOKEN=(dfy_[A-Za-z0-9]+)$/m.exec(envBox(regen.text))?.[1]; |
| 406 | assert.ok(next && next !== token); |
| 407 | assert.equal((await api("GET", "/user", undefined, token)).status, 401, "the old token stops at once"); |
| 408 | assert.equal((await ok("GET", "/user", undefined, next)).login, "zutrix-agent", "the same agent"); |
| 409 | |
| 410 | // bob's own agent here; ada, an admin, turns it off. |
| 411 | const b = await submit(S.bob, PAGE, `${PAGE}/generate`, { name: "bob's" }); |
| 412 | const bobToken = /^DIFFINERY_TOKEN=(dfy_[A-Za-z0-9]+)$/m.exec(envBox(b.text))?.[1]; |
| 413 | const bobAgent = (await ok("GET", "/user", undefined, bobToken)).login; |
| 414 | assert.equal(bobAgent, "bob-zutrix-agent"); |
| 415 | const adaView = (await page(S.ada, PAGE)).text; |
| 416 | const agentId = /\/settings\/agent-setup\/agents\/([^/]+)\/disable/.exec(adaView)?.[1]; |
| 417 | assert.ok(agentId, "ada sees bob's agent"); |
| 418 | const off = await submit(S.ada, PAGE, `${PAGE}/agents/${agentId}/disable`); |
| 419 | assert.equal(off.status, 303); |
| 420 | assert.equal((await api("GET", "/user", undefined, bobToken)).status, 401); |
| 421 | // bob still has write access, so he can generate again: the agent comes back, its old tokens don't. |
| 422 | const back = await submit(S.bob, PAGE, `${PAGE}/generate`, { name: "bob's again" }); |
| 423 | const newer = /^DIFFINERY_TOKEN=(dfy_[A-Za-z0-9]+)$/m.exec(envBox(back.text))?.[1]; |
| 424 | assert.equal((await ok("GET", "/user", undefined, newer)).login, "bob-zutrix-agent"); |
| 425 | assert.equal((await api("GET", "/user", undefined, bobToken)).status, 401, "turned off for good"); |
| 426 | // bob, not an admin, can't do the same to ada's. |
| 427 | assert.equal((await submit(S.bob, PAGE, `${PAGE}/agents/whatever/disable`)).status, 403); |
| 428 | }); |