1import type { Database } from "bun:sqlite";2import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";3import { mkdtemp } from "node:fs/promises";4import { tmpdir } from "node:os";5import { join } from "node:path";6import type { Hono } from "hono";7import { openBucket } from "../src/bucket/reader.ts";8import { syncAll } from "../src/bucket/sync.ts";9import {10 addEntry,11 blockReporter,12 fileIssue,13 filingsSince,14 findIssue,15 isBlocked,16 issueCounts,17 listIssues,18 type NewIssue,19 threadEntries,20 unblockReporter,21} from "../src/issues/store.ts";22import { createApp } from "../src/server/app.ts";23import { openKeprDb } from "../src/server/db.ts";24import { makeRepos, writeRelease } from "./fixture.ts";2526const newIssue = (repo: string, title: string, filedAt = 1000): NewIssue => ({27 repo,28 title,29 body: "It broke.",30 type: "bug",31 version: "1.0.0",32 reporterName: "",33 contactEmail: "[email protected]",34 ipHash: "ip-a",35 filedAt,36});3738describe("issue data layer", async () => {39 const db = openKeprDb(await mkdtemp(join(tmpdir(), "kepr-issues-")));4041 test("numbers are per repo and continue after an imported number", () => {42 expect(fileIssue(db, newIssue("app", "one"))).toBe(1);43 expect(fileIssue(db, newIssue("app", "two"))).toBe(2);44 expect(fileIssue(db, newIssue("lib", "first"))).toBe(1);45 db.run(46 "INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('lib', 41, 'from kepr 2', 'x', 'idea', 5)",47 );48 expect(fileIssue(db, newIssue("lib", "next"))).toBe(42);49 });5051 test("public reads leave out contact and IP", () => {52 const issue = findIssue(db, "app", fileIssue(db, newIssue("app", "secret")));53 expect(Object.keys(issue ?? {})).not.toContain("contact_email");54 expect(Object.keys(issue ?? {})).not.toContain("ip_hash");55 });5657 test("hidden issues vanish from lists, counts and lookups but still count against the IP", () => {58 const before = filingsSince(db, "ip-a", 0);59 const number = fileIssue(db, newIssue("app", "spam"));60 db.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = ?", [number]);61 expect(findIssue(db, "app", number)).toBeNull();62 expect(listIssues(db, "app", "open").map((i) => i.title)).not.toContain("spam");63 expect(issueCounts(db, "app")).toEqual({ open: 3, closed: 0 });64 expect(filingsSince(db, "ip-a", 0)).toBe(before + 1);65 expect(filingsSince(db, "ip-a", 1000)).toBe(0);66 });6768 test("the thread keeps replies in order with their author, name and the reply they answer", () => {69 const issue = findIssue(db, "app", 1);70 if (!issue) throw new Error("issue 1 missing");71 const first = addEntry(db, issue.id, {72 author: "reporter",73 name: "Bo",74 body: "More.",75 at: 2000,76 });77 addEntry(db, issue.id, { author: "operator", body: "Thanks.", at: 3000, replyTo: first });78 expect(threadEntries(db, issue.id).map((e) => [e.author, e.name, e.body, e.reply_to])).toEqual([79 ["reporter", "Bo", "More.", null],80 ["operator", "", "Thanks.", first],81 ]);82 expect(listIssues(db, "app", "open").find((i) => i.number === 1)?.replies).toBe(2);83 });8485 test("a block from a reply takes that reply's address, and only a reply of that issue", () => {86 const [one, two] = [findIssue(db, "app", 1), findIssue(db, "app", 2)];87 if (!one || !two) throw new Error("issues missing");88 const at = 4000;89 const reply = addEntry(db, one.id, { author: "reporter", body: "x", at, ipHash: "ip-r" });90 expect(blockReporter(db, two.id, at, reply)).toBeNull();91 const block = blockReporter(db, one.id, at, reply) as number;92 expect(isBlocked(db, "ip-r")).toBe(true);93 expect(isBlocked(db, "ip-a")).toBe(false);94 unblockReporter(db, block);95 });96});9798describe("issues end to end over HTTP", () => {99 let app: Hono;100 let kepr: Database;101 let save: string;102 let clock = Date.parse("2026-09-26T12:00:00Z");103 const tick = (seconds: number) => {104 clock += seconds * 1000;105 setSystemTime(new Date(clock));106 };107 108 type Session = { cookie: string; stamp: string };109 const openForm = async (path = "/app/issues/new", cookie = ""): Promise<Session> => {110 const response = await app.request(path, { headers: cookie ? { cookie } : {} });111 const set = response.headers.get("set-cookie")?.split(";")[0] ?? cookie;112 const stamp = /name="stamp" value="([^"]+)"/.exec(await response.text())?.[1] ?? "";113 return { cookie: set, stamp };114 };115 const post = (116 path: string,117 session: Session,118 fields: Record<string, string>,119 peer = "203.0.113.9",120 forwardedFor = "",121 ) =>122 app.request(123 path,124 {125 method: "POST",126 headers: {127 cookie: session.cookie,128 ...(forwardedFor && { "x-forwarded-for": forwardedFor }),129 },130 body: new URLSearchParams({ stamp: session.stamp, website: "", ...fields }),131 },132 133 { requestIP: () => ({ address: peer }) },134 );135 const issue = {136 title: "Crash on <start>",137 body: "It <b>crashed</b>.",138 type: "bug",139 version: "1.2.0",140 };141 const page = async (path: string) => (await app.request(path)).text();142143 beforeAll(async () => {144 setSystemTime(new Date(clock));145 const repos = await makeRepos(["app", "secret"]);146 save = repos.saves.app as string;147 await writeRelease(repos.bucketDir, "app", {148 version: "1.2.0",149 save,150 files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],151 });152 const dataDir = await mkdtemp(join(tmpdir(), "kepr-issues-e2e-"));153 kepr = openKeprDb(dataDir);154 const bucket = { kind: "folder" as const, path: repos.bucketDir };155 await syncAll({156 bucket: openBucket(bucket),157 kepr,158 mirrorDir: join(dataDir, "mirror"),159 });160 kepr.run("UPDATE repos SET public = 1 WHERE name = 'app'");161 app = createApp(162 { domain: "kepr.example", dataDir, adminPasswordHash: "$argon2id$x", bucket },163 kepr,164 );165 });166 afterAll(() => setSystemTime());167168 test("the form prefills the newest release, sets a strict same-site cookie and is never cached", async () => {169 const response = await app.request("/app/issues/new");170 expect(response.status).toBe(200);171 expect(response.headers.get("cache-control")).toBe("no-store");172 expect(response.headers.get("set-cookie")).toContain("SameSite=Strict");173 const text = await response.text();174 expect(text).toContain('name="version" maxlength="200" value="1.2.0"');175 expect(text).toContain('name="website"');176 });177178 test("refuses a too-fast, honeypot or cross-site submission", async () => {179 const session = await openForm();180 expect((await post("/app/issues/new", session, issue)).status).toBe(400);181 tick(3);182 expect((await post("/app/issues/new", session, { ...issue, website: "x" })).status).toBe(400);183 expect((await post("/app/issues/new", { ...session, cookie: "" }, issue)).status).toBe(403);184 const other = await openForm();185 expect(186 (await post("/app/issues/new", { ...session, cookie: other.cookie }, issue)).status,187 ).toBe(403);188 expect((await page("/app/issues")).includes("Crash")).toBe(false);189 });190191 test("refuses a field over 20,000 characters and keeps what was typed", async () => {192 const session = await openForm();193 tick(5);194 const response = await post("/app/issues/new", session, { ...issue, body: "x".repeat(20_001) });195 expect(response.status).toBe(400);196 expect(await response.text()).toContain("x".repeat(100));197 });198199 test("filing goes straight to the new issue", async () => {200 const session = await openForm();201 tick(4);202 const response = await post("/app/issues/new", session, {203 ...issue,204 name: "Ada",205 email: "[email protected]",206 });207 expect(response.status).toBe(303);208 expect(response.headers.get("location")).toBe("/app/issues/1");209 });210211 test("the list and thread show the issue escaped, with its name and never its email", async () => {212 const list = await page("/app/issues");213 expect(list).toContain("Crash on <start>");214 expect(list).toContain("#1 · bug · v1.2.0");215 expect(list).toContain('name="q"');216 expect(list).toContain('name="type"');217 expect(list).toContain("Ada");218 expect(await page("/app/issues?q=missing")).not.toContain('href="/app/issues/1"');219 expect(await page("/app/issues?type=question")).not.toContain('href="/app/issues/1"');220 expect(await page("/app/issues?q=CRASH&type=bug")).toContain('href="/app/issues/1"');221 expect(list).not.toContain("[email protected]");222 const response = await app.request("/app/issues/1");223 expect(response.headers.get("referrer-policy")).toBe("no-referrer");224 expect(response.headers.get("cache-control")).toBe("no-store");225 const thread = await response.text();226 expect(thread).toContain("It <b>crashed</b>.");227 expect(thread).toContain('<span class="issue-author">Ada</span>');228 expect(thread).not.toContain("[email protected]");229 expect(thread).toContain('href="/app/issues">back to issues</a>');230 expect(thread).toContain('<form class="issue-composer" id="reply"');231 });232233 let bobsReply = 0;234235 test("anyone can reply, in Markdown with raw HTML escaped and links marked as theirs", async () => {236 const session = await openForm("/app/issues/1");237 tick(3);238 const body =239 "Still <broken>.\n```\nlog line\n```\n**worse** [site](https://example.com) [x](javascript:alert(1))";240 const response = await post("/app/issues/1", session, { body, name: "Bob" }, "198.51.100.30");241 expect(response.status).toBe(303);242 const entry = /^\/app\/issues\/1#entry-(\d+)$/.exec(response.headers.get("location") ?? "");243 bobsReply = Number(entry?.[1]);244 expect(bobsReply).toBeGreaterThan(0);245 const thread = await page("/app/issues/1");246 expect(thread).toContain("Still <broken>.");247 expect(thread).toContain("<pre><code>log line");248 expect(thread).toContain("<strong>worse</strong>");249 expect(thread).toContain('<a href="https://example.com" rel="nofollow ugc noopener">site</a>');250 expect(thread).not.toContain('href="javascript:');251 expect(thread).toContain('<span class="issue-author">Bob</span>');252 expect(await page("/app/issues")).toContain("· 1 reply");253 });254255 test("quote starts a reply with the post quoted, and the reply shows what it answers", async () => {256 const quoted = await page(`/app/issues/1?quote=${bobsReply}`);257 expect(quoted).toContain(`name="reply_to" value="${bobsReply}"`);258 expect(quoted).toContain("> Still <broken>.\n> ```");259 expect(await page("/app/issues/1?quote=0")).toContain("> It <b>crashed");260 expect(await page("/app/issues/1?reply=999999")).toContain('name="reply_to" value=""');261 const session = await openForm("/app/issues/1");262 tick(3);263 const reply = { body: "Same here.", reply_to: String(bobsReply) };264 expect((await post("/app/issues/1", session, reply)).status).toBe(303);265 const thread = await page("/app/issues/1");266 expect(thread).toContain(`replying to\n<a href="#entry-${bobsReply}">Bob</a>`);267 expect(thread).toContain('<span class="issue-author">anonymous</span>');268 });269270 test("a refused reply keeps what was typed", async () => {271 const session = await openForm("/app/issues/1");272 tick(3);273 const response = await post("/app/issues/1", session, { body: "y".repeat(20_001), name: "Cy" });274 expect(response.status).toBe(400);275 const text = await response.text();276 expect(text).toContain("y".repeat(100));277 expect(text).toContain('value="Cy"');278 expect((await post("/app/issues/1", session, { body: " " })).status).toBe(400);279 });280281 test("twenty replies an hour per address", async () => {282 const session = await openForm("/app/issues/1");283 tick(3);284 const from = (peer: string) => post("/app/issues/1", session, { body: "more" }, peer);285 for (let i = 0; i < 20; i++) expect((await from("198.51.100.50")).status).toBe(303);286 expect((await from("198.51.100.50")).status).toBe(429);287 expect((await from("198.51.100.51")).status).toBe(303);288 kepr.run("UPDATE issue_entries SET hidden = 1 WHERE body = 'more'");289 });290291 test("five filings an hour per peer address, and a forged X-Forwarded-For does not help", async () => {292 const session = await openForm();293 tick(3);294 const from = (peer: string, forwardedFor = "") =>295 post("/app/issues/new", session, issue, peer, forwardedFor);296 for (let i = 0; i < 5; i++) {297 expect((await from("198.51.100.7", `192.0.2.${i}`)).status).toBe(303);298 }299 expect((await from("198.51.100.7", "192.0.2.99")).status).toBe(429);300 expect((await from("198.51.100.8")).status).toBe(303);301 tick(3601);302 expect((await from("198.51.100.7")).status).toBe(303);303 304 const plain = new Bun.CryptoHasher("sha256").update("198.51.100.7").digest("hex");305 const stored = kepr306 .query<{ ip_hash: string }, []>("SELECT ip_hash FROM issues WHERE ip_hash != ''")307 .all()308 .map((row) => row.ip_hash);309 expect(stored.every((hash) => /^[0-9a-f]{64}$/.test(hash) && hash !== plain)).toBe(true);310 });311312 test("behind a proxy on loopback, the rightmost X-Forwarded-For entry is the client", async () => {313 const session = await openForm();314 tick(3);315 const via = (peer: string, forwardedFor: string) =>316 post("/app/issues/new", session, issue, peer, forwardedFor);317 318 for (let i = 0; i < 5; i++) {319 expect((await via("127.0.0.1", `10.9.9.${i}, 198.51.100.20`)).status).toBe(303);320 }321 expect((await via("::1", "198.51.100.20")).status).toBe(429);322 expect((await via("::ffff:127.0.0.1", "10.9.9.9, 198.51.100.20")).status).toBe(429);323 expect((await via("127.0.0.1", "198.51.100.20, 198.51.100.21")).status).toBe(303);324 });325326 test("an operator reply and a close show on the thread, which then takes no replies", async () => {327 const found = findIssue(kepr, "app", 1);328 if (!found) throw new Error("issue 1 missing");329 const session = await openForm("/app/issues/1");330 addEntry(kepr, found.id, { author: "operator", body: "Fixed, thanks.", at: clock / 1000 });331 kepr.run("UPDATE issues SET status = 'closed', closed_at = 1, fixed_in = ? WHERE id = ?", [332 save,333 found.id,334 ]);335 const thread = await page("/app/issues/1");336 expect(thread).toContain('<article class="issue-post issue-post--operator"');337 expect(thread).toContain('<span class="issue-badge">operator</span>');338 expect(thread).toContain("Fixed, thanks.");339 expect(thread).toContain("◈ closed");340 expect(thread).toContain('href="/app/issues?status=closed">back to issues</a>');341 expect(thread).toContain(` · fixed in <a class="history-id" href="/app/saves/${save}">`);342 expect(thread).not.toContain('id="reply"');343 expect(thread).toContain("takes no new replies");344 tick(3);345 expect((await post("/app/issues/1", session, { body: "Late." })).status).toBe(403);346 expect(await page("/app/issues")).not.toContain('href="/app/issues/1"');347 expect(await page("/app/issues?status=closed")).toContain('href="/app/issues/1"');348 });349350 test("hidden issues and every issue route of a private repo are 404", async () => {351 kepr.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = 2");352 expect((await app.request("/app/issues/2")).status).toBe(404);353 expect((await app.request("/app/issues/999")).status).toBe(404);354 kepr.run(355 "INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('secret', 1, 't', 'b', 'bug', 1)",356 );357 for (const path of ["/secret/issues", "/secret/issues/new", "/secret/issues/1"]) {358 expect((await app.request(path)).status).toBe(404);359 }360 const session = await openForm();361 for (const path of ["/secret/issues/new", "/secret/issues/1"]) {362 expect((await post(path, session, { ...issue, body: "x" })).status).toBe(404);363 }364 });365366 test("a blocked address can neither file nor reply, until it is unblocked", async () => {367 const first = findIssue(kepr, "app", 1);368 if (!first) throw new Error("issue 1 missing");369 const block = blockReporter(kepr, first.id, 1) as number;370 const filing = await openForm();371 const thread = await openForm("/app/issues/3", filing.cookie);372 tick(4);373 const refused = await post("/app/issues/new", filing, issue);374 expect(refused.status).toBe(403);375 expect(await refused.text()).toContain("This address can't post here.");376 const reply = { body: "Blocked words." };377 expect((await post("/app/issues/3", thread, reply)).status).toBe(403);378 expect((await post("/app/issues/new", filing, issue, "198.51.100.8")).status).toBe(303);379 unblockReporter(kepr, block);380 expect((await post("/app/issues/3", thread, reply)).status).toBe(303);381 });382});