Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

269 lines · 13.9 KBRaw History
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.
11import { spawn, execFileSync } from "node:child_process";
12import { mkdtempSync, writeFileSync, mkdirSync, existsSync, rmSync } from "node:fs";
13import { tmpdir } from "node:os";
14import { randomBytes } from "node:crypto";
15import { join, dirname, resolve } from "node:path";
16import { fileURLToPath } from "node:url";
17import { createServer } from "node:net";
18import { test, before, after } from "node:test";
19import assert from "node:assert/strict";
20
21const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
22const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs");
23const work = mkdtempSync(join(tmpdir(), "diffinery-email-e2e-"));
24let base;
25let dev;
26let devLog = "";
27let token;
28let me;
29
30const 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
39async 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}
54async 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
60const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
61async 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). */
73const emails = async (q = "") => ok("GET", `/diffinery/emails?per_page=200${q}`);
74/** Wait for an email matching `pred` and return it. */
75const email = (what, pred) => until(`email: ${what}`, async () => (await emails()).find(pred));
76
77function 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
87function 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
101before(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
127after(() => {
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
136test("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
186test("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
259test("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});