console.test.ts

17.6 KB · raw

1import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";2import { join } from "node:path";3import { fileIssue } from "../src/issues/store.ts";4import { Browser, type Instance, instance } from "./browser.ts";5import { makeRepos, writeRelease } from "./fixture.ts";67let clock = Date.parse("2026-09-26T12:00:00Z");8const tick = (seconds: number) => {9	clock += seconds * 1000;10	setSystemTime(new Date(clock));11};1213describe("repos and visibility", () => {14	let inst: Instance;15	let browser: Browser;16	let bucketDir: string;17	let save: string;18	const publicPaths = () => [19		"/app",20		"/app/files/README.md",21		"/app/raw/README.md",22		`/app/saves/${save}`,23		`/app/archive/${save}.tar.gz`,24		"/app/archive/HEAD.tar.gz",25		"/app/archives",26		"/app/releases",27		"/app/releases/latest",28		"/app/releases/latest.json",29		"/app/releases/history.json",30		"/app/releases/latest/version.txt",31		"/app/releases/latest/linux-x86_64",32		"/app/releases/1.0.0/checksums.txt",33		"/app/releases/1.0.0/linux-x86_64",34		"/app/install.sh",35		"/api/app/releases/latest",36		"/api/app/releases/history",37		"/app/update-info",38		"/app/meta",39		"/app/issues",40		"/app/issues/new",41		"/app/issues/1",42	];43	const statuses = async () => {44		const out: string[] = [];45		for (const path of publicPaths()) out.push(`${path} ${(await inst.app.request(path)).status}`);46		return out;47	};48	const text = async (path: string) => (await inst.app.request(path)).text();49	const publish = (repo: string, fields: Record<string, string>) =>50		browser.post(`/admin/repos/${repo}`, { sort_order: "0", ...fields });5152	beforeAll(async () => {53		setSystemTime(new Date(clock));54		const repos = await makeRepos(["app", "other"]);55		bucketDir = repos.bucketDir;56		save = repos.saves.app as string;57		await writeRelease(bucketDir, "app", {58			version: "1.0.0",59			save,60			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],61		});62		inst = await instance(bucketDir);63		inst.kepr.run("INSERT INTO settings (key, value) VALUES ('handle', 'june')");64		fileIssueDirect(inst, "app");65		browser = new Browser(inst.app);66		await browser.signIn();67	});68	afterAll(() => setSystemTime());6970	test("every repo the sync found is listed, private, with its last sync", async () => {71		const page = await (await browser.get("/admin/repos")).text();72		expect(page).toContain('id="repo-app"');73		expect(page).toContain('id="repo-other"');74		expect(page.match(/<span>private<\/span>/g)?.length).toBe(2);75		expect(page).toContain("last sync 2026-09-26 12:00 UTC");76		expect((await statuses()).every((line) => line.endsWith(" 404"))).toBe(true);77	});7879	test("bad settings are refused and change nothing", async () => {80		for (const fields of <Record<string, string>[]>[81			{ homepage: "javascript:alert(1)" },82			{ sort_order: "first" },83			{ install1: "one\ntwo" },84			{ category: "x".repeat(501) },85		]) {86			expect((await publish("app", { public: "1", ...fields })).status).toBe(400);87		}88		expect((await inst.app.request("/app")).status).toBe(404);89		expect((await browser.post("/admin/repos/nowhere", { public: "1" })).status).toBe(404);90	});9192	test("publishing opens every public surface, with the settings shown", async () => {93		await publish("other", { public: "1" });94		const response = await publish("app", {95			public: "1",96			category: "tools",97			homepage: "https://app.example",98			install1: "nix profile add app",99		});100		expect(response.status).toBe(303);101		const lines = await statuses();102		expect(lines.filter((line) => line.endsWith(" 404"))).toEqual([]);103		expect(await text("/app")).toContain("https://app.example");104		expect(await text("/app")).toContain("nix profile add app");105		expect(await text("/")).toContain('href="/app"');106		expect(await text("/browse")).toContain('href="/app"');107		expect(await text("/@june")).toContain('href="/app"');108		expect(await text("/sitemap.xml")).toContain("/app</loc>");109	});110111	test("flipping a repo private 404s every surface and drops it from lists and counts", async () => {112		const stat = async () =>113			[...(await text("/@june")).matchAll(/profile-stat-number">([^<]*)</g)].map((m) => m[1]);114		// Both download routes above counted; the issue is the repo's one.115		expect(await stat()).toEqual(["2", "2", "1", "2"]);116		await publish("app", { category: "tools" });117		expect((await statuses()).filter((line) => !line.endsWith(" 404"))).toEqual([]);118		for (const path of ["/", "/browse", "/@june"]) expect(await text(path)).not.toContain("/app");119		expect(await text("/sitemap.xml")).not.toContain("/app<");120		expect(await text("/")).not.toContain("tools");121		expect(await stat()).toEqual(["1", "1", "0", "0"]);122		// Settings survive the flip.123		expect(await (await browser.get("/admin/repos")).text()).toContain('value="tools"');124	});125126	test("sync now reads the bucket at once and records the time", async () => {127		await writeRelease(bucketDir, "other", {128			version: "2.0.0",129			save: save,130			files: [{ platform: "linux-x86_64", name: "other", bytes: new Uint8Array([2]) }],131		});132		tick(60);133		expect((await inst.app.request("/other/releases/latest/version.txt")).status).toBe(404);134		const response = await browser.post("/admin/sync", {});135		expect(response.status).toBe(303);136		expect(await text("/other/releases/latest/version.txt")).toBe("2.0.0");137		expect(await (await browser.get("/admin/repos")).text()).toContain(138			"last sync 2026-09-26 12:01 UTC",139		);140	});141});142143describe("issues: filed, answered and closed end to end through real routes", () => {144	let inst: Instance;145	let operator: Browser;146	let save: string;147	const text = async (path: string) => (await inst.app.request(path)).text();148	const act = (fields: Record<string, string>) => operator.post("/admin/issues/app/1", fields);149150	beforeAll(async () => {151		setSystemTime(new Date(clock));152		const repos = await makeRepos(["app", "hush"]);153		save = repos.saves.app as string;154		inst = await instance(repos.bucketDir);155		operator = new Browser(inst.app);156		await operator.signIn();157		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });158		// A reporter files through the public form, as a browser would.159		const reporter = new Browser(inst.app, "198.51.100.40");160		const form = await (await reporter.request("/app/issues/new")).text();161		const stamp = /name="stamp" value="([^"]+)"/.exec(form)?.[1] ?? "";162		tick(4);163		const filed = await reporter.request("/app/issues/new", {164			method: "POST",165			body: new URLSearchParams({166				stamp,167				website: "",168				title: "Crash <on> start",169				body: "It crashed.",170				type: "bug",171				version: "",172				name: "Ada",173				email: "[email protected]",174			}),175		});176		expect(filed.status).toBe(200);177		fileIssueDirect(inst, "hush");178	});179	afterAll(() => setSystemTime());180181	test("the console lists issues across repos, private ones too, with the contact detail", async () => {182		const list = await (await operator.get("/admin/issues")).text();183		expect(list).toContain('href="/admin/issues/app/1"');184		expect(list).toContain('href="/admin/issues/hush/1"');185		expect(list).toContain("Crash &lt;on&gt; start");186		const page = await (await operator.get("/admin/issues/app/1")).text();187		expect(page).toContain("contact: [email protected]");188		expect(await text("/app/issues/1")).not.toContain("[email protected]");189	});190191	test("an operator reply shows on the public thread, marked", async () => {192		expect((await act({ action: "reply", body: "Fixed <soon>." })).status).toBe(303);193		expect((await act({ action: "reply", body: "  " })).status).toBe(400);194		const thread = await text("/app/issues/1");195		expect(thread).toContain('<span class="issue-badge">operator</span>');196		expect(thread).toContain("Fixed &lt;soon&gt;.");197	});198199	test("close with a fixed-in save prefix, then reopen", async () => {200		expect((await act({ action: "close", fixed_in: "zzzzzz" })).status).toBe(400);201		expect((await act({ action: "close", fixed_in: "0123456789ab" })).status).toBe(400);202		expect((await act({ action: "close", fixed_in: save.slice(0, 6) })).status).toBe(303);203		const thread = await text("/app/issues/1");204		expect(thread).toContain("◈ closed");205		expect(thread).toContain(`fixed in <a class="history-id" href="/app/saves/${save}">`);206		expect(await text("/app/issues?status=closed")).toContain('href="/app/issues/1"');207		expect(await (await operator.get("/admin/issues?filter=closed")).text()).toContain(208			'href="/admin/issues/app/1"',209		);210		expect((await act({ action: "reopen" })).status).toBe(303);211		expect(await text("/app/issues/1")).toContain("◉ open");212		expect(await text("/app/issues/1")).not.toContain("fixed in");213	});214215	test("set type, and hide for spam: gone publicly, kept in the console", async () => {216		expect((await act({ action: "type", type: "nonsense" })).status).toBe(400);217		expect((await act({ action: "type", type: "idea" })).status).toBe(303);218		expect(await text("/app/issues")).toContain("#1 · idea");219		expect((await act({ action: "hide" })).status).toBe(303);220		expect((await inst.app.request("/app/issues/1")).status).toBe(404);221		expect(await (await operator.get("/admin/issues")).text()).not.toContain("/admin/issues/app/1");222		expect(await (await operator.get("/admin/issues?filter=hidden")).text()).toContain(223			'href="/admin/issues/app/1"',224		);225		expect((await act({ action: "unhide" })).status).toBe(303);226		expect((await inst.app.request("/app/issues/1")).status).toBe(200);227		expect((await act({ action: "delete" })).status).toBe(400);228		expect((await operator.post("/admin/issues/app/9", { action: "reopen" })).status).toBe(404);229	});230});231232function fileIssueDirect(inst: Instance, repo: string): void {233	fileIssue(inst.kepr, {234		repo,235		title: `A ${repo} issue`,236		body: "b",237		type: "bug",238		version: "",239		reporterName: "",240		contactEmail: "",241		ipHash: "x",242		filedAt: 1,243	});244}245246describe("releases: counts, displayed notes and hiding", () => {247	let inst: Instance;248	let operator: Browser;249	const text = async (path: string) => (await inst.app.request(path)).text();250	const edit = (version: string, fields: Record<string, string>) =>251		operator.post(`/admin/releases/app/${version}`, fields);252253	beforeAll(async () => {254		const repos = await makeRepos(["app"]);255		const save = repos.saves.app as string;256		for (const version of ["1.0.0", "1.1.0"]) {257			await writeRelease(repos.bucketDir, "app", {258				version,259				save,260				notes: `notes for ${version}`,261				files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],262			});263		}264		inst = await instance(repos.bucketDir);265		operator = new Browser(inst.app);266		await operator.signIn();267		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });268		await inst.app.request("/app/releases/1.1.0/linux-x86_64");269	});270271	test("every version is listed with its download counts and notes", async () => {272		const page = await (await operator.get("/admin/releases")).text();273		expect(page).toContain('id="app-1.1.0"');274		expect(page).toContain('id="app-1.0.0"');275		expect(page.indexOf('id="app-1.1.0"')).toBeLessThan(page.indexOf('id="app-1.0.0"'));276		expect(page).toContain('<span>1 download</span><span class="muted">linux-x86_64 1</span>');277		expect(page).toContain(">notes for 1.1.0</textarea>");278	});279280	test("edited notes replace the record's everywhere; the record's own text clears the edit", async () => {281		expect((await edit("1.1.0", { notes: "Better <notes>." })).status).toBe(303);282		expect(JSON.parse(await text("/api/app/releases/latest")).notes).toBe("Better <notes>.");283		expect(await text("/app/releases")).toContain("Better &lt;notes&gt;.");284		expect(await (await operator.get("/admin/releases")).text()).toContain("(edited)");285		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);286		const row = inst.kepr.query("SELECT notes FROM release_settings WHERE version = '1.1.0'").get();287		expect(row).toEqual({ notes: null });288		expect((await edit("1.1.0", { notes: "x".repeat(20_001) })).status).toBe(400);289	});290291	test("a hidden version leaves pages, latest and every feed, and stays in the console", async () => {292		expect((await edit("1.1.0", { notes: "notes for 1.1.0", hidden: "1" })).status).toBe(303);293		expect(await text("/app/releases/latest/version.txt")).toBe("1.0.0");294		expect(JSON.parse(await text("/api/app/releases/latest")).version).toBe("1.0.0");295		expect(await text("/api/app/releases/history")).not.toContain("1.1.0");296		expect(await text("/app/releases")).not.toContain("1.1.0");297		expect((await inst.app.request("/app/releases/1.1.0/linux-x86_64")).status).toBe(404);298		const page = await (await operator.get("/admin/releases")).text();299		expect(page).toContain('id="app-1.1.0"');300		expect(page).toContain('value="1" checked> hidden');301		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);302		expect(await text("/app/releases/latest/version.txt")).toBe("1.1.0");303		expect((await edit("9.9.9", { notes: "" })).status).toBe(404);304		expect((await operator.post("/admin/releases/nowhere/1.0.0", { notes: "" })).status).toBe(404);305	});306});307308describe("site and profile settings, and the signature upload", () => {309	let inst: Instance;310	let operator: Browser;311	const PNG = Uint8Array.from(312		atob(313			"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",314		),315		(ch) => ch.charCodeAt(0),316	);317	const settings = {318		site_name: "ada's library",319		tagline: "Small <tools>.",320		branding: "by ada",321		handle: "ada",322		bio: "Writes code.",323		location: "Lisbon",324		link: "https://ada.example",325		building_since: "june 2026",326	};327	const text = async (path: string) => (await inst.app.request(path)).text();328	const upload = (bytes: Uint8Array | string, name: string, type: string, extra = {}) => {329		const form = new FormData();330		for (const [key, value] of Object.entries({ ...settings, ...extra })) form.set(key, value);331		form.set("signature", new File([bytes], name, { type }));332		return operator.post("/admin/site", form);333	};334	const signature = () =>335		inst.kepr.query("SELECT value FROM settings WHERE key = 'signature'").get();336337	beforeAll(async () => {338		const repos = await makeRepos(["app"]);339		inst = await instance(repos.bucketDir);340		operator = new Browser(inst.app);341		await operator.signIn();342	});343344	test("settings are saved and every public page reads them", async () => {345		expect((await operator.post("/admin/site", settings)).status).toBe(303);346		const front = await text("/");347		expect(front).toContain("<title>ada&#39;s library</title>");348		expect(front).toContain("Small &lt;tools&gt;.");349		expect(front).toContain('<span class="wordmark-by">by ada</span>');350		const profile = await text("/@ada");351		for (const value of [352			"Writes code.",353			"Lisbon",354			'href="https://ada.example"',355			"building since june 2026",356		]) {357			expect(profile).toContain(value);358		}359		expect(await (await operator.get("/admin/site")).text()).toContain('value="Lisbon"');360		// Emptying a field removes the setting, so its default comes back.361		expect((await operator.post("/admin/site", { ...settings, site_name: "" })).status).toBe(303);362		expect(await text("/")).toContain("<title>kepr</title>");363	});364365	test("a bad handle, link or overlong field is refused", async () => {366		for (const bad of [367			{ handle: "a/b" },368			{ link: "javascript:alert(1)" },369			{ bio: "x".repeat(2001) },370		]) {371			expect((await operator.post("/admin/site", { ...settings, ...bad })).status).toBe(400);372		}373		expect((await inst.app.request("/@ada")).status).toBe(200);374	});375376	test("a PNG signature is stored in data/assets and shown on the profile", async () => {377		expect((await upload(PNG, "sig.png", "image/png")).status).toBe(303);378		const name = (signature() as { value: string }).value;379		expect(name).toMatch(/^signature-[0-9a-f]{16}\.png$/);380		expect(await Bun.file(join(inst.dataDir, "assets", name)).exists()).toBe(true);381		expect(await text("/@ada")).toContain(382			`<img class="profile-signature" src="/static/assets/${name}"`,383		);384		const served = await inst.app.request(`/static/assets/${name}`);385		expect(served.headers.get("content-type")).toBe("image/png");386		expect(new Uint8Array(await served.arrayBuffer())).toEqual(PNG);387	});388389	test("SVG, or anything whose bytes are not PNG, JPEG or WebP, is refused whatever it claims", async () => {390		const before = signature();391		const svg = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>';392		expect((await upload(svg, "sig.svg", "image/svg+xml")).status).toBe(400);393		expect((await upload(svg, "sig.png", "image/png")).status).toBe(400);394		expect((await upload("GIF89a", "sig.gif", "image/gif")).status).toBe(400);395		expect(signature()).toEqual(before);396		expect((await upload(new Uint8Array(70 * 1024), "big.png", "image/png")).status).toBe(413);397		// Between the 60 KB limit and the 64 KiB body cap, the limit answers.398		const png = (size: number) => {399			const bytes = new Uint8Array(size);400			bytes.set(PNG);401			return bytes;402		};403		const over = await upload(png(60 * 1024 + 1), "big.png", "image/png");404		expect(over.status).toBe(400);405		expect(await over.text()).toContain("The signature must be under 60 KB.");406		expect(signature()).toEqual(before);407		expect((await upload(png(60 * 1024), "edge.png", "image/png")).status).toBe(303);408	});409410	test("a new signature replaces the old file; removing it brings the handle back", async () => {411		const old = (signature() as { value: string }).value;412		const webp = new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 1]);413		expect((await upload(webp, "sig.webp", "image/webp")).status).toBe(303);414		const name = (signature() as { value: string }).value;415		expect(name).toMatch(/\.webp$/);416		expect(await Bun.file(join(inst.dataDir, "assets", old)).exists()).toBe(false);417		expect(418			(await operator.post("/admin/site", { ...settings, remove_signature: "1" })).status,419		).toBe(303);420		expect(signature()).toBeNull();421		expect(await Bun.file(join(inst.dataDir, "assets", name)).exists()).toBe(false);422		expect(await text("/@ada")).toContain('<h1 class="profile-username">ada</h1>');423	});424});