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 ip = "203.0.113.9",104 ) =>105 app.request(path, {106 method: "POST",107 headers: { cookie: session.cookie, "x-forwarded-for": `${ip}, 10.0.0.1` },108 body: new URLSearchParams({ stamp: session.stamp, website: "", ...fields }),109 });110 const issue = {111 title: "Crash on <start>",112 body: "It <b>crashed</b>.",113 type: "bug",114 version: "1.2.0",115 };116 const page = async (path: string) => (await app.request(path)).text();117118 beforeAll(async () => {119 setSystemTime(new Date(clock));120 const repos = await makeRepos(["app", "secret"]);121 save = repos.saves.app as string;122 await writeRelease(repos.bucketDir, "app", {123 version: "1.2.0",124 save,125 files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],126 });127 const dataDir = await mkdtemp(join(tmpdir(), "kepr-issues-e2e-"));128 kepr = openKeprDb(dataDir);129 const bucket = { kind: "folder" as const, path: repos.bucketDir };130 await syncAll({131 bucket: openBucket(bucket),132 kepr,133 mirrorDir: join(dataDir, "mirror"),134 tarballDir: join(dataDir, "tarballs"),135 });136 kepr.run("UPDATE repos SET public = 1 WHERE name = 'app'");137 app = createApp(138 { domain: "kepr.example", dataDir, adminPasswordHash: "$argon2id$x", bucket },139 kepr,140 );141 });142 afterAll(() => setSystemTime());143144 let token = "";145146 test("the form prefills the newest release, sets a strict same-site cookie and is never cached", async () => {147 const response = await app.request("/app/issues/new");148 expect(response.status).toBe(200);149 expect(response.headers.get("cache-control")).toBe("no-store");150 expect(response.headers.get("set-cookie")).toContain("SameSite=Strict");151 const text = await response.text();152 expect(text).toContain('name="version" maxlength="200" value="1.2.0"');153 expect(text).toContain('name="website"');154 });155156 test("refuses a too-fast, honeypot or cross-site submission", async () => {157 const session = await openForm();158 expect((await post("/app/issues/new", session, issue)).status).toBe(400);159 tick(3);160 expect((await post("/app/issues/new", session, { ...issue, website: "x" })).status).toBe(400);161 expect((await post("/app/issues/new", { ...session, cookie: "" }, issue)).status).toBe(403);162 const other = await openForm();163 expect(164 (await post("/app/issues/new", { ...session, cookie: other.cookie }, issue)).status,165 ).toBe(403);166 expect((await page("/app/issues")).includes("Crash")).toBe(false);167 });168169 test("refuses a field over 20,000 characters and keeps what was typed", async () => {170 const session = await openForm();171 tick(5);172 const response = await post("/app/issues/new", session, { ...issue, body: "x".repeat(20_001) });173 expect(response.status).toBe(400);174 expect(await response.text()).toContain("x".repeat(100));175 });176177 test("files an issue and shows the follow-up link once, carrying a token", async () => {178 const session = await openForm();179 tick(4);180 const response = await post("/app/issues/new", session, {181 ...issue,182 name: "Ada",183 email: "[email protected]",184 });185 expect(response.status).toBe(200);186 expect(response.headers.get("cache-control")).toBe("no-store");187 const text = await response.text();188 token = /\/app\/issues\/1\?token=([0-9a-f]{64})/.exec(text)?.[1] ?? "";189 expect(token).not.toBe("");190 expect(text).toContain("https://kepr.example/app/issues/1?token=");191 expect(await page("/app/issues/1")).not.toContain(token);192 });193194 test("the list and thread show the issue escaped, with its name and never its email", async () => {195 const list = await page("/app/issues");196 expect(list).toContain("Crash on <start>");197 expect(list).toContain("#1 · bug · v1.2.0");198 const thread = await page("/app/issues/1");199 expect(thread).toContain("It <b>crashed</b>.");200 expect(thread).toContain('<span class="issue-author">Ada</span>');201 expect(thread).not.toContain("[email protected]");202 expect(thread).not.toContain('name="token"');203 });204205 test("the token opens the follow-up form, without a referrer or cache; a wrong one does not", async () => {206 const response = await app.request(`/app/issues/1?token=${token}`);207 expect(response.headers.get("referrer-policy")).toBe("no-referrer");208 expect(response.headers.get("cache-control")).toBe("no-store");209 expect(await response.text()).toContain('name="token"');210 const wrong = await page(`/app/issues/1?token=${"0".repeat(64)}`);211 expect(wrong).not.toContain('name="token"');212 expect(wrong).toContain("does not match");213 });214215 test("a reporter follow-up posts with the token; a wrong token is refused", async () => {216 const session = await openForm(`/app/issues/1?token=${token}`);217 tick(3);218 const wrong = await post("/app/issues/1", session, { token: "f".repeat(64), body: "Sneaky." });219 expect(wrong.status).toBe(403);220 const response = await post("/app/issues/1", session, { token, body: "Still <broken>." });221 expect(response.status).toBe(303);222 expect(response.headers.get("location")).toMatch(223 new RegExp(`^/app/issues/1\\?token=${token}#entry-\\d+$`),224 );225 const thread = await page("/app/issues/1");226 expect(thread).toContain("Still <broken>.");227 expect(thread).not.toContain("Sneaky.");228 expect(thread.match(/<span class="issue-author">Ada<\/span>/g)?.length).toBe(2);229 });230231 test("five filings an hour per originating address, the sixth is refused", async () => {232 const session = await openForm();233 tick(3);234 const from = (ip: string) => post("/app/issues/new", session, issue, ip);235 for (let i = 0; i < 5; i++) expect((await from("198.51.100.7")).status).toBe(200);236 expect((await from("198.51.100.7")).status).toBe(429);237 expect((await from("198.51.100.8")).status).toBe(200);238 tick(3601);239 expect((await from("198.51.100.7")).status).toBe(200);240 241 const plain = new Bun.CryptoHasher("sha256").update("198.51.100.7").digest("hex");242 const stored = kepr243 .query<{ ip_hash: string }, []>("SELECT ip_hash FROM issues WHERE ip_hash != ''")244 .all()245 .map((row) => row.ip_hash);246 expect(stored.every((hash) => /^[0-9a-f]{64}$/.test(hash) && hash !== plain)).toBe(true);247 });248249 test("an operator reply and a close show on the thread, marked (the console is Phase 7's)", async () => {250 const found = findIssue(kepr, "app", 1);251 if (!found) throw new Error("issue 1 missing");252 addEntry(kepr, found.id, "operator", "Fixed, thanks.", Math.floor(clock / 1000));253 kepr.run("UPDATE issues SET status = 'closed', closed_at = 1, fixed_in = ? WHERE id = ?", [254 save,255 found.id,256 ]);257 const thread = await page("/app/issues/1");258 expect(thread).toContain('<article class="issue-post issue-post--operator"');259 expect(thread).toContain('<span class="issue-badge">operator</span>');260 expect(thread).toContain("Fixed, thanks.");261 expect(thread).toContain("◈ closed");262 expect(thread).toContain(` · fixed in <a class="history-id" href="/app/saves/${save}">`);263 expect(await page("/app/issues")).not.toContain('href="/app/issues/1"');264 expect(await page("/app/issues?status=closed")).toContain('href="/app/issues/1"');265 });266267 test("hidden issues and every issue route of a private repo are 404", async () => {268 kepr.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = 2");269 expect((await app.request("/app/issues/2")).status).toBe(404);270 expect((await app.request("/app/issues/999")).status).toBe(404);271 kepr.run(272 "INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('secret', 1, 't', 'b', 'bug', 1)",273 );274 for (const path of ["/secret/issues", "/secret/issues/new", "/secret/issues/1"]) {275 expect((await app.request(path)).status).toBe(404);276 }277 const session = await openForm();278 for (const path of ["/secret/issues/new", "/secret/issues/1"]) {279 expect((await post(path, session, { ...issue, token, body: "x" })).status).toBe(404);280 }281 });282});