1234import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";5import { Browser, type Instance, instance } from "./browser.ts";6import { makeRepos } from "./fixture.ts";78let clock = Date.parse("2026-10-08T12:00:00Z");9const tick = (seconds: number) => {10 clock += seconds * 1000;11 setSystemTime(new Date(clock));12};13type Queued = { recipient: string; subject: string; body: string; unsubscribe: string };1415describe("following an issue by email (§23)", () => {16 let inst: Instance;17 let operator: Browser;18 const queued = (to: string) =>19 inst.kepr20 .query<Queued, [string]>(21 "SELECT recipient, subject, body, unsubscribe FROM email_queue WHERE recipient = ? ORDER BY id",22 )23 .all(to);24 const stamped = async (who: Browser, path: string) => {25 const page = await (await who.request(path)).text();26 tick(4);27 return /name="stamp" value="([^"]+)"/.exec(page)?.[1] ?? "";28 };29 const send = async (who: Browser, path: string, fields: Record<string, string>, from = path) =>30 who.request(path, {31 method: "POST",32 body: new URLSearchParams({ website: "", stamp: await stamped(who, from), ...fields }),33 });34 const link = (body: string, action: string) =>35 new RegExp(`https://kepr\\.example(/app/issues/1/${action}\\?f=\\d+&t=[0-9a-f]+)`).exec(36 body,37 )?.[1] ?? "";3839 beforeAll(async () => {40 setSystemTime(new Date(clock));41 inst = await instance((await makeRepos(["app"])).bucketDir);42 operator = new Browser(inst.app);43 await operator.signIn();44 await operator.post("/admin/repos/app", { public: "1" });45 const reporter = new Browser(inst.app, "198.51.100.60");46 await send(reporter, "/app/issues/new", { title: "Crash", body: "It broke.", type: "bug" });47 });48 afterAll(() => setSystemTime());4950 test("without SMTP, nothing offers email and following is not there", async () => {51 const thread = await (await inst.app.request("/app/issues/1")).text();52 expect(thread).not.toContain('id="follow"');53 expect(thread).not.toContain('name="email"');54 const reader = new Browser(inst.app, "198.51.100.61");55 const response = await send(56 reader,57 "/app/issues/1/follow",58 { email: "[email protected]" },59 "/app/issues/1",60 );61 expect(response.status).toBe(404);62 });6364 test("an address gets one confirmation, and nothing else until it confirms", async () => {65 inst.config.smtp = {66 host: "smtp.example",67 port: 587,68 security: "starttls",69 username: "",70 password: "",71 from: "[email protected]",72 to: "[email protected]",73 };74 const reader = new Browser(inst.app, "198.51.100.61");75 const thread = await (await reader.request("/app/issues/1")).text();76 expect(thread).toContain('id="follow"');77 const follow = () =>78 send(reader, "/app/issues/1/follow", { email: "[email protected]" }, "/app/issues/1");79 const response = await follow();80 expect(response.status).toBe(303);81 expect(response.headers.get("location")).toBe("/app/issues/1?notice=check");82 expect((await follow()).headers.get("location")).toBe("/app/issues/1?notice=check");83 const [confirm, ...more] = queued("[email protected]");84 expect(more).toEqual([]);85 expect(confirm?.subject).toBe("Confirm email updates for app #1");86 expect(confirm?.unsubscribe).toBe("");87 const other = new Browser(inst.app, "198.51.100.62");88 await send(other, "/app/issues/1", { body: "Me too." });89 expect(queued("[email protected]")).toHaveLength(1);90 const wrong = await inst.app.request(91 link(confirm?.body ?? "", "follow").replace(/t=\w+/, "t=00"),92 );93 expect(wrong.headers.get("location")).toBe("/app/issues/1?notice=expired");94 const opened = await inst.app.request(link(confirm?.body ?? "", "follow"));95 expect(opened.headers.get("location")).toBe("/app/issues/1?notice=following");96 expect(await (await inst.app.request("/app/issues/1?notice=following")).text()).toContain(97 "You'll get an email for each reply",98 );99 });100101 test("followers hear of each reply but their own, with a stop link and header", async () => {102 const other = new Browser(inst.app, "198.51.100.62");103 await send(other, "/app/issues/1", { body: "Still <broken>.", name: "Bo" });104 const [, reply] = queued("[email protected]");105 expect(reply?.subject).toBe("Re: Crash (app #1)");106 expect(reply?.body).toContain("Bo replied:\n\nStill <broken>.");107 expect(reply?.body).toContain("https://kepr.example/app/issues/1#entry-");108 expect(reply?.unsubscribe).toMatch(/^\/app\/issues\/1\/unfollow\?f=\d+&t=[0-9a-f]+$/);109 const ada = new Browser(inst.app, "198.51.100.61");110 await send(ada, "/app/issues/1", { body: "Thanks.", email: "[email protected]" });111 expect(queued("[email protected]")).toHaveLength(2);112 });113114 test("the operator's reply and close reach followers too", async () => {115 await operator.get("/admin/issues/app/1");116 await operator.post("/admin/issues/app/1", { action: "reply", body: "Looking into it." });117 await operator.post("/admin/issues/app/1", { action: "close", fixed_in: "" });118 const [, , reply, closed] = queued("[email protected]");119 expect(reply?.body).toContain("The operator replied:\n\nLooking into it.");120 expect(closed?.body).toContain('"Crash" was closed.');121 });122123 test("a stop link asks first, and an inbox's one-click stops at once", async () => {124 const stop = queued("[email protected]")[1]?.unsubscribe ?? "";125 const page = await (await inst.app.request(stop)).text();126 expect(page).toContain("stop emails");127 expect(queued("[email protected]")).toHaveLength(4);128 const oneClick = await inst.app.request(stop, {129 method: "POST",130 body: new URLSearchParams({ "List-Unsubscribe": "One-Click" }),131 });132 expect(oneClick.status).toBe(200);133 await operator.post("/admin/issues/app/1", { action: "reopen" });134 expect(queued("[email protected]")).toHaveLength(4);135 const again = await inst.app.request(stop, { method: "POST" });136 expect(again.headers.get("location")).toBe("/app/issues/1?notice=expired");137 });138139 test("filing can follow the issue at the contact address", async () => {140 const reporter = new Browser(inst.app, "198.51.100.63");141 const filed = await send(reporter, "/app/issues/new", {142 title: "Dark mode",143 body: "Please.",144 type: "idea",145 email: "[email protected]",146 follow: "1",147 });148 expect(filed.headers.get("location")).toBe("/app/issues/2?notice=check");149 expect(queued("[email protected]")[0]?.subject).toBe("Confirm email updates for app #2");150 const bad = await send(reporter, "/app/issues/new", {151 title: "x",152 body: "y",153 type: "bug",154 email: "not an address",155 });156 expect(bad.status).toBe(400);157 });158159 test("a spent link cannot stop a follower that reuses its id", async () => {160 161 const gone = new Browser(inst.app, "198.51.100.70");162 await send(gone, "/app/issues/1/follow", { email: "[email protected]" }, "/app/issues/1");163 const stale = link(queued("[email protected]")[0]?.body ?? "", "follow").replace(164 "/follow?",165 "/unfollow?",166 );167 const freed = Number(/f=(\d+)/.exec(stale)?.[1]);168 await inst.app.request(stale, { method: "POST" });169 const next = new Browser(inst.app, "198.51.100.71");170 await send(next, "/app/issues/1/follow", { email: "[email protected]" }, "/app/issues/1");171 const reused = inst.kepr172 .query<{ id: number }, [string]>("SELECT id FROM issue_followers WHERE email = ?")173 .get("[email protected]")?.id;174 expect(reused).toBe(freed);175 await inst.app.request(link(queued("[email protected]")[0]?.body ?? "", "follow"));176 const replay = await inst.app.request(stale, { method: "POST" });177 expect(replay.headers.get("location")).toBe("/app/issues/1?notice=expired");178 const other = new Browser(inst.app, "198.51.100.72");179 await send(other, "/app/issues/1", { body: "One more." });180 expect(queued("[email protected]")).toHaveLength(2);181 });182183 test("a blocked address cannot follow an issue", async () => {184 const spammer = new Browser(inst.app, "198.51.100.81");185 await send(spammer, "/app/issues/1", { body: "Buy my thing." });186 const entry = inst.kepr187 .query<{ id: number }, []>("SELECT id FROM issue_entries ORDER BY id DESC LIMIT 1")188 .get()?.id;189 await operator.get("/admin/issues/app/1");190 await operator.post("/admin/issues/app/1", { action: "block_entry", entry: String(entry) });191 const refused = await send(192 spammer,193 "/app/issues/1/follow",194 { email: "[email protected]" },195 "/app/issues/1",196 );197 expect(refused.status).toBe(403);198 expect(queued("[email protected]")).toHaveLength(0);199 });200201 test("twenty follows an hour per address, and stopping one does not free the hour", async () => {202 const reader = new Browser(inst.app, "198.51.100.80");203 const addresses = Array.from({ length: 20 }, (_, n) => `many${n}@example.com`);204 for (const address of addresses) {205 const response = await send(206 reader,207 "/app/issues/1/follow",208 { email: address },209 "/app/issues/1",210 );211 expect(response.headers.get("location")).toBe("/app/issues/1?notice=check");212 }213 214 215 const stop = link(queued(addresses[0] ?? "")[0]?.body ?? "", "follow").replace(216 "/follow?",217 "/unfollow?",218 );219 await inst.app.request(stop, { method: "POST" });220 const over = await send(221 reader,222 "/app/issues/1/follow",223 { email: "[email protected]" },224 "/app/issues/1",225 );226 expect(over.status).toBe(429);227 });228229 test("a reply answers the same whether or not the address already follows", async () => {230 const reader = new Browser(inst.app, "198.51.100.83");231 const already = new Browser(inst.app, "198.51.100.84");232 await send(already, "/app/issues/1/follow", { email: "[email protected]" }, "/app/issues/1");233 await inst.app.request(link(queued("[email protected]")[0]?.body ?? "", "follow"));234 expect(queued("[email protected]")).toHaveLength(1);235 236 237 const follows = await send(238 reader,239 "/app/issues/1",240 { body: "Same here.", email: "[email protected]" },241 "/app/issues/1",242 );243 const stranger = await send(244 reader,245 "/app/issues/1",246 { body: "New here.", email: "[email protected]" },247 "/app/issues/1",248 );249 expect(follows.headers.get("location")).toContain("?notice=check#entry-");250 expect(stranger.headers.get("location")).toContain("?notice=check#entry-");251 252 const confirmations = (to: string) =>253 queued(to).filter((row) => row.subject.startsWith("Confirm"));254 expect(confirmations("[email protected]")).toHaveLength(1);255 expect(confirmations("[email protected]")).toHaveLength(1);256 });257258 test("a refused post does not echo more than a field may hold", async () => {259 const long = `${"x".repeat(20_100)}TAIL-MARK`;260 const reader = new Browser(inst.app, "198.51.100.82");261 const refused = await send(reader, "/app/issues/1", { body: long });262 expect(refused.status).toBe(400);263 const page = await refused.text();264 expect(page).toContain("x".repeat(20_000));265 expect(page).not.toContain("TAIL-MARK");266 });267});