import { beforeEach, describe, expect, mock, test } from "bun:test"; const env = { EMAIL_REPLY_DOMAIN: "gitfor.dev", SECRETS_KEY: "k".repeat(32), PUBLIC_URL: "https://gitfor.dev", }; mock.module("cloudflare:workers", () => ({ env, waitUntil: () => undefined })); type Issue = { id: string; repo_id: string; is_pull: number; locked: number; number: number; title: string; }; const users = new Map([["u1aaaaaaaaaaaaaaaaaa", { account_id: "u1aaaaaaaaaaaaaaaaaa", name: "alice" }]]); const emails = new Map([["u1aaaaaaaaaaaaaaaaaa", [{ email: "Alice@Example.com", verified: true, primary: true }]]]); let issues = new Map(); let access: "read" | "none" = "read"; let comments: Array<{ issueId: string; userId: string; body: string }> = []; mock.module("./auth", () => ({ loadActiveUserById: async (id: string) => users.get(id) ?? null, toViewer: (user: { account_id: string; name: string }) => ({ id: user.account_id, name: user.name, displayName: null, email: "", isAdmin: false, }), })); mock.module("./scoped-db", () => ({ repoDb: () => ({ first: async (_sql: string, id: string) => issues.get(id) ?? null, }), })); mock.module("./issue-scope", () => ({ issueRepoId: async (id: string) => issues.get(id)?.repo_id ?? null, })); mock.module("./user-emails", () => ({ listAccountEmails: async (id: string) => emails.get(id) ?? [], })); mock.module("./limits", () => ({ limitWrite: async () => undefined })); mock.module("./repos", () => ({ loadRepoById: async (id: string) => ({ id, owner_name: "acme", name: "web", has_issues: 1, }), requireRepoMutation: async () => { if (access === "none") { const { notFoundError } = await import("./errors"); throw notFoundError("Repository not found"); } return { repo: { id: "r1" }, permission: "read" }; }, })); mock.module("./issue-comments", () => ({ createIssueComment: async (_access: unknown, issue: Issue, viewer: { id: string }, body: string) => { comments.push({ issueId: issue.id, userId: viewer.id, body }); }, })); const { handleReplyEmail, isReplyAddress, replyAddressFor } = await import("./email-reply"); const USER = "u1aaaaaaaaaaaaaaaaaa"; const ISSUE = "i1bbbbbbbbbbbbbbbbbb"; function inbound(to: string, raw: string, extraHeaders: Record = {}, envelope = "alice@example.com") { const bytes = new TextEncoder().encode(raw); const result: { rejected: string | null } = { rejected: null }; const message = { to, from: envelope, headers: new Headers(extraHeaders), raw: new Response(bytes).body as ReadableStream, rawSize: bytes.byteLength, setReject: (reason: string) => { result.rejected = reason; }, } as unknown as ForwardableEmailMessage; return { message, result }; } function mime( from: string, body: string, auth = "mx.cloudflare.net; dkim=pass header.d=example.com; dmarc=pass header.from=example.com", ) { return [ `Authentication-Results: ${auth}`, `From: Alice <${from}>`, "To: reply@gitfor.dev", "Subject: Re: [gitfor] acme/web#12: Crash", "Content-Type: text/plain; charset=utf-8", "", body, ].join("\r\n"); } beforeEach(() => { issues = new Map([ [ ISSUE, { id: ISSUE, repo_id: "r1", is_pull: 0, locked: 0, number: 12, title: "Crash", }, ], ]); access = "read"; comments = []; }); describe("reply addresses", () => { test("are per user and thread, fit in a local part, and are recognised", async () => { const address = await replyAddressFor(USER, ISSUE); expect(address).toMatch(/^reply\+i1b+\.u1a+\.[0-9a-z]{3}[0-9a-f]{12}@gitfor\.dev$/); expect(address?.split("@")[0].length).toBeLessThanOrEqual(64); expect(isReplyAddress(address ?? "")).toBe(true); expect(isReplyAddress("abuse@gitfor.dev")).toBe(false); expect(await replyAddressFor("u2aaaaaaaaaaaaaaaaaa", ISSUE)).not.toBe(address); }); test("are off when no reply domain is configured", async () => { env.EMAIL_REPLY_DOMAIN = ""; expect(await replyAddressFor(USER, ISSUE)).toBeNull(); env.EMAIL_REPLY_DOMAIN = "gitfor.dev"; }); test("default to the sending domain of EMAIL_FROM", async () => { env.EMAIL_REPLY_DOMAIN = ""; Object.assign(env, { EMAIL_FROM: "gitfor " }); expect(await replyAddressFor(USER, ISSUE)).toMatch(/@mail\.gitfor\.dev$/); Object.assign(env, { EMAIL_FROM: "gitfor " }); expect(await replyAddressFor(USER, ISSUE)).toBeNull(); Object.assign(env, { EMAIL_FROM: "" }); env.EMAIL_REPLY_DOMAIN = "gitfor.dev"; }); }); describe("handleReplyEmail", () => { test("posts the reply text, without the quote, as the addressed user", async () => { const to = (await replyAddressFor(USER, ISSUE)) ?? ""; const { message, result } = inbound( to.toUpperCase(), mime("alice@example.com", "Fixed in main.\r\n\r\nOn Mon, gitfor wrote:\r\n> old"), ); await handleReplyEmail(message); expect(result.rejected).toBeNull(); expect(comments).toEqual([{ issueId: ISSUE, userId: USER, body: "Fixed in main." }]); }); test("rejects a forged or tampered address", async () => { const to = (await replyAddressFor(USER, ISSUE)) ?? ""; const tampered = to.replace(/[0-9a-f]{12}@/, "000000000000@"); const { message, result } = inbound(tampered, mime("alice@example.com", "hi")); await handleReplyEmail(message); expect(result.rejected).toBe("Unknown reply address"); expect(comments).toEqual([]); }); test("rejects mail from an address the user has not verified", async () => { const to = (await replyAddressFor(USER, ISSUE)) ?? ""; const { message, result } = inbound(to, mime("mallory@example.com", "hi"), {}, "mallory@example.com"); await handleReplyEmail(message); expect(result.rejected).toContain("not verified"); expect(comments).toEqual([]); }); test("rejects a From header that differs from the envelope sender", async () => { const to = (await replyAddressFor(USER, ISSUE)) ?? ""; const { message, result } = inbound(to, mime("alice@example.com", "hi"), {}, "mallory@evil.test"); await handleReplyEmail(message); expect(result.rejected).toContain("envelope"); expect(comments).toEqual([]); }); test("rejects mail without an aligned DMARC or DKIM pass in the topmost Authentication-Results", async () => { const to = (await replyAddressFor(USER, ISSUE)) ?? ""; for (const auth of [ "mx.cloudflare.net; dkim=fail header.d=example.com; dmarc=fail", "mx.cloudflare.net; dkim=pass header.d=evil.test", "evil.test; dmarc=pass header.from=example.com", ]) { const { message, result } = inbound(to, mime("alice@example.com", "hi", auth)); await handleReplyEmail(message); expect(result.rejected).toContain("authenticated"); } const forgedBelow = ["Authentication-Results: mx; dmarc=fail", mime("alice@example.com", "hi")].join("\r\n"); const { message, result } = inbound(to, forgedBelow); await handleReplyEmail(message); expect(result.rejected).toContain("authenticated"); expect(comments).toEqual([]); }); test("rejects expired reply addresses", async () => { const to = (await replyAddressFor(USER, ISSUE, Date.now() - 91 * 86_400_000)) ?? ""; const { message, result } = inbound(to, mime("alice@example.com", "hi")); await handleReplyEmail(message); expect(result.rejected).toBe("Unknown reply address"); }); test("bounces with the access error when the user lost access", async () => { access = "none"; const to = (await replyAddressFor(USER, ISSUE)) ?? ""; const { message, result } = inbound(to, mime("alice@example.com", "hi")); await handleReplyEmail(message); expect(result.rejected).toBe("Repository not found"); expect(comments).toEqual([]); }); test("bounces empty replies and deleted threads", async () => { const to = (await replyAddressFor(USER, ISSUE)) ?? ""; const empty = inbound(to, mime("alice@example.com", "> only the quote")); await handleReplyEmail(empty.message); expect(empty.result.rejected).toBe("The reply was empty"); issues.clear(); const gone = inbound(to, mime("alice@example.com", "hi")); await handleReplyEmail(gone.message); expect(gone.result.rejected).toBe("This conversation no longer exists"); expect(comments).toEqual([]); }); test("silently ignores auto-replies so out-of-office mail never loops", async () => { const to = (await replyAddressFor(USER, ISSUE)) ?? ""; const { message, result } = inbound(to, mime("alice@example.com", "I am away"), { "auto-submitted": "auto-replied", }); await handleReplyEmail(message); expect(result.rejected).toBeNull(); expect(comments).toEqual([]); }); });