issues.test.ts

12.8 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		});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 &lt;start&gt;");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 &lt;b&gt;crashed&lt;/b&gt;.");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 &lt;broken&gt;.");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		// Keyed hashes only: no address, and not the plain SHA-256 Kepr 2 stored.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		// The client forges the left entries; the proxy appended the real address last.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});