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 });144 kepr.run("UPDATE repos SET public = 1 WHERE name = 'app'");145 app = createApp(146 { domain: "kepr.example", dataDir, adminPasswordHash: "$argon2id$x", bucket },147 kepr,148 );149 });150 afterAll(() => setSystemTime());151152 let token = "";153154 test("the form prefills the newest release, sets a strict same-site cookie and is never cached", async () => {155 const response = await app.request("/app/issues/new");156 expect(response.status).toBe(200);157 expect(response.headers.get("cache-control")).toBe("no-store");158 expect(response.headers.get("set-cookie")).toContain("SameSite=Strict");159 const text = await response.text();160 expect(text).toContain('name="version" maxlength="200" value="1.2.0"');161 expect(text).toContain('name="website"');162 });163164 test("refuses a too-fast, honeypot or cross-site submission", async () => {165 const session = await openForm();166 expect((await post("/app/issues/new", session, issue)).status).toBe(400);167 tick(3);168 expect((await post("/app/issues/new", session, { ...issue, website: "x" })).status).toBe(400);169 expect((await post("/app/issues/new", { ...session, cookie: "" }, issue)).status).toBe(403);170 const other = await openForm();171 expect(172 (await post("/app/issues/new", { ...session, cookie: other.cookie }, issue)).status,173 ).toBe(403);174 expect((await page("/app/issues")).includes("Crash")).toBe(false);175 });176177 test("refuses a field over 20,000 characters and keeps what was typed", async () => {178 const session = await openForm();179 tick(5);180 const response = await post("/app/issues/new", session, { ...issue, body: "x".repeat(20_001) });181 expect(response.status).toBe(400);182 expect(await response.text()).toContain("x".repeat(100));183 });184185 test("files an issue and shows the follow-up link once, carrying a token", async () => {186 const session = await openForm();187 tick(4);188 const response = await post("/app/issues/new", session, {189 ...issue,190 name: "Ada",191 email: "[email protected]",192 });193 expect(response.status).toBe(200);194 expect(response.headers.get("cache-control")).toBe("no-store");195 const text = await response.text();196 token = /\/app\/issues\/1\?token=([0-9a-f]{64})/.exec(text)?.[1] ?? "";197 expect(token).not.toBe("");198 expect(text).toContain("https://kepr.example/app/issues/1?token=");199 expect(await page("/app/issues/1")).not.toContain(token);200 });201202 test("the list and thread show the issue escaped, with its name and never its email", async () => {203 const list = await page("/app/issues");204 expect(list).toContain("Crash on <start>");205 expect(list).toContain("#1 · bug · v1.2.0");206 expect(list).toContain('name="q"');207 expect(list).toContain('name="type"');208 expect(list).toContain("Ada");209 expect(await page("/app/issues?q=missing")).not.toContain('href="/app/issues/1"');210 expect(await page("/app/issues?type=question")).not.toContain('href="/app/issues/1"');211 expect(await page("/app/issues?q=CRASH&type=bug")).toContain('href="/app/issues/1"');212 expect(list).not.toContain("[email protected]");213 const thread = await page("/app/issues/1");214 expect(thread).toContain("It <b>crashed</b>.");215 expect(thread).toContain('<span class="issue-author">Ada</span>');216 expect(thread).not.toContain("[email protected]");217 expect(thread).not.toContain('name="token"');218 });219220 test("the token opens the follow-up form, without a referrer or cache; a wrong one does not", async () => {221 const response = await app.request(`/app/issues/1?token=${token}`);222 expect(response.headers.get("referrer-policy")).toBe("no-referrer");223 expect(response.headers.get("cache-control")).toBe("no-store");224 expect(await response.text()).toContain('name="token"');225 const wrong = await page(`/app/issues/1?token=${"0".repeat(64)}`);226 expect(wrong).not.toContain('name="token"');227 expect(wrong).toContain("does not match");228 });229230 test("a reporter follow-up posts with the token; a wrong token is refused", async () => {231 const session = await openForm(`/app/issues/1?token=${token}`);232 tick(3);233 const wrong = await post("/app/issues/1", session, { token: "f".repeat(64), body: "Sneaky." });234 expect(wrong.status).toBe(403);235 const response = await post("/app/issues/1", session, { token, body: "Still <broken>." });236 expect(response.status).toBe(303);237 expect(response.headers.get("location")).toMatch(238 new RegExp(`^/app/issues/1\\?token=${token}#entry-\\d+$`),239 );240 const thread = await page("/app/issues/1");241 expect(thread).toContain("Still <broken>.");242 expect(thread).not.toContain("Sneaky.");243 expect(thread.match(/<span class="issue-author">Ada<\/span>/g)?.length).toBe(2);244 });245246 test("five filings an hour per peer address, and a forged X-Forwarded-For does not help", async () => {247 const session = await openForm();248 tick(3);249 const from = (peer: string, forwardedFor = "") =>250 post("/app/issues/new", session, issue, peer, forwardedFor);251 for (let i = 0; i < 5; i++) {252 expect((await from("198.51.100.7", `192.0.2.${i}`)).status).toBe(200);253 }254 expect((await from("198.51.100.7", "192.0.2.99")).status).toBe(429);255 expect((await from("198.51.100.8")).status).toBe(200);256 tick(3601);257 expect((await from("198.51.100.7")).status).toBe(200);258 259 const plain = new Bun.CryptoHasher("sha256").update("198.51.100.7").digest("hex");260 const stored = kepr261 .query<{ ip_hash: string }, []>("SELECT ip_hash FROM issues WHERE ip_hash != ''")262 .all()263 .map((row) => row.ip_hash);264 expect(stored.every((hash) => /^[0-9a-f]{64}$/.test(hash) && hash !== plain)).toBe(true);265 });266267 test("behind a proxy on loopback, the rightmost X-Forwarded-For entry is the client", async () => {268 const session = await openForm();269 tick(3);270 const via = (peer: string, forwardedFor: string) =>271 post("/app/issues/new", session, issue, peer, forwardedFor);272 273 for (let i = 0; i < 5; i++) {274 expect((await via("127.0.0.1", `10.9.9.${i}, 198.51.100.20`)).status).toBe(200);275 }276 expect((await via("::1", "198.51.100.20")).status).toBe(429);277 expect((await via("::ffff:127.0.0.1", "10.9.9.9, 198.51.100.20")).status).toBe(429);278 expect((await via("127.0.0.1", "198.51.100.20, 198.51.100.21")).status).toBe(200);279 });280281 test("an operator reply and a close show on the thread, marked", async () => {282 const found = findIssue(kepr, "app", 1);283 if (!found) throw new Error("issue 1 missing");284 addEntry(kepr, found.id, "operator", "Fixed, thanks.", Math.floor(clock / 1000));285 kepr.run("UPDATE issues SET status = 'closed', closed_at = 1, fixed_in = ? WHERE id = ?", [286 save,287 found.id,288 ]);289 const thread = await page("/app/issues/1");290 expect(thread).toContain('<article class="issue-post issue-post--operator"');291 expect(thread).toContain('<span class="issue-badge">operator</span>');292 expect(thread).toContain("Fixed, thanks.");293 expect(thread).toContain("◈ closed");294 expect(thread).toContain(` · fixed in <a class="history-id" href="/app/saves/${save}">`);295 expect(await page("/app/issues")).not.toContain('href="/app/issues/1"');296 expect(await page("/app/issues?status=closed")).toContain('href="/app/issues/1"');297 });298299 test("hidden issues and every issue route of a private repo are 404", async () => {300 kepr.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = 2");301 expect((await app.request("/app/issues/2")).status).toBe(404);302 expect((await app.request("/app/issues/999")).status).toBe(404);303 kepr.run(304 "INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('secret', 1, 't', 'b', 'bug', 1)",305 );306 for (const path of ["/secret/issues", "/secret/issues/new", "/secret/issues/1"]) {307 expect((await app.request(path)).status).toBe(404);308 }309 const session = await openForm();310 for (const path of ["/secret/issues/new", "/secret/issues/1"]) {311 expect((await post(path, session, { ...issue, token, body: "x" })).status).toBe(404);312 }313 });314});