issues.test.ts

18.6 KB · raw

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	isBlocked,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("public reads leave out contact and IP", () => {52		const issue = findIssue(db, "app", fileIssue(db, newIssue("app", "secret")).number);53		expect(Object.keys(issue ?? {})).not.toContain("contact_email");54		expect(Object.keys(issue ?? {})).not.toContain("ip_hash");55	});5657	test("hidden issues vanish from lists, counts and lookups but still count against the IP", () => {58		const before = filingsSince(db, "ip-a", 0);59		const { number } = fileIssue(db, newIssue("app", "spam"));60		db.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = ?", [number]);61		expect(findIssue(db, "app", number)).toBeNull();62		expect(listIssues(db, "app", "open").map((i) => i.title)).not.toContain("spam");63		expect(issueCounts(db, "app")).toEqual({ open: 3, closed: 0 });64		expect(filingsSince(db, "ip-a", 0)).toBe(before + 1);65		expect(filingsSince(db, "ip-a", 1000)).toBe(0);66	});6768	test("the thread keeps replies in order with their author, name and the reply they answer", () => {69		const issue = findIssue(db, "app", 1);70		if (!issue) throw new Error("issue 1 missing");71		const first = addEntry(db, issue.id, {72			author: "reporter",73			name: "Bo",74			body: "More.",75			at: 2000,76		});77		addEntry(db, issue.id, { author: "operator", body: "Thanks.", at: 3000, replyTo: first });78		expect(threadEntries(db, issue.id).map((e) => [e.author, e.name, e.body, e.reply_to])).toEqual([79			["reporter", "Bo", "More.", null],80			["operator", "", "Thanks.", first],81		]);82		expect(listIssues(db, "app", "open").find((i) => i.number === 1)?.replies).toBe(2);83	});8485	test("a block from a reply takes that reply's address, and only a reply of that issue", () => {86		const [one, two] = [findIssue(db, "app", 1), findIssue(db, "app", 2)];87		if (!one || !two) throw new Error("issues missing");88		const at = 4000;89		const reply = addEntry(db, one.id, { author: "reporter", body: "x", at, ipHash: "ip-r" });90		expect(blockReporter(db, two.id, at, reply)).toBeNull();91		const block = blockReporter(db, one.id, at, reply) as number;92		expect(isBlocked(db, "ip-r")).toBe(true);93		expect(isBlocked(db, "ip-a")).toBe(false);94		unblockReporter(db, block);95	});96});9798describe("issues end to end over HTTP", () => {99	let app: Hono;100	let kepr: Database;101	let save: string;102	let clock = Date.parse("2026-09-26T12:00:00Z");103	const tick = (seconds: number) => {104		clock += seconds * 1000;105		setSystemTime(new Date(clock));106	};107	// A browser session: the form cookie from GET /new, and the form's stamp.108	type Session = { cookie: string; stamp: string };109	const openForm = async (path = "/app/issues/new", cookie = ""): Promise<Session> => {110		const response = await app.request(path, { headers: cookie ? { cookie } : {} });111		const set = response.headers.get("set-cookie")?.split(";")[0] ?? cookie;112		const stamp = /name="stamp" value="([^"]+)"/.exec(await response.text())?.[1] ?? "";113		return { cookie: set, stamp };114	};115	const post = (116		path: string,117		session: Session,118		fields: Record<string, string>,119		peer = "203.0.113.9",120		forwardedFor = "",121	) =>122		app.request(123			path,124			{125				method: "POST",126				headers: {127					cookie: session.cookie,128					...(forwardedFor && { "x-forwarded-for": forwardedFor }),129				},130				body: new URLSearchParams({ stamp: session.stamp, website: "", ...fields }),131			},132			// The env Bun.serve passes: the server, whose requestIP gives the socket peer.133			{ requestIP: () => ({ address: peer }) },134		);135	const issue = {136		title: "Crash on <start>",137		body: "It <b>crashed</b>.",138		type: "bug",139		version: "1.2.0",140	};141	const page = async (path: string) => (await app.request(path)).text();142	const postFiles = (path: string, session: Session, body: string, files: File[]) => {143		const form = new FormData();144		form.append("stamp", session.stamp);145		form.append("website", "");146		form.append("body", body);147		for (const file of files) form.append("files", file);148		return app.request(149			path,150			{ method: "POST", headers: { cookie: session.cookie }, body: form },151			{ requestIP: () => ({ address: "203.0.113.9" }) },152		);153	};154155	beforeAll(async () => {156		setSystemTime(new Date(clock));157		const repos = await makeRepos(["app", "secret"]);158		save = repos.saves.app as string;159		await writeRelease(repos.bucketDir, "app", {160			version: "1.2.0",161			save,162			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],163		});164		const dataDir = await mkdtemp(join(tmpdir(), "kepr-issues-e2e-"));165		kepr = openKeprDb(dataDir);166		const bucket = { kind: "folder" as const, path: repos.bucketDir };167		await syncAll({168			bucket: openBucket(bucket),169			kepr,170			mirrorDir: join(dataDir, "mirror"),171		});172		kepr.run("UPDATE repos SET public = 1 WHERE name = 'app'");173		app = createApp(174			{ domain: "kepr.example", dataDir, adminPasswordHash: "$argon2id$x", bucket },175			kepr,176		);177	});178	afterAll(() => setSystemTime());179180	test("the form prefills the newest release, sets a strict same-site cookie and is never cached", async () => {181		const response = await app.request("/app/issues/new");182		expect(response.status).toBe(200);183		expect(response.headers.get("cache-control")).toBe("no-store");184		expect(response.headers.get("set-cookie")).toContain("SameSite=Strict");185		const text = await response.text();186		expect(text).toContain('name="version" maxlength="200" value="1.2.0"');187		expect(text).toContain('name="website"');188	});189190	test("refuses a too-fast, honeypot or cross-site submission", async () => {191		const session = await openForm();192		expect((await post("/app/issues/new", session, issue)).status).toBe(400);193		tick(3);194		expect((await post("/app/issues/new", session, { ...issue, website: "x" })).status).toBe(400);195		expect((await post("/app/issues/new", { ...session, cookie: "" }, issue)).status).toBe(403);196		const other = await openForm();197		expect(198			(await post("/app/issues/new", { ...session, cookie: other.cookie }, issue)).status,199		).toBe(403);200		expect((await page("/app/issues")).includes("Crash")).toBe(false);201	});202203	test("refuses a field over 20,000 characters and keeps what was typed", async () => {204		const session = await openForm();205		tick(5);206		const response = await post("/app/issues/new", session, { ...issue, body: "x".repeat(20_001) });207		expect(response.status).toBe(400);208		expect(await response.text()).toContain("x".repeat(100));209	});210211	test("filing goes straight to the new issue", async () => {212		const session = await openForm();213		tick(4);214		const response = await post("/app/issues/new", session, {215			...issue,216			name: "Ada",217			email: "[email protected]",218		});219		expect(response.status).toBe(303);220		expect(response.headers.get("location")).toBe("/app/issues/1");221	});222223	test("the list and thread show the issue escaped, with its name and never its email", async () => {224		const list = await page("/app/issues");225		expect(list).toContain("Crash on &lt;start&gt;");226		expect(list).toContain("#1 · bug · v1.2.0");227		expect(list).toContain('name="q"');228		expect(list).toContain('name="type"');229		expect(list).toContain("Ada");230		expect(await page("/app/issues?q=missing")).not.toContain('href="/app/issues/1"');231		expect(await page("/app/issues?type=question")).not.toContain('href="/app/issues/1"');232		expect(await page("/app/issues?q=CRASH&type=bug")).toContain('href="/app/issues/1"');233		expect(list).not.toContain("[email protected]");234		const response = await app.request("/app/issues/1");235		expect(response.headers.get("referrer-policy")).toBe("no-referrer");236		expect(response.headers.get("cache-control")).toBe("no-store");237		const thread = await response.text();238		expect(thread).toContain("It &lt;b&gt;crashed&lt;/b&gt;.");239		expect(thread).toContain('<span class="issue-author">Ada</span>');240		expect(thread).not.toContain("[email protected]");241		expect(thread).toContain('href="/app/issues">back to issues</a>');242		expect(thread).toContain('<form class="issue-composer" id="reply"');243	});244245	let bobsReply = 0;246247	test("anyone can reply, in Markdown with raw HTML escaped and links marked as theirs", async () => {248		const session = await openForm("/app/issues/1");249		tick(3);250		const body =251			"Still <broken>.\n```\nlog line\n```\n**worse** [site](https://example.com) [x](javascript:alert(1))";252		const response = await post("/app/issues/1", session, { body, name: "Bob" }, "198.51.100.30");253		expect(response.status).toBe(303);254		const entry = /^\/app\/issues\/1#entry-(\d+)$/.exec(response.headers.get("location") ?? "");255		bobsReply = Number(entry?.[1]);256		expect(bobsReply).toBeGreaterThan(0);257		const thread = await page("/app/issues/1");258		expect(thread).toContain("Still &lt;broken&gt;.");259		expect(thread).toContain("<pre><code>log line");260		expect(thread).toContain("<strong>worse</strong>");261		expect(thread).toContain('<a href="https://example.com" rel="nofollow ugc noopener">site</a>');262		expect(thread).not.toContain('href="javascript:');263		expect(thread).toContain('<span class="issue-author">Bob</span>');264		expect(await page("/app/issues")).toContain("· 1 reply");265	});266267	test("quote starts a reply with the post quoted, and the reply shows what it answers", async () => {268		const quoted = await page(`/app/issues/1?quote=${bobsReply}`);269		expect(quoted).toContain(`name="reply_to" value="${bobsReply}"`);270		expect(quoted).toContain("&gt; Still &lt;broken&gt;.\n&gt; ```");271		expect(await page("/app/issues/1?quote=0")).toContain("&gt; It &lt;b&gt;crashed");272		expect(await page("/app/issues/1?reply=999999")).toContain('name="reply_to" value=""');273		const session = await openForm("/app/issues/1");274		tick(3);275		const reply = { body: "Same here.", reply_to: String(bobsReply) };276		expect((await post("/app/issues/1", session, reply)).status).toBe(303);277		const thread = await page("/app/issues/1");278		expect(thread).toContain(`replying to\n<a href="#entry-${bobsReply}">Bob</a>`);279		expect(thread).toContain('<span class="issue-author">anonymous</span>');280	});281282	test("pictures and logs attach to a reply, are judged by their bytes, and hide with it", async () => {283		const session = await openForm("/app/issues/1");284		tick(3);285		const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);286		const disguised = new File(["<svg onload=alert(1)>"], "shot.png", { type: "image/png" });287		expect((await postFiles("/app/issues/1", session, "x", [disguised])).status).toBe(400);288		const five = Array.from({ length: 5 }, (_, i) => new File([png], `${i}.png`));289		expect((await postFiles("/app/issues/1", session, "x", five)).status).toBe(400);290		const files = [291			new File([png], "shot.png"),292			new File([png], "report.html"),293			new File(["one\nline <two>\n"], "crash.log"),294		];295		expect((await postFiles("/app/issues/1", session, "See attached.", files)).status).toBe(303);296		const thread = await page("/app/issues/1");297		expect(thread).toContain("line &lt;two&gt;");298		const shots = [...thread.matchAll(/src="(\/app\/issues\/1\/files\/\d+\.png)"/g)].map(299			(m) => m[1] ?? "",300		);301		expect(shots).toHaveLength(2);302		const shot = shots[0] ?? "";303		// The bytes decide the type, so a picture named report.html is offered as report.png.304		expect((await app.request(shots[1] ?? "")).headers.get("content-disposition")).toContain(305			"report.png",306		);307		const log = /href="(\/app\/issues\/1\/files\/\d+\.txt)"/.exec(thread)?.[1] ?? "";308		const image = await app.request(shot);309		expect(image.headers.get("content-type")).toBe("image/png");310		expect(image.headers.get("content-security-policy")).toContain("sandbox");311		expect(new Uint8Array(await image.arrayBuffer())).toEqual(png);312		expect((await app.request(log)).headers.get("content-type")).toBe("text/plain; charset=utf-8");313		expect((await app.request(shot.replace(".png", ".gif"))).status).toBe(404);314		kepr.run("UPDATE issue_entries SET hidden = 1 WHERE body = 'See attached.'");315		expect((await app.request(shot)).status).toBe(404);316		expect(await page("/app/issues/1")).not.toContain(shot);317	});318319	test("a refused reply keeps what was typed", async () => {320		const session = await openForm("/app/issues/1");321		tick(3);322		const response = await post("/app/issues/1", session, { body: "y".repeat(20_001), name: "Cy" });323		expect(response.status).toBe(400);324		const text = await response.text();325		expect(text).toContain("y".repeat(100));326		expect(text).toContain('value="Cy"');327		expect((await post("/app/issues/1", session, { body: " " })).status).toBe(400);328	});329330	test("twenty replies an hour per address", async () => {331		const session = await openForm("/app/issues/1");332		tick(3);333		const from = (peer: string) => post("/app/issues/1", session, { body: "more" }, peer);334		for (let i = 0; i < 20; i++) expect((await from("198.51.100.50")).status).toBe(303);335		expect((await from("198.51.100.50")).status).toBe(429);336		expect((await from("198.51.100.51")).status).toBe(303);337		kepr.run("UPDATE issue_entries SET hidden = 1 WHERE body = 'more'");338	});339340	test("five filings an hour per peer address, and a forged X-Forwarded-For does not help", async () => {341		const session = await openForm();342		tick(3);343		const from = (peer: string, forwardedFor = "") =>344			post("/app/issues/new", session, issue, peer, forwardedFor);345		for (let i = 0; i < 5; i++) {346			expect((await from("198.51.100.7", `192.0.2.${i}`)).status).toBe(303);347		}348		expect((await from("198.51.100.7", "192.0.2.99")).status).toBe(429);349		expect((await from("198.51.100.8")).status).toBe(303);350		tick(3601);351		expect((await from("198.51.100.7")).status).toBe(303);352		// Keyed hashes only: no address, and not the plain SHA-256 Kepr 2 stored.353		const plain = new Bun.CryptoHasher("sha256").update("198.51.100.7").digest("hex");354		const stored = kepr355			.query<{ ip_hash: string }, []>("SELECT ip_hash FROM issues WHERE ip_hash != ''")356			.all()357			.map((row) => row.ip_hash);358		expect(stored.every((hash) => /^[0-9a-f]{64}$/.test(hash) && hash !== plain)).toBe(true);359	});360361	test("behind a proxy on loopback, the rightmost X-Forwarded-For entry is the client", async () => {362		const session = await openForm();363		tick(3);364		const via = (peer: string, forwardedFor: string) =>365			post("/app/issues/new", session, issue, peer, forwardedFor);366		// The client forges the left entries; the proxy appended the real address last.367		for (let i = 0; i < 5; i++) {368			expect((await via("127.0.0.1", `10.9.9.${i}, 198.51.100.20`)).status).toBe(303);369		}370		expect((await via("::1", "198.51.100.20")).status).toBe(429);371		expect((await via("::ffff:127.0.0.1", "10.9.9.9, 198.51.100.20")).status).toBe(429);372		expect((await via("127.0.0.1", "198.51.100.20, 198.51.100.21")).status).toBe(303);373	});374375	test("an operator reply and a close show on the thread, which then takes no replies", async () => {376		const found = findIssue(kepr, "app", 1);377		if (!found) throw new Error("issue 1 missing");378		const session = await openForm("/app/issues/1");379		addEntry(kepr, found.id, { author: "operator", body: "Fixed, thanks.", at: clock / 1000 });380		kepr.run("UPDATE issues SET status = 'closed', closed_at = 1, fixed_in = ? WHERE id = ?", [381			save,382			found.id,383		]);384		const thread = await page("/app/issues/1");385		expect(thread).toContain('<article class="issue-post issue-post--operator"');386		expect(thread).toContain('<span class="issue-badge">operator</span>');387		expect(thread).toContain("Fixed, thanks.");388		expect(thread).toContain("◈ closed");389		expect(thread).toContain('href="/app/issues?status=closed">back to issues</a>');390		expect(thread).toContain(` · fixed in <a class="history-id" href="/app/saves/${save}">`);391		expect(thread).not.toContain('id="reply"');392		expect(thread).toContain("takes no new replies");393		tick(3);394		expect((await post("/app/issues/1", session, { body: "Late." })).status).toBe(403);395		expect(await page("/app/issues")).not.toContain('href="/app/issues/1"');396		expect(await page("/app/issues?status=closed")).toContain('href="/app/issues/1"');397	});398399	test("hidden issues and every issue route of a private repo are 404", async () => {400		kepr.run("UPDATE issues SET hidden = 1 WHERE repo = 'app' AND number = 2");401		expect((await app.request("/app/issues/2")).status).toBe(404);402		expect((await app.request("/app/issues/999")).status).toBe(404);403		kepr.run(404			"INSERT INTO issues (repo, number, title, body, type, filed_at) VALUES ('secret', 1, 't', 'b', 'bug', 1)",405		);406		for (const path of ["/secret/issues", "/secret/issues/new", "/secret/issues/1"]) {407			expect((await app.request(path)).status).toBe(404);408		}409		const session = await openForm();410		for (const path of ["/secret/issues/new", "/secret/issues/1"]) {411			expect((await post(path, session, { ...issue, body: "x" })).status).toBe(404);412		}413	});414415	test("a blocked address can neither file nor reply, until it is unblocked", async () => {416		const first = findIssue(kepr, "app", 1);417		if (!first) throw new Error("issue 1 missing");418		const block = blockReporter(kepr, first.id, 1) as number;419		const filing = await openForm();420		const thread = await openForm("/app/issues/3", filing.cookie);421		tick(4);422		const refused = await post("/app/issues/new", filing, issue);423		expect(refused.status).toBe(403);424		expect(await refused.text()).toContain("This address can&#39;t post here.");425		const reply = { body: "Blocked words." };426		expect((await post("/app/issues/3", thread, reply)).status).toBe(403);427		expect((await post("/app/issues/new", filing, issue, "198.51.100.8")).status).toBe(303);428		unblockReporter(kepr, block);429		expect((await post("/app/issues/3", thread, reply)).status).toBe(303);430	});431});