| 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) turning that |
| 10 | // kind of email off, the footer link muting a thread while mentions still get |
| 11 | // through, failed workflow run emails, and the suppression list: signed bounce |
| 12 | // and complaint intake, no email to a suppressed address, lifting it again; the |
| 13 | // morning digest on its schedule (phase 5c) and Today at phone width. |
| 14 | import "./fresh-connections.mjs"; |
| 15 | import { spawn, spawnSync, execFileSync } from "node:child_process"; |
| 16 | import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs"; |
| 17 | import { tmpdir } from "node:os"; |
| 18 | import { createHmac, randomBytes } from "node:crypto"; |
| 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 { chromium } from "playwright"; |
| 24 | import { migratedState } from "./d1-state.mjs"; |
| 25 | import { freePort } from "./ports.mjs"; |
| 26 | |
| 27 | const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); |
| 28 | const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs"); |
| 29 | const work = mkdtempSync(join(tmpdir(), "diffinery-email-e2e-")); |
| 30 | let base; |
| 31 | let dev; |
| 32 | let devLog = ""; |
| 33 | let token; |
| 34 | let me; |
| 35 | const eventsSecret = randomBytes(24).toString("hex"); |
| 36 | |
| 37 | async function api(method, path, body, tok = token) { |
| 38 | const res = await fetch(`${base}/api/v3${path}`, { |
| 39 | method, |
| 40 | headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" }, |
| 41 | body: body === undefined ? undefined : JSON.stringify(body), |
| 42 | }); |
| 43 | const text = await res.text(); |
| 44 | let json = null; |
| 45 | try { |
| 46 | json = text ? JSON.parse(text) : null; |
| 47 | } catch { |
| 48 | json = text; |
| 49 | } |
| 50 | return { status: res.status, json }; |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * Claim a waiting self-hosted job as a registered runner offering `labels`, the way `diffinery runner` |
| 55 | * does (personal tokens can't claim jobs): one runner per repository and set of labels. |
| 56 | */ |
| 57 | const e2eRunners = new Map(); |
| 58 | async function runnerClaim(repoPath, labels = ["linux"]) { |
| 59 | const key = `${repoPath} ${labels.join(",")}`; |
| 60 | if (!e2eRunners.has(key)) { |
| 61 | const reg = await api("POST", `${repoPath}/actions/runners/registration-token`); |
| 62 | assert.equal(reg.status, 201, JSON.stringify(reg.json)); |
| 63 | const runner = await api("POST", `${repoPath}/actions/runners/register`, { registration_token: reg.json.token, name: `e2e-runner-${e2eRunners.size + 1}`, labels }); |
| 64 | assert.equal(runner.status, 201, JSON.stringify(runner.json)); |
| 65 | e2eRunners.set(key, runner.json.token); |
| 66 | } |
| 67 | const r = await api("POST", `${repoPath}/actions/runners/claim`, {}, e2eRunners.get(key)); |
| 68 | // A repository deleted and created again under the same name needs a new runner. |
| 69 | if (r.status !== 401) return r; |
| 70 | e2eRunners.delete(key); |
| 71 | return runnerClaim(repoPath, labels); |
| 72 | } |
| 73 | async function ok(method, path, body, tok) { |
| 74 | const r = await api(method, path, body, tok); |
| 75 | assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); |
| 76 | return r.json; |
| 77 | } |
| 78 | |
| 79 | const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
| 80 | /** `explain`, if given, says on a timeout where things stood (the polling's own log lines are left out of the tail). */ |
| 81 | async function until(what, fn, timeoutMs = 60_000, explain = async () => "") { |
| 82 | const end = Date.now() + timeoutMs; |
| 83 | let last; |
| 84 | while (Date.now() < end) { |
| 85 | last = await fn(); |
| 86 | if (last) return last; |
| 87 | await sleep(500); |
| 88 | } |
| 89 | const tail = devLog |
| 90 | .split("\n") |
| 91 | .filter((l) => !/runners\/claim 204/.test(l)) |
| 92 | .join("\n") |
| 93 | .slice(-4000); |
| 94 | assert.fail(`timed out waiting for ${what}\n${await explain().catch((e) => `(couldn't say where things stood: ${e.message})`)}\n--- wrangler log tail ---\n${tail}`); |
| 95 | } |
| 96 | |
| 97 | /** |
| 98 | * Waits for a self-hosted job of the repository to claim; on a timeout, says what the push printed and |
| 99 | * where the repository's runs and their jobs stood, so a job that never became claimable can be told apart. |
| 100 | */ |
| 101 | async function claimJob(repoPath, pushed = "") { |
| 102 | return until( |
| 103 | "a job to claim", |
| 104 | async () => { |
| 105 | const r = await runnerClaim(repoPath, ["linux"]); |
| 106 | return r.status === 200 ? r.json : null; |
| 107 | }, |
| 108 | 60_000, |
| 109 | async () => { |
| 110 | const runs = (await ok("GET", `${repoPath}/actions/runs?per_page=10`)).workflow_runs; |
| 111 | const lines = [`push output: ${pushed.trim() || "(none)"}`, `runs: ${runs.length}`]; |
| 112 | for (const r of runs) { |
| 113 | const jobs = (await ok("GET", `${repoPath}/actions/runs/${r.id}/jobs?filter=all`)).jobs; |
| 114 | lines.push(`- run ${r.run_number} ${r.head_sha.slice(0, 7)} ${r.status}/${r.conclusion}: ${jobs.map((j) => `${j.name} ${j.status}/${j.conclusion}`).join(", ") || "no jobs"}`); |
| 115 | } |
| 116 | return lines.join("\n"); |
| 117 | }, |
| 118 | ); |
| 119 | } |
| 120 | |
| 121 | /** My emails, newest first, with the full message (kept in local development only). */ |
| 122 | const emails = async (q = "") => ok("GET", `/diffinery/emails?per_page=200${q}`); |
| 123 | /** Wait for an email matching `pred` and return it. */ |
| 124 | const email = (what, pred) => until(`email: ${what}`, async () => (await emails()).find(pred)); |
| 125 | |
| 126 | function git(cwd, args, tok = token) { |
| 127 | const auth = Buffer.from(`x-token:${tok}`).toString("base64"); |
| 128 | return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", ...args], { |
| 129 | cwd, |
| 130 | encoding: "utf8", |
| 131 | stdio: ["ignore", "pipe", "pipe"], |
| 132 | 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" }, |
| 133 | }); |
| 134 | } |
| 135 | |
| 136 | /** Pushes main and returns what the server said (a workflow that failed to start says so here). */ |
| 137 | function gitPush(cwd) { |
| 138 | const auth = Buffer.from(`x-token:${token}`).toString("base64"); |
| 139 | const r = spawnSync("git", ["-c", `http.extraHeader=Authorization: Basic ${auth}`, "-c", "protocol.version=2", "push", "origin", "main"], { |
| 140 | cwd, |
| 141 | encoding: "utf8", |
| 142 | env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, |
| 143 | }); |
| 144 | assert.equal(r.status, 0, `git push failed: ${r.stderr}`); |
| 145 | return r.stderr.split("\n").filter((l) => l.startsWith("remote:")).join("\n"); |
| 146 | } |
| 147 | |
| 148 | function spawnAsync(cmd, args, env, timeoutMs) { |
| 149 | return new Promise((done) => { |
| 150 | const child = spawn(cmd, args, { env: { ...process.env, ...env } }); |
| 151 | let out = ""; |
| 152 | child.stdout.on("data", (d) => (out += d)); |
| 153 | child.stderr.on("data", (d) => (out += d)); |
| 154 | const t = setTimeout(() => child.kill("SIGKILL"), timeoutMs); |
| 155 | child.on("close", (status) => { |
| 156 | clearTimeout(t); |
| 157 | done({ status, out }); |
| 158 | }); |
| 159 | }); |
| 160 | } |
| 161 | |
| 162 | before(async () => { |
| 163 | const port = await freePort(); |
| 164 | base = `http://127.0.0.1:${port}`; |
| 165 | const persist = join(work, "state"); |
| 166 | migratedState(APP, persist); |
| 167 | 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")}`, "--var", `MAIL_EVENTS_SECRET:${eventsSecret}`, "--show-interactive-dev-session=false"], { |
| 168 | cwd: APP, |
| 169 | env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, |
| 170 | stdio: ["ignore", "pipe", "pipe"], |
| 171 | detached: true, |
| 172 | }); |
| 173 | dev.stdout.on("data", (d) => (devLog += d)); |
| 174 | dev.stderr.on("data", (d) => (devLog += d)); |
| 175 | await until("wrangler dev", async () => { |
| 176 | try { |
| 177 | return (await fetch(`${base}/healthz`)).ok; |
| 178 | } catch { |
| 179 | return false; |
| 180 | } |
| 181 | }, 120_000); |
| 182 | const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "email-e2e", scopes: ["admin"] }) }); |
| 183 | assert.equal(res.status, 201, await res.clone().text()); |
| 184 | token = (await res.json()).token; |
| 185 | me = await ok("GET", "/user"); |
| 186 | }); |
| 187 | |
| 188 | after(() => { |
| 189 | if (dev?.pid) { |
| 190 | try { |
| 191 | process.kill(-dev.pid, "SIGTERM"); |
| 192 | } catch {} |
| 193 | } |
| 194 | if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); |
| 195 | }); |
| 196 | |
| 197 | test("email: security emails, settings, the test email and every template", async () => { |
| 198 | // Creating a token is a security email, and it was delivered through the EMAIL binding. |
| 199 | const tokenMail = await email("token created", (e) => e.kind === "token_created"); |
| 200 | assert.equal(tokenMail.status, "sent", JSON.stringify(tokenMail)); |
| 201 | assert.ok(tokenMail.message_id, "Email Sending returned a message id"); |
| 202 | assert.equal(tokenMail.to_addr, me.email); |
| 203 | assert.match(tokenMail.subject, /token/i); |
| 204 | const msg = tokenMail.dev_copy; |
| 205 | assert.equal(msg.from.email, "notifications@diffinery.com"); |
| 206 | assert.match(msg.html, /email-e2e/); |
| 207 | assert.match(msg.text, /email-e2e/); |
| 208 | assert.equal(msg.headers["X-Diffinery-Reason"], "security_alert"); |
| 209 | assert.equal(msg.headers["List-Unsubscribe"], undefined, "security emails can't be turned off"); |
| 210 | |
| 211 | // Settings: partial updates, defaults kept, junk refused. |
| 212 | const defaults = await ok("GET", "/diffinery/notification-settings"); |
| 213 | assert.deepEqual(defaults.participating, { web: true, email: true }); |
| 214 | assert.equal(defaults.actions.failuresOnly, true); |
| 215 | const changed = await ok("PUT", "/diffinery/notification-settings", { watching: { email: false } }); |
| 216 | assert.deepEqual(changed.watching, { web: true, email: false }); |
| 217 | assert.deepEqual(changed.participating, defaults.participating); |
| 218 | assert.equal((await api("PUT", "/diffinery/notification-settings", { bogus: true })).status, 422); |
| 219 | await ok("PUT", "/diffinery/notification-settings", { watching: { email: true } }); |
| 220 | |
| 221 | // The test email goes out right away and reports Cloudflare's answer. |
| 222 | const sent = await ok("POST", "/diffinery/emails/test"); |
| 223 | assert.equal(sent.status, "sent", JSON.stringify(sent)); |
| 224 | assert.ok(sent.messageId); |
| 225 | |
| 226 | // Site admins: delivery status and every template, sent for real. |
| 227 | const status = await ok("GET", "/diffinery/admin/email"); |
| 228 | assert.equal(status.sending, true); |
| 229 | assert.equal(status.queue, true); |
| 230 | const all = await ok("POST", "/diffinery/admin/emails/samples"); |
| 231 | assert.ok(all.total >= 30, `only ${all.total} templates`); |
| 232 | assert.equal(all.sent, all.total, JSON.stringify(all.results.filter((r) => r.status !== "sent"))); |
| 233 | |
| 234 | // The template gallery and the settings page render. |
| 235 | const gallery = await fetch(`${base}/settings/admin/email`); |
| 236 | assert.equal(gallery.status, 200); |
| 237 | assert.match(await gallery.text(), /Send every template to me/); |
| 238 | const preview = await fetch(`${base}/settings/admin/email/preview/run-failed`); |
| 239 | assert.equal(preview.status, 200); |
| 240 | assert.match(preview.headers.get("content-security-policy") ?? "", /default-src 'none'/); |
| 241 | assert.match(await preview.text(), /Run failed/); |
| 242 | const settings = await fetch(`${base}/settings/notifications`); |
| 243 | assert.equal(settings.status, 200); |
| 244 | assert.match(await settings.text(), /Watched repositories/); |
| 245 | }); |
| 246 | |
| 247 | test("email: watching, mentions, own activity, one-click unsubscribe and failed runs", async () => { |
| 248 | const owner = me.login; |
| 249 | // Repositories you create are watched (All activity). |
| 250 | await ok("POST", "/user/repos", { name: "mailbox" }); |
| 251 | const sub = await ok("GET", `/repos/${owner}/mailbox/subscription`); |
| 252 | assert.equal(sub.subscribed, true); |
| 253 | |
| 254 | // Adding an agent is a security email. |
| 255 | const bot = await ok("POST", "/diffinery/agents", { login: "mail-bot" }, null); |
| 256 | const botToken = bot.token.token; |
| 257 | await email("agent added", (e) => e.kind === "agent_created"); |
| 258 | |
| 259 | // The agent opens an issue that @mentions me. |
| 260 | const issue = await ok("POST", `/repos/${owner}/mailbox/issues`, { title: "Flaky deploy step", body: `@${owner} can you take a look?` }, botToken); |
| 261 | const opened = await email("issue opened", (e) => e.kind === "thread" && e.subject.includes(`(Issue #${issue.number})`)); |
| 262 | assert.equal(opened.status, "sent"); |
| 263 | assert.equal(opened.subject, `[${owner}/mailbox] Flaky deploy step (Issue #${issue.number})`); |
| 264 | assert.equal(opened.dev_copy.headers["X-Diffinery-Reason"], "mention"); |
| 265 | assert.equal(opened.dev_copy.from.name, "mail-bot (Diffinery)"); |
| 266 | assert.match(opened.dev_copy.text, /can you take a look/); |
| 267 | |
| 268 | // My own comment doesn't email me; the agent's reply does (I commented, like GitHub's "comment" reason). |
| 269 | await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "On it." }); |
| 270 | await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "Thanks, the logs are attached." }, botToken); |
| 271 | const reply = await email("reply", (e) => e.kind === "thread" && e.subject.startsWith("Re: ") && e.dev_copy?.text.includes("logs are attached")); |
| 272 | assert.equal(reply.dev_copy.headers["X-Diffinery-Reason"], "comment"); |
| 273 | assert.equal(reply.dev_copy.headers["In-Reply-To"], opened.dev_copy.headers.References); |
| 274 | |
| 275 | // One-click unsubscribe from the mail app (RFC 8058) turns that kind of email off: here, Participating. |
| 276 | const link = /^<(.+)>$/.exec(reply.dev_copy.headers["List-Unsubscribe"])[1]; |
| 277 | assert.ok(link.startsWith(`${base}/-/unsubscribe/`)); |
| 278 | assert.equal(reply.dev_copy.headers["List-Unsubscribe-Post"], "List-Unsubscribe=One-Click"); |
| 279 | const confirm = await fetch(link); |
| 280 | const confirmPage = await confirm.text(); |
| 281 | assert.match(confirmPage, /Turn off email about issues and pull requests you take part in/); |
| 282 | // Whoever holds the link doesn't learn the account's email address from it. |
| 283 | assert.ok(reply.to_addr && !confirmPage.includes(reply.to_addr), "the unsubscribe page doesn't show the email address"); |
| 284 | const unsub = await fetch(link, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" }); |
| 285 | assert.equal(unsub.status, 200, await unsub.clone().text()); |
| 286 | assert.equal((await ok("GET", "/diffinery/notification-settings")).participating.email, false); |
| 287 | assert.equal((await fetch(`${link.slice(0, -4)}AAAA`)).status, 404, "a tampered link is refused"); |
| 288 | // Turned off: the next reply is skipped, not sent. |
| 289 | await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "Nobody hears this one." }, botToken); |
| 290 | const quiet = await email("a skipped reply", (e) => e.kind === "thread" && e.status === "skipped" && e.detail === "turned off participating emails"); |
| 291 | assert.equal(quiet.dev_copy, undefined); |
| 292 | await ok("PUT", "/diffinery/notification-settings", { participating: { email: true } }); |
| 293 | |
| 294 | // The footer's Mute this thread link mutes just the thread (one-click works there too). |
| 295 | const mute = /Mute this thread: (\S+)/.exec(reply.dev_copy.text)[1]; |
| 296 | assert.match(await (await fetch(mute)).text(), new RegExp(`Mute ${owner}/mailbox#${issue.number}`)); |
| 297 | const muted = await fetch(mute, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" }); |
| 298 | assert.equal(muted.status, 200, await muted.clone().text()); |
| 299 | const page = await (await fetch(`${base}/${owner}/mailbox/issues/${issue.number}`)).text(); |
| 300 | assert.match(page, /You muted this issue/); |
| 301 | |
| 302 | // Muted: a plain comment stays quiet, a mention still gets through. |
| 303 | await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: "Another update nobody needs." }, botToken); |
| 304 | await ok("POST", `/repos/${owner}/mailbox/issues/${issue.number}/comments`, { body: `@${owner} this one needs you.` }, botToken); |
| 305 | await email("mention on a muted thread", (e) => e.kind === "thread" && e.dev_copy?.text.includes("this one needs you")); |
| 306 | const threadMails = (await emails("&kind=thread")).filter((e) => e.status === "sent" && e.subject.includes(`(Issue #${issue.number})`)); |
| 307 | assert.deepEqual( |
| 308 | threadMails.map((e) => e.dev_copy.headers["X-Diffinery-Reason"]).reverse(), |
| 309 | ["mention", "comment", "mention"], |
| 310 | "opened, the reply and the last mention; not my comment or the muted one", |
| 311 | ); |
| 312 | |
| 313 | // A failed workflow run emails the person who pushed. |
| 314 | if (!existsSync(AGENT)) return assert.fail(`build the runner first: pnpm --filter @diffinery/runner build (missing ${AGENT})`); |
| 315 | const src = join(work, "mailbox"); |
| 316 | mkdirSync(join(src, ".github/workflows"), { recursive: true }); |
| 317 | 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"); |
| 318 | git(src, ["init", "-q", "-b", "main"]); |
| 319 | git(src, ["add", "-A"]); |
| 320 | git(src, ["commit", "-q", "-m", "Add CI that fails"]); |
| 321 | git(src, ["remote", "add", "origin", `${base}/${owner}/mailbox.git`]); |
| 322 | const claim = await claimJob(`/repos/${owner}/mailbox`, gitPush(src)); |
| 323 | 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); |
| 324 | const failed = await email("run failed", (e) => e.kind === "run" && e.subject.includes(`${owner}/mailbox`)); |
| 325 | assert.equal(failed.status, "sent"); |
| 326 | assert.match(failed.subject, new RegExp(`^\\[${owner}/mailbox\\] Run failed: CI - main \\([0-9a-f]{7}\\)$`)); |
| 327 | assert.equal(failed.dev_copy.headers["X-Diffinery-Reason"], "ci_activity"); |
| 328 | assert.match(failed.dev_copy.text, /test/); |
| 329 | |
| 330 | // Ignoring the repository silences its runs too, even the ones you pushed. |
| 331 | const runOnce = async (file) => { |
| 332 | writeFileSync(join(src, file), `${file}\n`); |
| 333 | git(src, ["add", "-A"]); |
| 334 | git(src, ["commit", "-q", "-m", `Add ${file}`]); |
| 335 | const c = await claimJob(`/repos/${owner}/mailbox`, gitPush(src)); |
| 336 | await spawnAsync(process.execPath, [AGENT], { DIFFINERY_API_URL: c.api_url, DIFFINERY_JOB_ID: c.job_id, DIFFINERY_JOB_TOKEN: c.token, DIFFINERY_WORK_ROOT: join(work, `job-${c.job_id}`) }, 180_000); |
| 337 | }; |
| 338 | await ok("PUT", `/repos/${owner}/mailbox/subscription`, { ignored: true }); |
| 339 | await runOnce("ignored.txt"); |
| 340 | const ignoredSha = git(src, ["rev-parse", "--short=7", "HEAD"]).trim(); |
| 341 | await until("the ignored run to finish", async () => { |
| 342 | const r = await ok("GET", `/repos/${owner}/mailbox/actions/runs?head_sha=${git(src, ["rev-parse", "HEAD"]).trim()}`); |
| 343 | return r.workflow_runs[0]?.status === "completed" ? r : null; |
| 344 | }); |
| 345 | // The run's event goes through two queues (1 s batches here); give them a few rounds. |
| 346 | await new Promise((r) => setTimeout(r, 8000)); |
| 347 | const ignoredMails = (await emails("&kind=run")).filter((e) => e.subject.includes(`(${ignoredSha})`)); |
| 348 | assert.deepEqual(ignoredMails, [], "no email for a run in an ignored repository"); |
| 349 | // Back to the default, and run emails come again. |
| 350 | await ok("DELETE", `/repos/${owner}/mailbox/subscription`); |
| 351 | await runOnce("watched.txt"); |
| 352 | const watchedSha = git(src, ["rev-parse", "--short=7", "HEAD"]).trim(); |
| 353 | await email("run failed after un-ignoring", (e) => e.kind === "run" && e.subject.includes(`(${watchedSha})`)); |
| 354 | }); |
| 355 | |
| 356 | test("email: a commit blocked by push protection, made without git, is emailed like a blocked push", async () => { |
| 357 | await ok("POST", "/user/repos", { name: "leak-mail", auto_init: true }); |
| 358 | // Built at run time so this file holds no credential-shaped string. |
| 359 | const leak = `AWS_ACCESS_KEY_ID=${"AKIA"}IOSFODNN7EXAMPL3\n`; |
| 360 | const r = await api("PUT", `/repos/${me.login}/leak-mail/contents/config.env`, { message: "leak", content: Buffer.from(leak).toString("base64") }); |
| 361 | assert.equal(r.status, 422, JSON.stringify(r.json)); |
| 362 | const blocked = await email("blocked commit", (e) => e.kind === "push_blocked" && /leak-mail/.test(e.subject)); |
| 363 | assert.equal(blocked.to_addr, me.email); |
| 364 | assert.match(blocked.dev_copy.text, /config\.env/); |
| 365 | assert.match(blocked.dev_copy.text, /AWS access key/); |
| 366 | }); |
| 367 | |
| 368 | /** POST bounce or complaint events to the signed intake; `sign` false sends no signature, a string signs with that secret. */ |
| 369 | function postEvents(events, sign = eventsSecret, t = Math.floor(Date.now() / 1000)) { |
| 370 | const body = JSON.stringify(events); |
| 371 | const headers = { "content-type": "application/json" }; |
| 372 | if (sign) headers["x-diffinery-signature"] = `t=${t},v1=${createHmac("sha256", sign).update(`${t}.${body}`).digest("hex")}`; |
| 373 | return fetch(`${base}/-/email/events`, { method: "POST", headers, body }); |
| 374 | } |
| 375 | |
| 376 | test("email: bounces and complaints suppress an address; its owner lifts it by verifying, an admin removes it", async () => { |
| 377 | const bounce = (recipient) => ({ type: "cf.email.sending.message.bounced", source: { type: "email.sending" }, payload: { recipient, bounce: { type: "hard", classification: "permanent_failure", reason: "550 5.1.1 User unknown" } } }); |
| 378 | |
| 379 | // The intake refuses anything without a good, recent signature, and changes nothing. |
| 380 | for (const r of [await postEvents([bounce(me.email)], false), await postEvents([bounce(me.email)], "not-the-secret"), await postEvents([bounce(me.email)], eventsSecret, Math.floor(Date.now() / 1000) - 3600)]) { |
| 381 | assert.equal(r.status, 401, await r.text()); |
| 382 | } |
| 383 | assert.equal((await ok("GET", "/diffinery/email")).suppressed, null); |
| 384 | assert.equal((await ok("POST", "/diffinery/emails/test")).status, "sent"); |
| 385 | |
| 386 | // An authenticated permanent bounce suppresses my address; a soft one and a delivery change nothing. |
| 387 | const soft = { type: "cf.email.sending.message.deferred", payload: { recipient: "soft@example.com", bounce: { type: "soft" } } }; |
| 388 | const r = await postEvents({ events: [bounce(me.email.toUpperCase()), soft, { type: "cf.email.sending.message.delivered", payload: { recipient: "x@example.com" } }] }); |
| 389 | assert.equal(r.status, 200, await r.clone().text()); |
| 390 | assert.deepEqual(await r.json(), { suppressed: 1, ignored: 2 }); |
| 391 | const mine = await ok("GET", "/diffinery/email"); |
| 392 | assert.equal(mine.suppressed.reason, "hard-bounce"); |
| 393 | assert.equal(mine.suppressed.detail, "550 5.1.1 User unknown"); |
| 394 | assert.equal(mine.suppressed.owner_can_lift, true); |
| 395 | |
| 396 | // Then nothing is sent to it: not the test email, not a security email. |
| 397 | const test = await ok("POST", "/diffinery/emails/test"); |
| 398 | assert.deepEqual(test, { status: "skipped", detail: "address is suppressed: email to it bounced permanently" }); |
| 399 | await ok("POST", "/diffinery/tokens", { name: "after-bounce", scopes: ["read"] }, null); |
| 400 | const skipped = await email("token email skipped", (e) => e.kind === "token_created" && e.status === "skipped"); |
| 401 | assert.match(skipped.detail, /address is suppressed/); |
| 402 | assert.equal((await emails("&kind=token_created")).filter((e) => e.status === "sent" && e.created_at >= skipped.created_at).length, 0); |
| 403 | |
| 404 | // The settings pages say so; admins see it in the list and the audit log. |
| 405 | assert.match(await (await fetch(`${base}/settings/email`)).text(), /email to it bounced permanently[\s\S]*Send a verification email/); |
| 406 | assert.match(await (await fetch(`${base}/settings/notifications`)).text(), /Settings → Email/); |
| 407 | const list = await ok("GET", "/diffinery/admin/email/suppressions"); |
| 408 | assert.ok(list.some((x) => x.email === me.email.toLowerCase() && x.source === "webhook")); |
| 409 | assert.match(await (await fetch(`${base}/settings/admin/email`)).text(), /Suppressed addresses/); |
| 410 | |
| 411 | // Re-verifying lifts it: the verification email is the one that goes through. |
| 412 | const v = await ok("POST", "/diffinery/email/verification"); |
| 413 | assert.equal(v.status, "sent", JSON.stringify(v)); |
| 414 | const vmail = await email("verification email", (e) => e.kind === "email_verify" && e.status === "sent"); |
| 415 | const verifyUrl = /(http\S+\/settings\/email\/verify\/\S+)/.exec(vmail.dev_copy.text)[1]; |
| 416 | const form = await fetch(verifyUrl); |
| 417 | const html = await form.text(); |
| 418 | assert.equal(form.status, 200, html); |
| 419 | const csrf = /name="_csrf" value="([^"]+)"/.exec(html)?.[1]; |
| 420 | const cookie = /dfy_csrf=([^;]+)/.exec(form.headers.get("set-cookie") ?? "")?.[1]; |
| 421 | const lift = await fetch(verifyUrl, { method: "POST", body: new URLSearchParams({ _csrf: csrf }), redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" } }); |
| 422 | assert.equal(lift.status, 303); |
| 423 | assert.equal((await ok("GET", "/diffinery/email")).suppressed, null); |
| 424 | assert.equal((await ok("POST", "/diffinery/emails/test")).status, "sent"); |
| 425 | assert.equal((await fetch(`${verifyUrl.slice(0, -4)}AAAA`)).status, 404, "a tampered link is refused"); |
| 426 | |
| 427 | // A complaint about an address without an account; an admin removes it, audited. |
| 428 | const complaint = await postEvents({ type: "cf.email.sending.message.complained", payload: { recipient: "friend@example.com", complaint: { type: "abuse" } } }); |
| 429 | assert.deepEqual(await complaint.json(), { suppressed: 1, ignored: 0 }); |
| 430 | const invite = await ok("POST", "/invitations", { email: "friend@example.com" }); |
| 431 | assert.ok(invite); |
| 432 | const inviteMail = await until("invite email skipped", async () => (await ok("GET", "/diffinery/emails?all=1&kind=invite&per_page=50")).find((e) => e.to_addr === "friend@example.com")); |
| 433 | assert.equal(inviteMail.status, "skipped"); |
| 434 | assert.match(inviteMail.detail, /reported as spam/); |
| 435 | assert.equal((await api("DELETE", "/diffinery/admin/email/suppressions/friend@example.com")).status, 204); |
| 436 | assert.equal((await api("DELETE", "/diffinery/admin/email/suppressions/friend@example.com")).status, 404); |
| 437 | assert.ok(!(await ok("GET", "/diffinery/admin/email/suppressions")).some((x) => x.email === "friend@example.com")); |
| 438 | const audit = await (await fetch(`${base}/settings/audit`)).text(); |
| 439 | for (const action of ["email.suppression.add", "email.suppression.lift", "email.suppression.remove"]) assert.match(audit, new RegExp(action.replaceAll(".", "\\."))); |
| 440 | }); |
| 441 | |
| 442 | test("email: the morning digest on its schedule, one-click unsubscribe, and Today on a phone (phase 5c)", async () => { |
| 443 | const S = "/diffinery/notification-settings"; |
| 444 | // The template gallery in the first test already sent a sample digest; count from here. |
| 445 | const sample = (await emails()).filter((e) => e.kind === "digest").map((e) => e.id ?? `${e.created_at} ${e.subject}`); |
| 446 | const digests = async () => (await emails()).filter((e) => e.kind === "digest" && !sample.includes(e.id ?? `${e.created_at} ${e.subject}`)); |
| 447 | assert.deepEqual((await ok("GET", S)).digest, { email: false, hour: 8, timeZone: "UTC", weekends: false }); |
| 448 | assert.equal((await api("PUT", S, { digest: { hour: 24 } })).status, 422); |
| 449 | assert.equal((await api("PUT", S, { digest: { timeZone: "Mars/Olympus" } })).status, 422); |
| 450 | |
| 451 | // On, for the next hour in Tokyo, every day. |
| 452 | const hour = (Number(new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Tokyo", hour: "numeric", hourCycle: "h23" }).format(new Date())) + 1) % 24; |
| 453 | const saved = await ok("PUT", S, { digest: { email: true, hour, timeZone: "Asia/Tokyo", weekends: true } }); |
| 454 | assert.deepEqual(saved.digest, { email: true, hour, timeZone: "Asia/Tokyo", weekends: true }); |
| 455 | const next = /The next one goes out <time datetime="([^"]+)"/.exec(await (await fetch(`${base}/settings/notifications`)).text())?.[1]; |
| 456 | assert.ok(next && Date.parse(next) > Date.now() && Date.parse(next) <= Date.now() + 3600_000, `next digest at ${next}`); |
| 457 | assert.equal(new Date(next).getUTCMinutes(), 0); |
| 458 | |
| 459 | // The five-minute cron sends nothing before the hour, then queues it once; it goes out like any email. |
| 460 | const cron = (t) => fetch(`${base}/cdn-cgi/local/scheduled?cron=${encodeURIComponent("*/5 * * * *")}&time=${t}`); |
| 461 | assert.equal((await cron(Date.parse(next) - 10 * 60_000)).status, 200); |
| 462 | await sleep(2000); |
| 463 | assert.equal((await digests()).length, 0); |
| 464 | assert.equal((await cron(Date.parse(next) + 2 * 60_000)).status, 200); |
| 465 | const d = await until("the morning digest", async () => (await digests())[0]); |
| 466 | assert.equal(d.status, "sent", d.detail); |
| 467 | assert.match(d.subject, /^\[Diffinery\] Today: /); |
| 468 | assert.equal(d.dev_copy.headers["X-Diffinery-Kind"], "digest"); |
| 469 | assert.equal(d.dev_copy.headers["X-Diffinery-Reason"], "digest"); |
| 470 | assert.match(d.dev_copy.text, /Failed runs · \d+/); |
| 471 | assert.ok(d.dev_copy.text.includes(`Open Today: ${base}/`)); |
| 472 | assert.match(d.dev_copy.text, /you turned on the morning digest/); |
| 473 | assert.equal((await cron(Date.parse(next) + 7 * 60_000)).status, 200); |
| 474 | await sleep(3000); |
| 475 | assert.equal((await digests()).length, 1, "the next run doesn't send it again"); |
| 476 | assert.match(await (await fetch(`${base}/settings/notifications`)).text(), /The next one goes out/); |
| 477 | |
| 478 | // The mail app's one-click unsubscribe turns off only the digest. |
| 479 | const link = /^<(.+)>$/.exec(d.dev_copy.headers["List-Unsubscribe"])[1]; |
| 480 | const unsub = await fetch(link, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "List-Unsubscribe=One-Click" }); |
| 481 | assert.equal(unsub.status, 200); |
| 482 | const off = await ok("GET", S); |
| 483 | assert.equal(off.digest.email, false); |
| 484 | assert.equal(off.participating.email, true); |
| 485 | assert.doesNotMatch(await (await fetch(`${base}/settings/notifications`)).text(), /The next one goes out/); |
| 486 | |
| 487 | // Asked for, it is sent now even while off. |
| 488 | const now = await ok("POST", "/diffinery/emails/digest"); |
| 489 | assert.equal(now.status, "sent", JSON.stringify(now)); |
| 490 | assert.equal((await digests()).length, 2); |
| 491 | |
| 492 | // The settings form: the browser's time zone, filled in by the page. |
| 493 | const formPage = await fetch(`${base}/settings/notifications`); |
| 494 | const csrf = /name="_csrf" value="([^"]+)"/.exec(await formPage.text())?.[1]; |
| 495 | const cookie = /dfy_csrf=([^;]+)/.exec(formPage.headers.get("set-cookie") ?? "")?.[1]; |
| 496 | const fields = [["_csrf", csrf], ...["participating.web", "participating.email", "watching.web", "watching.email", "actions.web", "actions.email", "actions.failuresOnly", "repos.email", "orgs.email", "autoWatch"].map((k) => [k, "1"]), ["digest.email", "1"], ["digest.hour", "7"], ["digest.timeZone", "browser"], ["digest.browserTimeZone", "Europe/Istanbul"]]; |
| 497 | const posted = await fetch(`${base}/settings/notifications`, { method: "POST", redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" }, body: new URLSearchParams(fields) }); |
| 498 | assert.equal(posted.status, 303); |
| 499 | assert.deepEqual((await ok("GET", S)).digest, { email: true, hour: 7, timeZone: "Europe/Istanbul", weekends: false }); |
| 500 | |
| 501 | // On a phone: the page fills in the browser's zone, nothing scrolls sideways, and Today comes before the repository list. |
| 502 | const browser = await chromium.launch(); |
| 503 | try { |
| 504 | const page = await (await browser.newContext({ viewport: { width: 390, height: 844 }, timezoneId: "America/Sao_Paulo", isMobile: true, hasTouch: true })).newPage(); |
| 505 | const overflow = () => page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth); |
| 506 | await page.goto(`${base}/settings/notifications`); |
| 507 | assert.equal(await page.locator("input[data-browser-tz]").inputValue(), "America/Sao_Paulo"); |
| 508 | assert.equal(await overflow(), 0, "the settings page scrolls sideways"); |
| 509 | if (process.env.E2E_SHOTS) await page.screenshot({ path: join(process.env.E2E_SHOTS, "notifications-390.png"), fullPage: true }); |
| 510 | await page.goto(`${base}/`); |
| 511 | assert.equal(await overflow(), 0, "Today scrolls sideways"); |
| 512 | const today = await page.locator("h1", { hasText: "Today" }).boundingBox(); |
| 513 | const side = await page.locator("h3", { hasText: /repositories/i }).first().boundingBox(); |
| 514 | assert.ok(today && side && today.y < side.y, "Today comes first"); |
| 515 | if (process.env.E2E_SHOTS) await page.screenshot({ path: join(process.env.E2E_SHOTS, "today-390.png"), fullPage: true }); |
| 516 | } finally { |
| 517 | await browser.close(); |
| 518 | } |
| 519 | }); |