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 fileIssue,12 filingsSince,13 findIssue,14 hashToken,15 issueCounts,16 listIssues,17 type NewIssue,18 threadEntries,19} from "../src/issues/store.ts";20import { createApp } from "../src/server/app.ts";21import { openKeprDb } from "../src/server/db.ts";22import { makeRepos, writeRelease } from "./fixture.ts";2324const newIssue = (repo: string, title: string, filedAt = 1000): NewIssue => ({25 repo,26 title,27 body: "It broke.",28 type: "bug",29 version: "1.0.0",30 reporterName: "",31 contactEmail: "[email protected]",32 ipHash: "ip-a",33 filedAt,34});3536describe("issue data layer", async () => {37 const db = openKeprDb(await mkdtemp(join(tmpdir(), "kepr-issues-")));3839 test("numbers are per repo and continue after an imported number", () => {40 expect(fileIssue(db, newIssue("app", "one")).number).toBe(1);41 expect(fileIssue(db, newIssue("app", "two")).number).toBe(2);42 expect(fileIssue(db, newIssue("lib", "first")).number).toBe(1);43 db.run(44 "INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('lib', 41, 'from kepr 2', 'x', 'idea', 5)",45 );46 expect(fileIssue(db, newIssue("lib", "next")).number).toBe(42);47 });4849 test("only the token's hash is stored, and public reads leave out contact and IP", () => {50 const { number, token } = fileIssue(db, newIssue("app", "secret"));51 expect(token).toMatch(/^[0-9a-f]{64}$/);52 const issue = findIssue(db, "app", number);53 expect(issue?.token_hash).toBe(hashToken(token));54 expect(JSON.stringify(issue)).not.toContain(token);55 expect(Object.keys(issue ?? {})).not.toContain("contact_email");56 expect(Object.keys(issue ?? {})).not.toContain("ip_hash");57 });5859 test("hidden issues vanish from lists, counts and lookups but still count against the IP", () => {60 const before = filingsSince(db, "ip-a", 0);61 const { number } = fileIssue(db, newIssue("app", "spam"));62 db.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = ?", [number]);63 expect(findIssue(db, "app", number)).toBeNull();64 expect(listIssues(db, "app", "open").map((i) => i.title)).not.toContain("spam");65 expect(issueCounts(db, "app")).toEqual({ open: 3, closed: 0 });66 expect(filingsSince(db, "ip-a", 0)).toBe(before + 1);67 expect(filingsSince(db, "ip-a", 1000)).toBe(0);68 });6970 test("the thread keeps entries in order with their author kind", () => {71 const issue = findIssue(db, "app", 1);72 if (!issue) throw new Error("issue 1 missing");73 addEntry(db, issue.id, "reporter", "More detail.", 2000);74 addEntry(db, issue.id, "operator", "Thanks.", 3000);75 expect(threadEntries(db, issue.id).map((e) => [e.author, e.body])).toEqual([76 ["reporter", "More detail."],77 ["operator", "Thanks."],78 ]);79 });80});8182describe("issues end to end over HTTP", () => {83 let app: Hono;84 let kepr: Database;85 let save: string;86 let clock = Date.parse("2026-09-26T12:00:00Z");87 const tick = (seconds: number) => {88 clock += seconds * 1000;89 setSystemTime(new Date(clock));90 };91 92 type Session = { cookie: string; stamp: string };93 const openForm = async (path = "/app/issues/new", cookie = ""): Promise<Session> => {94 const response = await app.request(path, { headers: cookie ? { cookie } : {} });95 const set = response.headers.get("set-cookie")?.split(";")[0] ?? cookie;96 const stamp = /name="stamp" value="([^"]+)"/.exec(await response.text())?.[1] ?? "";97 return { cookie: set, stamp };98 };99 const post = (100 path: string,101 session: Session,102 fields: Record<string, string>,103 peer = "203.0.113.9",104 forwardedFor = "",105 ) =>106 app.request(107 path,108 {109 method: "POST",110 headers: {111 cookie: session.cookie,112 ...(forwardedFor && { "x-forwarded-for": forwardedFor }),113 },114 body: new URLSearchParams({ stamp: session.stamp, website: "", ...fields }),115 },116 117 { requestIP: () => ({ address: peer }) },118 );119 const issue = {120 title: "Crash on <start>",121 body: "It <b>crashed</b>.",122 type: "bug",123 version: "1.2.0",124 };125 const page = async (path: string) => (await app.request(path)).text();126127 beforeAll(async () => {128 setSystemTime(new Date(clock));129 const repos = await makeRepos(["app", "secret"]);130 save = repos.saves.app as string;131 await writeRelease(repos.bucketDir, "app", {132 version: "1.2.0",133 save,134 files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],135 });136 const dataDir = await mkdtemp(join(tmpdir(), "kepr-issues-e2e-"));137 kepr = openKeprDb(dataDir);138 const bucket = { kind: "folder" as const, path: repos.bucketDir };139 await syncAll({140 bucket: openBucket(bucket),141 kepr,142 mirrorDir: join(dataDir, "mirror"),143 tarballDir: join(dataDir, "tarballs"),144 });145 kepr.run("UPDATE repos SET public = 1 WHERE name = 'app'");146 app = createApp(147 { domain: "kepr.example", dataDir, adminPasswordHash: "$argon2id$x", bucket },148 kepr,149 );150 });151 afterAll(() => setSystemTime());152153 let token = "";154155 test("the form prefills the newest release, sets a strict same-site cookie and is never cached", async () => {156 const response = await app.request("/app/issues/new");157 expect(response.status).toBe(200);158 expect(response.headers.get("cache-control")).toBe("no-store");159 expect(response.headers.get("set-cookie")).toContain("SameSite=Strict");160 const text = await response.text();161 expect(text).toContain('name="version" maxlength="200" value="1.2.0"');162 expect(text).toContain('name="website"');163 });164165 test("refuses a too-fast, honeypot or cross-site submission", async () => {166 const session = await openForm();167 expect((await post("/app/issues/new", session, issue)).status).toBe(400);168 tick(3);169 expect((await post("/app/issues/new", session, { ...issue, website: "x" })).status).toBe(400);170 expect((await post("/app/issues/new", { ...session, cookie: "" }, issue)).status).toBe(403);171 const other = await openForm();172 expect(173 (await post("/app/issues/new", { ...session, cookie: other.cookie }, issue)).status,174 ).toBe(403);175 expect((await page("/app/issues")).includes("Crash")).toBe(false);176 });177178 test("refuses a field over 20,000 characters and keeps what was typed", async () => {179 const session = await openForm();180 tick(5);181 const response = await post("/app/issues/new", session, { ...issue, body: "x".repeat(20_001) });182 expect(response.status).toBe(400);183 expect(await response.text()).toContain("x".repeat(100));184 });185186 test("files an issue and shows the follow-up link once, carrying a token", async () => {187 const session = await openForm();188 tick(4);189 const response = await post("/app/issues/new", session, {190 ...issue,191 name: "Ada",192 email: "[email protected]",193 });194 expect(response.status).toBe(200);195 expect(response.headers.get("cache-control")).toBe("no-store");196 const text = await response.text();197 token = /\/app\/issues\/1\?token=([0-9a-f]{64})/.exec(text)?.[1] ?? "";198 expect(token).not.toBe("");199 expect(text).toContain("https://kepr.example/app/issues/1?token=");200 expect(await page("/app/issues/1")).not.toContain(token);201 });202203 test("the list and thread show the issue escaped, with its name and never its email", async () => {204 const list = await page("/app/issues");205 expect(list).toContain("Crash on <start>");206 expect(list).toContain("#1 · bug · v1.2.0");207 const thread = await page("/app/issues/1");208 expect(thread).toContain("It <b>crashed</b>.");209 expect(thread).toContain('<span class="issue-author">Ada</span>');210 expect(thread).not.toContain("[email protected]");211 expect(thread).not.toContain('name="token"');212 });213214 test("the token opens the follow-up form, without a referrer or cache; a wrong one does not", async () => {215 const response = await app.request(`/app/issues/1?token=${token}`);216 expect(response.headers.get("referrer-policy")).toBe("no-referrer");217 expect(response.headers.get("cache-control")).toBe("no-store");218 expect(await response.text()).toContain('name="token"');219 const wrong = await page(`/app/issues/1?token=${"0".repeat(64)}`);220 expect(wrong).not.toContain('name="token"');221 expect(wrong).toContain("does not match");222 });223224 test("a reporter follow-up posts with the token; a wrong token is refused", async () => {225 const session = await openForm(`/app/issues/1?token=${token}`);226 tick(3);227 const wrong = await post("/app/issues/1", session, { token: "f".repeat(64), body: "Sneaky." });228 expect(wrong.status).toBe(403);229 const response = await post("/app/issues/1", session, { token, body: "Still <broken>." });230 expect(response.status).toBe(303);231 expect(response.headers.get("location")).toMatch(232 new RegExp(`^/app/issues/1\\?token=${token}#entry-\\d+$`),233 );234 const thread = await page("/app/issues/1");235 expect(thread).toContain("Still <broken>.");236 expect(thread).not.toContain("Sneaky.");237 expect(thread.match(/<span class="issue-author">Ada<\/span>/g)?.length).toBe(2);238 });239240 test("five filings an hour per peer address, and a forged X-Forwarded-For does not help", async () => {241 const session = await openForm();242 tick(3);243 const from = (peer: string, forwardedFor = "") =>244 post("/app/issues/new", session, issue, peer, forwardedFor);245 for (let i = 0; i < 5; i++) {246 expect((await from("198.51.100.7", `192.0.2.${i}`)).status).toBe(200);247 }248 expect((await from("198.51.100.7", "192.0.2.99")).status).toBe(429);249 expect((await from("198.51.100.8")).status).toBe(200);250 tick(3601);251 expect((await from("198.51.100.7")).status).toBe(200);252 253 const plain = new Bun.CryptoHasher("sha256").update("198.51.100.7").digest("hex");254 const stored = kepr255 .query<{ ip_hash: string }, []>("SELECT ip_hash FROM issues WHERE ip_hash != ''")256 .all()257 .map((row) => row.ip_hash);258 expect(stored.every((hash) => /^[0-9a-f]{64}$/.test(hash) && hash !== plain)).toBe(true);259 });260261 test("behind a proxy on loopback, the rightmost X-Forwarded-For entry is the client", async () => {262 const session = await openForm();263 tick(3);264 const via = (peer: string, forwardedFor: string) =>265 post("/app/issues/new", session, issue, peer, forwardedFor);266 267 for (let i = 0; i < 5; i++) {268 expect((await via("127.0.0.1", `10.9.9.${i}, 198.51.100.20`)).status).toBe(200);269 }270 expect((await via("::1", "198.51.100.20")).status).toBe(429);271 expect((await via("::ffff:127.0.0.1", "10.9.9.9, 198.51.100.20")).status).toBe(429);272 expect((await via("127.0.0.1", "198.51.100.20, 198.51.100.21")).status).toBe(200);273 });274275 test("an operator reply and a close show on the thread, marked", async () => {276 const found = findIssue(kepr, "app", 1);277 if (!found) throw new Error("issue 1 missing");278 addEntry(kepr, found.id, "operator", "Fixed, thanks.", Math.floor(clock / 1000));279 kepr.run("UPDATE issues SET status = 'closed', closed_at = 1, fixed_in = ? WHERE id = ?", [280 save,281 found.id,282 ]);283 const thread = await page("/app/issues/1");284 expect(thread).toContain('<article class="issue-post issue-post--operator"');285 expect(thread).toContain('<span class="issue-badge">operator</span>');286 expect(thread).toContain("Fixed, thanks.");287 expect(thread).toContain("◈ closed");288 expect(thread).toContain(` · fixed in <a class="history-id" href="/app/saves/${save}">`);289 expect(await page("/app/issues")).not.toContain('href="/app/issues/1"');290 expect(await page("/app/issues?status=closed")).toContain('href="/app/issues/1"');291 });292293 test("hidden issues and every issue route of a private repo are 404", async () => {294 kepr.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = 2");295 expect((await app.request("/app/issues/2")).status).toBe(404);296 expect((await app.request("/app/issues/999")).status).toBe(404);297 kepr.run(298 "INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('secret', 1, 't', 'b', 'bug', 1)",299 );300 for (const path of ["/secret/issues", "/secret/issues/new", "/secret/issues/1"]) {301 expect((await app.request(path)).status).toBe(404);302 }303 const session = await openForm();304 for (const path of ["/secret/issues/new", "/secret/issues/1"]) {305 expect((await post(path, session, { ...issue, token, body: "x" })).status).toBe(404);306 }307 });308});