| 1 | // End-to-end email: a real `wrangler dev` Worker whose Cloudflare Email Sending |
| 2 | // binding is simulated by Miniflare (it accepts the message and returns a |
| 3 | // message id), the real `diffinery-mail` queue and the real runner agent. |
| 4 | // |
| 5 | // pnpm --filter @diffinery/runner build && pnpm --filter @diffinery/app e2e |
| 6 | // |
| 7 | // Covers: security emails (tokens, agents), notification settings, the test |
| 8 | // email, every template, watching repositories you create, @mentions, your |
| 9 | // own activity staying quiet, one-click unsubscribe (RFC 8058) muting a |
| 10 | // thread while mentions still get through, and failed workflow run emails. |
| 11 | import { spawn, execFileSync } from "node:child_process"; |
| 12 | import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs"; |
| 13 | import { tmpdir } from "node:os"; |
| 14 | import { randomBytes } from "node:crypto"; |
| 15 | import { join, dirname, resolve } from "node:path"; |
| 16 | import { fileURLToPath } from "node:url"; |
| 17 | import { createServer } from "node:net"; |
| 18 | import { test, before, after } from "node:test"; |
| 19 | import assert from "node:assert/strict"; |
| 20 | |
| 21 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 22 | const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs"); |
| 23 | const work = mkdtempSync(join(tmpdir(), "diffinery-email-e2e-")); |
| 24 | let base; |
| 25 | let dev; |
| 26 | let devLog = ""; |
| 27 | let token; |
| 28 | let me; |
| 29 | |
| 30 | const freePort = () => |
| 31 | new Promise((res) => { |
| 32 | const s = createServer(); |
| 33 | s.listen(0, "127.0.0.1", () => { |
| 34 | const { port } = s.address(); |
| 35 | s.close(() => res(port)); |
| 36 | }); |
| 37 | }); |
| 38 | |
| 39 | async function api(method, path, body, tok = token) { |
| 40 | const res = await fetch(`${base}/api/v3${path}`, { |
| 41 | method, |
| 42 | headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" }, |
| 43 | body: body === undefined ? undefined : JSON.stringify(body), |
| 44 | }); |
| 45 | const text = await res.text(); |
| 46 | let json = null; |
| 47 | try { |
| 48 | json = text ? JSON.parse(text) : null; |
| 49 | } catch { |
| 50 | json = text; |
| 51 | } |
| 52 | return { status: res.status, json }; |
| 53 | } |
| 54 | async function ok(method, path, body, tok) { |
| 55 | const r = await api(method, path, body, tok); |
| 56 | assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); |
| 57 | return r.json; |
| 58 | } |
| 59 | |
| 60 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 61 | async function until(what, fn, timeoutMs = 60_000) { |
| 62 | const end = Date.now() + timeoutMs; |
| 63 | let last; |
| 64 | while (Date.now() < end) { |
| 65 | last = await fn(); |
| 66 | if (last) return last; |
| 67 | await sleep(500); |
| 68 | } |
| 69 | assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); |
| 70 | } |
| 71 | |
| 72 | /** My emails, newest first, with the full message (kept in local development only). */ |
| 73 | const emails = async (q = "") => ok("GET", `/diffinery/emails?per_page=200${q}`); |
| 74 | /** Wait for an email matching `pred` and return it. */ |
| 75 | const email = (what, pred) => until(`email: ${what}`, async () => (await emails()).find(pred)); |
| 76 | |
| 77 | function git(cwd, args, tok = token) { |
| 78 | const auth = Buffer.from(`x-token:${tok}`).toString("base64"); |
| 79 | return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", ...args], { |
| 80 | cwd, |
| 81 | encoding: "utf8", |
| 82 | stdio: ["ignore", "pipe", "pipe"], |
| 83 | env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "E2E", GIT_AUTHOR_EMAIL: "e2e@example.com", GIT_COMMITTER_NAME: "E2E", GIT_COMMITTER_EMAIL: "e2e@example.com" }, |
| 84 | }); |
| 85 | } |
| 86 | |
| 87 | function spawnAsync(cmd, args, env, timeoutMs) { |
| 88 | return new Promise((done) => { |
| 89 | const child = spawn(cmd, args, { env: { ...process.env, ...env } }); |
| 90 | let out = ""; |
| 91 | child.stdout.on("data", (d) => (out += d)); |
| 92 | child.stderr.on("data", (d) => (out += d)); |
| 93 | const t = setTimeout(() => child.kill("SIGKILL"), timeoutMs); |
| 94 | child.on("close", (status) => { |
| 95 | clearTimeout(t); |
| 96 | done({ status, out }); |
| 97 | }); |
| 98 | }); |
| 99 | } |
| 100 | |
| 101 | before(async () => { |
| 102 | const port = await freePort(); |
| 103 | base = `http://127.0.0.1:${port}`; |
| 104 | const persist = join(work, "state"); |
| 105 | execFileSync("pnpm", ["exec", "wrangler", "d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); |
| 106 | dev = spawn("pnpm", ["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", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], { |
| 107 | cwd: APP, |
| 108 | env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, |
| 109 | stdio: ["ignore", "pipe", "pipe"], |
| 110 | detached: true, |
| 111 | }); |
| 112 | dev.stdout.on("data", (d) => (devLog += d)); |
| 113 | dev.stderr.on("data", (d) => (devLog += d)); |
| 114 | await until("wrangler dev", async () => { |
| 115 | try { |
| 116 | return (await fetch(`${base}/healthz`)).ok; |
| 117 | } catch { |
| 118 | return false; |
| 119 | } |
| 120 | }, 120_000); |
| 121 | const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "email-e2e", scopes: ["admin"] }) }); |
| 122 | assert.equal(res.status, 201, await res.clone().text()); |
| 123 | token = (await res.json()).token; |
| 124 | me = await ok("GET", "/user"); |
| 125 | }); |
| 126 | |
| 127 | after(() => { |
| 128 | if (dev?.pid) { |
| 129 | try { |
| 130 | process.kill(-dev.pid, "SIGTERM"); |
| 131 | } catch {} |
| 132 | } |
| 133 | if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 134 | }); |
| 135 | |
| 136 | test("email: security emails, settings, the test email and every template", async () => { |
| 137 | // Creating a token is a security email, and it was delivered through the EMAIL binding. |
| 138 | const tokenMail = await email("token created", (e) => e.kind === "token_created"); |
| 139 | assert.equal(tokenMail.status, "sent", JSON.stringify(tokenMail)); |
| 140 | assert.ok(tokenMail.message_id, "Email Sending returned a message id"); |
| 141 | assert.equal(tokenMail.to_addr, me.email); |
| 142 | assert.match(tokenMail.subject, /token/i); |
| 143 | const msg = tokenMail.dev_copy; |
| 144 | assert.equal(msg.from.email, "notifications@diffinery.com"); |
| 145 | assert.match(msg.html, /email-e2e/); |
| 146 | assert.match(msg.text, /email-e2e/); |
| 147 | assert.equal(msg.headers["X-Diffinery-Reason"], "security_alert"); |
| 148 | assert.equal(msg.headers["List-Unsubscribe"], undefined, "security emails can't be turned off"); |
| 149 | |
| 150 | // Settings: partial updates, defaults kept, junk refused. |
| 151 | const defaults = await ok("GET", "/diffinery/notification-settings"); |
| 152 | assert.deepEqual(defaults.participating, { web: true, email: true }); |
| 153 | assert.equal(defaults.actions.failuresOnly, true); |
| 154 | const changed = await ok("PUT", "/diffinery/notification-settings", { watching: { email: false } }); |
| 155 | assert.deepEqual(changed.watching, { web: true, email: false }); |
| 156 | assert.deepEqual(changed.participating, defaults.participating); |
| 157 | assert.equal((await api("PUT", "/diffinery/notification-settings", { bogus: true })).status, 422); |
| 158 | await ok("PUT", "/diffinery/notification-settings", { watching: { email: true } }); |
| 159 | |
| 160 | // The test email goes out right away and reports Cloudflare's answer. |
| 161 | const sent = await ok("POST", "/diffinery/emails/test"); |
| 162 | assert.equal(sent.status, "sent", JSON.stringify(sent)); |
| 163 | assert.ok(sent.messageId); |
| 164 | |
| 165 | // Site admins: delivery status and every template, sent for real. |
| 166 | const status = await ok("GET", "/diffinery/admin/email"); |
| 167 | assert.equal(status.sending, true); |
| 168 | assert.equal(status.queue, true); |
| 169 | const all = await ok("POST", "/diffinery/admin/emails/samples"); |
| 170 | assert.ok(all.total >= 30, `only ${all.total} templates`); |
| 171 | assert.equal(all.sent, all.total, JSON.stringify(all.results.filter((r) => r.status !== "sent"))); |
| 172 | |
| 173 | // The template gallery and the settings page render. |
| 174 | const gallery = await fetch(`${base}/settings/admin/email`); |
| 175 | assert.equal(gallery.status, 200); |
| 176 | assert.match(await gallery.text(), /Send every template to me/); |
| 177 | const preview = await fetch(`${base}/settings/admin/email/preview/run-failed`); |
| 178 | assert.equal(preview.status, 200); |
| 179 | assert.match(preview.headers.get("content-security-policy") ?? "", /default-src 'none'/); |
| 180 | assert.match(await preview.text(), /Run failed/); |
| 181 | const settings = await fetch(`${base}/settings/notifications`); |
| 182 | assert.equal(settings.status, 200); |
| 183 | assert.match(await settings.text(), /Watched repositories/); |
| 184 | }); |
| 185 | |
| 186 | test("email: watching, mentions, own activity, one-click unsubscribe and failed runs", async () => { |
| 187 | const owner = me.login; |
| 188 | // Repositories you create are watched (All activity). |
| 189 | await ok("POST", "/user/repos", { name: "mailbox" }); |
| 190 | const sub = await ok("GET", `/repos/${owner}/mailbox/subscription`); |
| 191 | assert.equal(sub.subscribed, true); |
| 192 | |
| 193 | // Adding an agent is a security email. |
| 194 | const bot = await ok("POST", "/diffinery/agents", { login: "mail-bot" }, null); |
| 195 | const botToken = bot.token.token; |
| 196 | await email("agent added", (e) => e.kind === "agent_created"); |
| 197 | |
| 198 | // The agent opens an issue that @mentions me. |
| 199 | const issue = await ok("POST", `/repos/${owner}/mailbox/issues`, { title: "Flaky deploy step", body: `@${owner} can you take a look?` }, botToken); |
| 200 | const opened = await email("issue opened", (e) => e.kind === "thread" && e.subject.includes(`(Issue #${issue.number})`)); |
| 201 | assert.equal(opened.status, "sent"); |
| 202 | assert.equal(opened.subject, `[${owner}/mailbox] Flaky deploy step (Issue #${issue.number})`); |
| 203 | assert.equal(opened.dev_copy.headers["X-Diffinery-Reason"], "mention"); |
| 204 | assert.equal(opened.dev_copy.from.name, "mail-bot (Diffinery)"); |
| 205 | assert.match(opened.dev_copy.text, /can you take a look/); |
| 206 | |
| 207 | // My own comment doesn't email me; the agent's reply does (I commented, like GitHub's "comment" reason). |
| 208 | await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "On it." }); |
| 209 | await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "Thanks, the logs are attached." }, botToken); |
| 210 | const reply = await email("reply", (e) => e.kind === "thread" && e.subject.startsWith("Re: ") && e.dev_copy?.text.includes("logs are attached")); |
| 211 | assert.equal(reply.dev_copy.headers["X-Diffinery-Reason"], "comment"); |
| 212 | assert.equal(reply.dev_copy.headers["In-Reply-To"], opened.dev_copy.headers.References); |
| 213 | |
| 214 | // One-click unsubscribe from the mail app mutes the thread. |
| 215 | const link = /^<(.+)>$/.exec(reply.dev_copy.headers["List-Unsubscribe"])[1]; |
| 216 | assert.ok(link.startsWith(`${base}/-/unsubscribe/`)); |
| 217 | assert.equal(reply.dev_copy.headers["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click"); |
| 218 | const confirm = await fetch(link); |
| 219 | assert.match(await confirm.text(), new RegExp(`Mute ${owner}/mailbox#${issue.number}`)); |
| 220 | const unsub = await fetch(link, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" }); |
| 221 | assert.equal(unsub.status, 200, await unsub.clone().text()); |
| 222 | const page = await (await fetch(`${base}/${owner}/mailbox/issues/${issue.number}`)).text(); |
| 223 | assert.match(page, /You muted this issue/); |
| 224 | assert.equal((await fetch(`${link.slice(0, -4)}AAAA`)).status, 404, "a tampered link is refused"); |
| 225 | |
| 226 | // Muted: a plain comment stays quiet, a mention still gets through. |
| 227 | await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "Another update nobody needs." }, botToken); |
| 228 | await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: `@${owner} this one needs you.` }, botToken); |
| 229 | await email("mention on a muted thread", (e) => e.kind === "thread" && e.dev_copy?.text.includes("this one needs you")); |
| 230 | const threadMails = (await emails("&kind=thread")).filter((e) => e.subject.includes(`(Issue #${issue.number})`)); |
| 231 | assert.deepEqual( |
| 232 | threadMails.map((e) => e.dev_copy.headers["X-Diffinery-Reason"]).reverse(), |
| 233 | ["mention", "comment", "mention"], |
| 234 | "opened, the reply and the last mention; not my comment or the muted one", |
| 235 | ); |
| 236 | |
| 237 | // A failed workflow run emails the person who pushed. |
| 238 | if (!existsSync(AGENT)) return assert.fail(`build the runner first: pnpm --filter @diffinery/runner build (missing ${AGENT})`); |
| 239 | const src = join(work, "mailbox"); |
| 240 | mkdirSync(join(src, ".github/workflows"), { recursive: true }); |
| 241 | writeFileSync(join(src, ".github/workflows/ci.yml"), "name: CI\non: push\njobs:\n test:\n runs-on: [self-hosted, linux]\n steps:\n - name: Unit tests\n run: echo boom && exit 3\n"); |
| 242 | git(src, ["init", "-q", "-b", "main"]); |
| 243 | git(src, ["add", "-A"]); |
| 244 | git(src, ["commit", "-q", "-m", "Add CI that fails"]); |
| 245 | git(src, ["remote", "add", "origin", `${base}/${owner}/mailbox.git`]); |
| 246 | git(src, ["push", "-q", "origin", "main"]); |
| 247 | const claim = await until("a job to claim", async () => { |
| 248 | const r = await api("POST", `/repos/${owner}/mailbox/actions/runners/claim`, { name: "e2e", labels: ["linux"] }); |
| 249 | return r.status === 200 ? r.json : null; |
| 250 | }); |
| 251 | await spawnAsync(process.execPath, [AGENT], { DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, 180_000); |
| 252 | const failed = await email("run failed", (e) => e.kind === "run" && e.subject.includes(`${owner}/mailbox`)); |
| 253 | assert.equal(failed.status, "sent"); |
| 254 | assert.match(failed.subject, new RegExp(`^\\[${owner}/mailbox\\] Run failed: CI - main \\([0-9a-f]{7}\\)$`)); |
| 255 | assert.equal(failed.dev_copy.headers["X-Diffinery-Reason"], "ci_activity"); |
| 256 | assert.match(failed.dev_copy.text, /test/); |
| 257 | }); |
| 258 | |
| 259 | test("email: a commit blocked by push protection, made without git, is emailed like a blocked push", async () => { |
| 260 | await ok("POST", "/user/repos", { name: "leak-mail", auto_init: true }); |
| 261 | // Built at run time so this file holds no credential-shaped string. |
| 262 | const leak = `AWS_ACCESS_KEY_ID=${"AKIA"}IOSFODNN7EXAMPL3\n`; |
| 263 | const r = await api("PUT", `/repos/${me.login}/leak-mail/contents/config.env`, { message: "leak", content: Buffer.from(leak).toString("base64") }); |
| 264 | assert.equal(r.status, 422, JSON.stringify(r.json)); |
| 265 | const blocked = await email("blocked commit", (e) => e.kind === "push_blocked" && /leak-mail/.test(e.subject)); |
| 266 | assert.equal(blocked.to_addr, me.email); |
| 267 | assert.match(blocked.dev_copy.text, /config\.env/); |
| 268 | assert.match(blocked.dev_copy.text, /AWS access key/); |
| 269 | }); |