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 hashToken,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")).number).toBe(1);43 expect(fileIssue(db, newIssue("app", "two")).number).toBe(2);44 expect(fileIssue(db, newIssue("lib", "first")).number).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")).number).toBe(42);49 });5051 test("only the token's hash is stored, and public reads leave out contact and IP", () => {52 const { number, token } = fileIssue(db, newIssue("app", "secret"));53 expect(token).toMatch(/^[0-9a-f]{64}$/);54 const issue = findIssue(db, "app", number);55 expect(issue?.token_hash).toBe(hashToken(token));56 expect(JSON.stringify(issue)).not.toContain(token);57 expect(Object.keys(issue ?? {})).not.toContain("contact_email");58 expect(Object.keys(issue ?? {})).not.toContain("ip_hash");59 });6061 test("hidden issues vanish from lists, counts and lookups but still count against the IP", () => {62 const before = filingsSince(db, "ip-a", 0);63 const { number } = fileIssue(db, newIssue("app", "spam"));64 db.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = ?", [number]);65 expect(findIssue(db, "app", number)).toBeNull();66 expect(listIssues(db, "app", "open").map((i) => i.title)).not.toContain("spam");67 expect(issueCounts(db, "app")).toEqual({ open: 3, closed: 0 });68 expect(filingsSince(db, "ip-a", 0)).toBe(before + 1);69 expect(filingsSince(db, "ip-a", 1000)).toBe(0);70 });7172 test("the thread keeps entries in order with their author kind", () => {73 const issue = findIssue(db, "app", 1);74 if (!issue) throw new Error("issue 1 missing");75 addEntry(db, issue.id, "reporter", "More detail.", 2000);76 addEntry(db, issue.id, "operator", "Thanks.", 3000);77 expect(threadEntries(db, issue.id).map((e) => [e.author, e.body])).toEqual([78 ["reporter", "More detail."],79 ["operator", "Thanks."],80 ]);81 });82});8384describe("issues end to end over HTTP", () => {85 let app: Hono;86 let kepr: Database;87 let save: string;88 let clock = Date.parse("2026-09-26T12:00:00Z");89 const tick = (seconds: number) => {90 clock += seconds * 1000;91 setSystemTime(new Date(clock));92 };93 94 type Session = { cookie: string; stamp: string };95 const openForm = async (path = "/app/issues/new", cookie = ""): Promise<Session> => {96 const response = await app.request(path, { headers: cookie ? { cookie } : {} });97 const set = response.headers.get("set-cookie")?.split(";")[0] ?? cookie;98 const stamp = /name="stamp" value="([^"]+)"/.exec(await response.text())?.[1] ?? "";99 return { cookie: set, stamp };100 };101 const post = (102 path: string,103 session: Session,104 fields: Record<string, string>,105 peer = "203.0.113.9",106 forwardedFor = "",107 ) =>108 app.request(109 path,110 {111 method: "POST",112 headers: {113 cookie: session.cookie,114 ...(forwardedFor && { "x-forwarded-for": forwardedFor }),115 },116 body: new URLSearchParams({ stamp: session.stamp, website: "", ...fields }),117 },118 119 { requestIP: () => ({ address: peer }) },120 );121 const issue = {122 title: "Crash on <start>",123 body: "It <b>crashed</b>.",124 type: "bug",125 version: "1.2.0",126 };127 const page = async (path: string) => (await app.request(path)).text();128129 beforeAll(async () => {130 setSystemTime(new Date(clock));131 const repos = await makeRepos(["app", "secret"]);132 save = repos.saves.app as string;133 await writeRelease(repos.bucketDir, "app", {134 version: "1.2.0",135 save,136 files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],137 });138 const dataDir = await mkdtemp(join(tmpdir(), "kepr-issues-e2e-"));139 kepr = openKeprDb(dataDir);140 const bucket = { kind: "folder" as const, path: repos.bucketDir };141 await syncAll({142 bucket: openBucket(bucket),143 kepr,144 mirrorDir: join(dataDir, "mirror"),145 });146 kepr.run("UPDATE repos SET public = 1 WHERE name = 'app'");147 app = createApp(148 { domain: "kepr.example", dataDir, adminPasswordHash: "$argon2id$x", bucket },149 kepr,150 );151 });152 afterAll(() => setSystemTime());153154 let token = "";155156 test("the form prefills the newest release, sets a strict same-site cookie and is never cached", async () => {157 const response = await app.request("/app/issues/new");158 expect(response.status).toBe(200);159 expect(response.headers.get("cache-control")).toBe("no-store");160 expect(response.headers.get("set-cookie")).toContain("SameSite=Strict");161 const text = await response.text();162 expect(text).toContain('name="version" maxlength="200" value="1.2.0"');163 expect(text).toContain('name="website"');164 });165166 test("refuses a too-fast, honeypot or cross-site submission", async () => {167 const session = await openForm();168 expect((await post("/app/issues/new", session, issue)).status).toBe(400);169 tick(3);170 expect((await post("/app/issues/new", session, { ...issue, website: "x" })).status).toBe(400);171 expect((await post("/app/issues/new", { ...session, cookie: "" }, issue)).status).toBe(403);172 const other = await openForm();173 expect(174 (await post("/app/issues/new", { ...session, cookie: other.cookie }, issue)).status,175 ).toBe(403);176 expect((await page("/app/issues")).includes("Crash")).toBe(false);177 });178179 test("refuses a field over 20,000 characters and keeps what was typed", async () => {180 const session = await openForm();181 tick(5);182 const response = await post("/app/issues/new", session, { ...issue, body: "x".repeat(20_001) });183 expect(response.status).toBe(400);184 expect(await response.text()).toContain("x".repeat(100));185 });186187 test("files an issue and shows the follow-up link once, carrying a token", async () => {188 const session = await openForm();189 tick(4);190 const response = await post("/app/issues/new", session, {191 ...issue,192 name: "Ada",193 email: "[email protected]",194 });195 expect(response.status).toBe(200);196 expect(response.headers.get("cache-control")).toBe("no-store");197 const text = await response.text();198 token = /\/app\/issues\/1\?token=([0-9a-f]{64})/.exec(text)?.[1] ?? "";199 expect(token).not.toBe("");200 expect(text).toContain("https://kepr.example/app/issues/1?token=");201 expect(await page("/app/issues/1")).not.toContain(token);202 });203204 test("the list and thread show the issue escaped, with its name and never its email", async () => {205 const list = await page("/app/issues");206 expect(list).toContain("Crash on <start>");207 expect(list).toContain("#1 · bug · v1.2.0");208 expect(list).toContain('name="q"');209 expect(list).toContain('name="type"');210 expect(list).toContain("Ada");211 expect(await page("/app/issues?q=missing")).not.toContain('href="/app/issues/1"');212 expect(await page("/app/issues?type=question")).not.toContain('href="/app/issues/1"');213 expect(await page("/app/issues?q=CRASH&type=bug")).toContain('href="/app/issues/1"');214 expect(list).not.toContain("[email protected]");215 const thread = await page("/app/issues/1");216 expect(thread).toContain("It <b>crashed</b>.");217 expect(thread).toContain('<span class="issue-author">Ada</span>');218 expect(thread).not.toContain("[email protected]");219 expect(thread).not.toContain('name="token"');220 });221222 test("the token opens the follow-up form, without a referrer or cache; a wrong one does not", async () => {223 const response = await app.request(`/app/issues/1?token=${token}`);224 expect(response.headers.get("referrer-policy")).toBe("no-referrer");225 expect(response.headers.get("cache-control")).toBe("no-store");226 expect(await response.text()).toContain('name="token"');227 const wrong = await page(`/app/issues/1?token=${"0".repeat(64)}`);228 expect(wrong).not.toContain('name="token"');229 expect(wrong).toContain("does not match");230 });231232 test("a reporter follow-up posts with the token; a wrong token is refused", async () => {233 const session = await openForm(`/app/issues/1?token=${token}`);234 tick(3);235 const wrong = await post("/app/issues/1", session, { token: "f".repeat(64), body: "Sneaky." });236 expect(wrong.status).toBe(403);237 const response = await post("/app/issues/1", session, { token, body: "Still <broken>." });238 expect(response.status).toBe(303);239 expect(response.headers.get("location")).toMatch(240 new RegExp(`^/app/issues/1\\?token=${token}#entry-\\d+$`),241 );242 const thread = await page("/app/issues/1");243 expect(thread).toContain("Still <broken>.");244 expect(thread).not.toContain("Sneaky.");245 expect(thread.match(/<span class="issue-author">Ada<\/span>/g)?.length).toBe(2);246 });247248 test("five filings an hour per peer address, and a forged X-Forwarded-For does not help", async () => {249 const session = await openForm();250 tick(3);251 const from = (peer: string, forwardedFor = "") =>252 post("/app/issues/new", session, issue, peer, forwardedFor);253 for (let i = 0; i < 5; i++) {254 expect((await from("198.51.100.7", `192.0.2.${i}`)).status).toBe(200);255 }256 expect((await from("198.51.100.7", "192.0.2.99")).status).toBe(429);257 expect((await from("198.51.100.8")).status).toBe(200);258 tick(3601);259 expect((await from("198.51.100.7")).status).toBe(200);260 261 const plain = new Bun.CryptoHasher("sha256").update("198.51.100.7").digest("hex");262 const stored = kepr263 .query<{ ip_hash: string }, []>("SELECT ip_hash FROM issues WHERE ip_hash != ''")264 .all()265 .map((row) => row.ip_hash);266 expect(stored.every((hash) => /^[0-9a-f]{64}$/.test(hash) && hash !== plain)).toBe(true);267 });268269 test("behind a proxy on loopback, the rightmost X-Forwarded-For entry is the client", async () => {270 const session = await openForm();271 tick(3);272 const via = (peer: string, forwardedFor: string) =>273 post("/app/issues/new", session, issue, peer, forwardedFor);274 275 for (let i = 0; i < 5; i++) {276 expect((await via("127.0.0.1", `10.9.9.${i}, 198.51.100.20`)).status).toBe(200);277 }278 expect((await via("::1", "198.51.100.20")).status).toBe(429);279 expect((await via("::ffff:127.0.0.1", "10.9.9.9, 198.51.100.20")).status).toBe(429);280 expect((await via("127.0.0.1", "198.51.100.20, 198.51.100.21")).status).toBe(200);281 });282283 test("an operator reply and a close show on the thread, marked", async () => {284 const found = findIssue(kepr, "app", 1);285 if (!found) throw new Error("issue 1 missing");286 addEntry(kepr, found.id, "operator", "Fixed, thanks.", Math.floor(clock / 1000));287 kepr.run("UPDATE issues SET status = 'closed', closed_at = 1, fixed_in = ? WHERE id = ?", [288 save,289 found.id,290 ]);291 const thread = await page("/app/issues/1");292 expect(thread).toContain('<article class="issue-post issue-post--operator"');293 expect(thread).toContain('<span class="issue-badge">operator</span>');294 expect(thread).toContain("Fixed, thanks.");295 expect(thread).toContain("◈ closed");296 expect(thread).toContain(` · fixed in <a class="history-id" href="/app/saves/${save}">`);297 expect(await page("/app/issues")).not.toContain('href="/app/issues/1"');298 expect(await page("/app/issues?status=closed")).toContain('href="/app/issues/1"');299 });300301 test("hidden issues and every issue route of a private repo are 404", async () => {302 kepr.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = 2");303 expect((await app.request("/app/issues/2")).status).toBe(404);304 expect((await app.request("/app/issues/999")).status).toBe(404);305 kepr.run(306 "INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('secret', 1, 't', 'b', 'bug', 1)",307 );308 for (const path of ["/secret/issues", "/secret/issues/new", "/secret/issues/1"]) {309 expect((await app.request(path)).status).toBe(404);310 }311 const session = await openForm();312 for (const path of ["/secret/issues/new", "/secret/issues/1"]) {313 expect((await post(path, session, { ...issue, token, body: "x" })).status).toBe(404);314 }315 });316317 test("a blocked address can neither file nor follow up, until it is unblocked", async () => {318 const first = findIssue(kepr, "app", 1);319 if (!first) throw new Error("issue 1 missing");320 const block = blockReporter(kepr, first.id, 1) as number;321 const filing = await openForm();322 const thread = await openForm(`/app/issues/1?token=${token}`, filing.cookie);323 tick(4);324 const refused = await post("/app/issues/new", filing, issue);325 expect(refused.status).toBe(403);326 expect(await refused.text()).toContain("This address can't post here.");327 const followUp = { token, body: "Blocked words." };328 expect((await post("/app/issues/1", thread, followUp)).status).toBe(403);329 expect((await post("/app/issues/new", filing, issue, "198.51.100.8")).status).toBe(200);330 unblockReporter(kepr, block);331 expect((await post("/app/issues/1", thread, followUp)).status).toBe(303);332 });333});