issues.test.ts

12.4 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	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	// A browser session: the form cookie from GET /new, and the form's stamp.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			// The env Bun.serve passes: the server, whose requestIP gives the socket peer.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 &lt;start&gt;");206		expect(list).toContain("#1 · bug · v1.2.0");207		const thread = await page("/app/issues/1");208		expect(thread).toContain("It &lt;b&gt;crashed&lt;/b&gt;.");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 &lt;broken&gt;.");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		// Keyed hashes only: no address, and not the plain SHA-256 Kepr 2 stored.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		// The client forges the left entries; the proxy appended the real address last.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});