import type { Database } from "bun:sqlite"; import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test"; import { mkdtemp } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Hono } from "hono"; import { openBucket } from "../src/bucket/reader.ts"; import { syncAll } from "../src/bucket/sync.ts"; import { addEntry, blockReporter, fileIssue, filingsSince, findIssue, isBlocked, issueCounts, listIssues, type NewIssue, threadEntries, unblockReporter, } from "../src/issues/store.ts"; import { createApp } from "../src/server/app.ts"; import { openKeprDb } from "../src/server/db.ts"; import { makeRepos, writeRelease } from "./fixture.ts"; const newIssue = (repo: string, title: string, filedAt = 1000): NewIssue => ({ repo, title, body: "It broke.", type: "bug", version: "1.0.0", reporterName: "", contactEmail: "me@example.com", ipHash: "ip-a", filedAt, }); describe("issue data layer", async () => { const db = openKeprDb(await mkdtemp(join(tmpdir(), "kepr-issues-"))); test("numbers are per repo and continue after an imported number", () => { expect(fileIssue(db, newIssue("app", "one")).number).toBe(1); expect(fileIssue(db, newIssue("app", "two")).number).toBe(2); expect(fileIssue(db, newIssue("lib", "first")).number).toBe(1); db.run( "INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('lib', 41, 'from kepr 2', 'x', 'idea', 5)", ); expect(fileIssue(db, newIssue("lib", "next")).number).toBe(42); }); test("public reads leave out contact and IP", () => { const issue = findIssue(db, "app", fileIssue(db, newIssue("app", "secret")).number); expect(Object.keys(issue ?? {})).not.toContain("contact_email"); expect(Object.keys(issue ?? {})).not.toContain("ip_hash"); }); test("hidden issues vanish from lists, counts and lookups but still count against the IP", () => { const before = filingsSince(db, "ip-a", 0); const { number } = fileIssue(db, newIssue("app", "spam")); db.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = ?", [number]); expect(findIssue(db, "app", number)).toBeNull(); expect(listIssues(db, "app", "open").map((i) => i.title)).not.toContain("spam"); expect(issueCounts(db, "app")).toEqual({ open: 3, closed: 0 }); expect(filingsSince(db, "ip-a", 0)).toBe(before + 1); expect(filingsSince(db, "ip-a", 1000)).toBe(0); }); test("the thread keeps replies in order with their author, name and the reply they answer", () => { const issue = findIssue(db, "app", 1); if (!issue) throw new Error("issue 1 missing"); const first = addEntry(db, issue.id, { author: "reporter", name: "Bo", body: "More.", at: 2000, }); addEntry(db, issue.id, { author: "operator", body: "Thanks.", at: 3000, replyTo: first }); expect(threadEntries(db, issue.id).map((e) => [e.author, e.name, e.body, e.reply_to])).toEqual([ ["reporter", "Bo", "More.", null], ["operator", "", "Thanks.", first], ]); expect(listIssues(db, "app", "open").find((i) => i.number === 1)?.replies).toBe(2); }); test("a block from a reply takes that reply's address, and only a reply of that issue", () => { const [one, two] = [findIssue(db, "app", 1), findIssue(db, "app", 2)]; if (!one || !two) throw new Error("issues missing"); const at = 4000; const reply = addEntry(db, one.id, { author: "reporter", body: "x", at, ipHash: "ip-r" }); expect(blockReporter(db, two.id, at, reply)).toBeNull(); const block = blockReporter(db, one.id, at, reply) as number; expect(isBlocked(db, "ip-r")).toBe(true); expect(isBlocked(db, "ip-a")).toBe(false); unblockReporter(db, block); }); }); describe("issues end to end over HTTP", () => { let app: Hono; let kepr: Database; let save: string; let clock = Date.parse("2026-09-26T12:00:00Z"); const tick = (seconds: number) => { clock += seconds * 1000; setSystemTime(new Date(clock)); }; // A browser session: the form cookie from GET /new, and the form's stamp. type Session = { cookie: string; stamp: string }; const openForm = async (path = "/app/issues/new", cookie = ""): Promise => { const response = await app.request(path, { headers: cookie ? { cookie } : {} }); const set = response.headers.get("set-cookie")?.split(";")[0] ?? cookie; const stamp = /name="stamp" value="([^"]+)"/.exec(await response.text())?.[1] ?? ""; return { cookie: set, stamp }; }; const post = ( path: string, session: Session, fields: Record, peer = "203.0.113.9", forwardedFor = "", ) => app.request( path, { method: "POST", headers: { cookie: session.cookie, ...(forwardedFor && { "x-forwarded-for": forwardedFor }), }, body: new URLSearchParams({ stamp: session.stamp, website: "", ...fields }), }, // The env Bun.serve passes: the server, whose requestIP gives the socket peer. { requestIP: () => ({ address: peer }) }, ); const issue = { title: "Crash on ", body: "It crashed.", type: "bug", version: "1.2.0", }; const page = async (path: string) => (await app.request(path)).text(); const postFiles = (path: string, session: Session, body: string, files: File[]) => { const form = new FormData(); form.append("stamp", session.stamp); form.append("website", ""); form.append("body", body); for (const file of files) form.append("files", file); return app.request( path, { method: "POST", headers: { cookie: session.cookie }, body: form }, { requestIP: () => ({ address: "203.0.113.9" }) }, ); }; beforeAll(async () => { setSystemTime(new Date(clock)); const repos = await makeRepos(["app", "secret"]); save = repos.saves.app as string; await writeRelease(repos.bucketDir, "app", { version: "1.2.0", save, files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }], }); const dataDir = await mkdtemp(join(tmpdir(), "kepr-issues-e2e-")); kepr = openKeprDb(dataDir); const bucket = { kind: "folder" as const, path: repos.bucketDir }; await syncAll({ bucket: openBucket(bucket), kepr, mirrorDir: join(dataDir, "mirror"), }); kepr.run("UPDATE repos SET public = 1 WHERE name = 'app'"); app = createApp( { domain: "kepr.example", dataDir, adminPasswordHash: "$argon2id$x", bucket }, kepr, ); }); afterAll(() => setSystemTime()); test("the form prefills the newest release, sets a strict same-site cookie and is never cached", async () => { const response = await app.request("/app/issues/new"); expect(response.status).toBe(200); expect(response.headers.get("cache-control")).toBe("no-store"); expect(response.headers.get("set-cookie")).toContain("SameSite=Strict"); const text = await response.text(); expect(text).toContain('name="version" maxlength="200" value="1.2.0"'); expect(text).toContain('name="website"'); }); test("refuses a too-fast, honeypot or cross-site submission", async () => { const session = await openForm(); expect((await post("/app/issues/new", session, issue)).status).toBe(400); tick(3); expect((await post("/app/issues/new", session, { ...issue, website: "x" })).status).toBe(400); expect((await post("/app/issues/new", { ...session, cookie: "" }, issue)).status).toBe(403); const other = await openForm(); expect( (await post("/app/issues/new", { ...session, cookie: other.cookie }, issue)).status, ).toBe(403); expect((await page("/app/issues")).includes("Crash")).toBe(false); }); test("refuses a field over 20,000 characters and keeps what was typed", async () => { const session = await openForm(); tick(5); const response = await post("/app/issues/new", session, { ...issue, body: "x".repeat(20_001) }); expect(response.status).toBe(400); expect(await response.text()).toContain("x".repeat(100)); }); test("filing goes straight to the new issue", async () => { const session = await openForm(); tick(4); const response = await post("/app/issues/new", session, { ...issue, name: "Ada", email: "ada@private.example", }); expect(response.status).toBe(303); expect(response.headers.get("location")).toBe("/app/issues/1"); }); test("the list and thread show the issue escaped, with its name and never its email", async () => { const list = await page("/app/issues"); expect(list).toContain("Crash on <start>"); expect(list).toContain("#1 · bug · v1.2.0"); expect(list).toContain('name="q"'); expect(list).toContain('name="type"'); expect(list).toContain("Ada"); expect(await page("/app/issues?q=missing")).not.toContain('href="/app/issues/1"'); expect(await page("/app/issues?type=question")).not.toContain('href="/app/issues/1"'); expect(await page("/app/issues?q=CRASH&type=bug")).toContain('href="/app/issues/1"'); expect(list).not.toContain("ada@private.example"); const response = await app.request("/app/issues/1"); expect(response.headers.get("referrer-policy")).toBe("no-referrer"); expect(response.headers.get("cache-control")).toBe("no-store"); const thread = await response.text(); expect(thread).toContain("It <b>crashed</b>."); expect(thread).toContain('Ada'); expect(thread).not.toContain("ada@private.example"); expect(thread).toContain('href="/app/issues">back to issues'); expect(thread).toContain('
{ const session = await openForm("/app/issues/1"); tick(3); const body = "Still .\n```\nlog line\n```\n**worse** [site](https://example.com) [x](javascript:alert(1))"; const response = await post("/app/issues/1", session, { body, name: "Bob" }, "198.51.100.30"); expect(response.status).toBe(303); const entry = /^\/app\/issues\/1#entry-(\d+)$/.exec(response.headers.get("location") ?? ""); bobsReply = Number(entry?.[1]); expect(bobsReply).toBeGreaterThan(0); const thread = await page("/app/issues/1"); expect(thread).toContain("Still <broken>."); expect(thread).toContain("
log line");
		expect(thread).toContain("worse");
		expect(thread).toContain('site');
		expect(thread).not.toContain('href="javascript:');
		expect(thread).toContain('Bob');
		expect(await page("/app/issues")).toContain("· 1 reply");
	});

	test("quote starts a reply with the post quoted, and the reply shows what it answers", async () => {
		const quoted = await page(`/app/issues/1?quote=${bobsReply}`);
		expect(quoted).toContain(`name="reply_to" value="${bobsReply}"`);
		expect(quoted).toContain("> Still <broken>.\n> ```");
		expect(await page("/app/issues/1?quote=0")).toContain("> It <b>crashed");
		expect(await page("/app/issues/1?reply=999999")).toContain('name="reply_to" value=""');
		const session = await openForm("/app/issues/1");
		tick(3);
		const reply = { body: "Same here.", reply_to: String(bobsReply) };
		expect((await post("/app/issues/1", session, reply)).status).toBe(303);
		const thread = await page("/app/issues/1");
		expect(thread).toContain(`replying to\nBob`);
		expect(thread).toContain('anonymous');
	});

	test("pictures and logs attach to a reply, are judged by their bytes, and hide with it", async () => {
		const session = await openForm("/app/issues/1");
		tick(3);
		const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
		const disguised = new File([""], "shot.png", { type: "image/png" });
		expect((await postFiles("/app/issues/1", session, "x", [disguised])).status).toBe(400);
		const five = Array.from({ length: 5 }, (_, i) => new File([png], `${i}.png`));
		expect((await postFiles("/app/issues/1", session, "x", five)).status).toBe(400);
		const files = [
			new File([png], "shot.png"),
			new File([png], "report.html"),
			new File(["one\nline \n"], "crash.log"),
		];
		expect((await postFiles("/app/issues/1", session, "See attached.", files)).status).toBe(303);
		const thread = await page("/app/issues/1");
		expect(thread).toContain("line <two>");
		const shots = [...thread.matchAll(/src="(\/app\/issues\/1\/files\/\d+\.png)"/g)].map(
			(m) => m[1] ?? "",
		);
		expect(shots).toHaveLength(2);
		const shot = shots[0] ?? "";
		// The bytes decide the type, so a picture named report.html is offered as report.png.
		expect((await app.request(shots[1] ?? "")).headers.get("content-disposition")).toContain(
			"report.png",
		);
		const log = /href="(\/app\/issues\/1\/files\/\d+\.txt)"/.exec(thread)?.[1] ?? "";
		const image = await app.request(shot);
		expect(image.headers.get("content-type")).toBe("image/png");
		expect(image.headers.get("content-security-policy")).toContain("sandbox");
		expect(new Uint8Array(await image.arrayBuffer())).toEqual(png);
		expect((await app.request(log)).headers.get("content-type")).toBe("text/plain; charset=utf-8");
		expect((await app.request(shot.replace(".png", ".gif"))).status).toBe(404);
		kepr.run("UPDATE issue_entries SET hidden = 1 WHERE body = 'See attached.'");
		expect((await app.request(shot)).status).toBe(404);
		expect(await page("/app/issues/1")).not.toContain(shot);
	});

	test("a refused reply keeps what was typed", async () => {
		const session = await openForm("/app/issues/1");
		tick(3);
		const response = await post("/app/issues/1", session, { body: "y".repeat(20_001), name: "Cy" });
		expect(response.status).toBe(400);
		const text = await response.text();
		expect(text).toContain("y".repeat(100));
		expect(text).toContain('value="Cy"');
		expect((await post("/app/issues/1", session, { body: " " })).status).toBe(400);
	});

	test("twenty replies an hour per address", async () => {
		const session = await openForm("/app/issues/1");
		tick(3);
		const from = (peer: string) => post("/app/issues/1", session, { body: "more" }, peer);
		for (let i = 0; i < 20; i++) expect((await from("198.51.100.50")).status).toBe(303);
		expect((await from("198.51.100.50")).status).toBe(429);
		expect((await from("198.51.100.51")).status).toBe(303);
		kepr.run("UPDATE issue_entries SET hidden = 1 WHERE body = 'more'");
	});

	test("five filings an hour per peer address, and a forged X-Forwarded-For does not help", async () => {
		const session = await openForm();
		tick(3);
		const from = (peer: string, forwardedFor = "") =>
			post("/app/issues/new", session, issue, peer, forwardedFor);
		for (let i = 0; i < 5; i++) {
			expect((await from("198.51.100.7", `192.0.2.${i}`)).status).toBe(303);
		}
		expect((await from("198.51.100.7", "192.0.2.99")).status).toBe(429);
		expect((await from("198.51.100.8")).status).toBe(303);
		tick(3601);
		expect((await from("198.51.100.7")).status).toBe(303);
		// Keyed hashes only: no address, and not the plain SHA-256 Kepr 2 stored.
		const plain = new Bun.CryptoHasher("sha256").update("198.51.100.7").digest("hex");
		const stored = kepr
			.query<{ ip_hash: string }, []>("SELECT ip_hash FROM issues WHERE ip_hash != ''")
			.all()
			.map((row) => row.ip_hash);
		expect(stored.every((hash) => /^[0-9a-f]{64}$/.test(hash) && hash !== plain)).toBe(true);
	});

	test("behind a proxy on loopback, the rightmost X-Forwarded-For entry is the client", async () => {
		const session = await openForm();
		tick(3);
		const via = (peer: string, forwardedFor: string) =>
			post("/app/issues/new", session, issue, peer, forwardedFor);
		// The client forges the left entries; the proxy appended the real address last.
		for (let i = 0; i < 5; i++) {
			expect((await via("127.0.0.1", `10.9.9.${i}, 198.51.100.20`)).status).toBe(303);
		}
		expect((await via("::1", "198.51.100.20")).status).toBe(429);
		expect((await via("::ffff:127.0.0.1", "10.9.9.9, 198.51.100.20")).status).toBe(429);
		expect((await via("127.0.0.1", "198.51.100.20, 198.51.100.21")).status).toBe(303);
	});

	test("an operator reply and a close show on the thread, which then takes no replies", async () => {
		const found = findIssue(kepr, "app", 1);
		if (!found) throw new Error("issue 1 missing");
		const session = await openForm("/app/issues/1");
		addEntry(kepr, found.id, { author: "operator", body: "Fixed, thanks.", at: clock / 1000 });
		kepr.run("UPDATE issues SET status = 'closed', closed_at = 1, fixed_in = ? WHERE id = ?", [
			save,
			found.id,
		]);
		const thread = await page("/app/issues/1");
		expect(thread).toContain('
operator'); expect(thread).toContain("Fixed, thanks."); expect(thread).toContain("◈ closed"); expect(thread).toContain('href="/app/issues?status=closed">back to issues'); expect(thread).toContain(` · fixed in `); expect(thread).not.toContain('id="reply"'); expect(thread).toContain("takes no new replies"); tick(3); expect((await post("/app/issues/1", session, { body: "Late." })).status).toBe(403); expect(await page("/app/issues")).not.toContain('href="/app/issues/1"'); expect(await page("/app/issues?status=closed")).toContain('href="/app/issues/1"'); }); test("hidden issues and every issue route of a private repo are 404", async () => { kepr.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = 2"); expect((await app.request("/app/issues/2")).status).toBe(404); expect((await app.request("/app/issues/999")).status).toBe(404); kepr.run( "INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('secret', 1, 't', 'b', 'bug', 1)", ); for (const path of ["/secret/issues", "/secret/issues/new", "/secret/issues/1"]) { expect((await app.request(path)).status).toBe(404); } const session = await openForm(); for (const path of ["/secret/issues/new", "/secret/issues/1"]) { expect((await post(path, session, { ...issue, body: "x" })).status).toBe(404); } }); test("a blocked address can neither file nor reply, until it is unblocked", async () => { const first = findIssue(kepr, "app", 1); if (!first) throw new Error("issue 1 missing"); const block = blockReporter(kepr, first.id, 1) as number; const filing = await openForm(); const thread = await openForm("/app/issues/3", filing.cookie); tick(4); const refused = await post("/app/issues/new", filing, issue); expect(refused.status).toBe(403); expect(await refused.text()).toContain("This address can't post here."); const reply = { body: "Blocked words." }; expect((await post("/app/issues/3", thread, reply)).status).toBe(403); expect((await post("/app/issues/new", filing, issue, "198.51.100.8")).status).toBe(303); unblockReporter(kepr, block); expect((await post("/app/issues/3", thread, reply)).status).toBe(303); }); });