console.test.ts

20.4 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		"/app?panel=issues",43		"/app/file?path=README.md",44		"/app/bundle",45		"/app/bundle.blake3",46		"/app/releases/1.0.0",47	];48	const statuses = async () => {49		const out: string[] = [];50		for (const path of publicPaths()) out.push(`${path} ${(await inst.app.request(path)).status}`);51		return out;52	};53	const text = async (path: string) => (await inst.app.request(path)).text();54	const publish = (repo: string, fields: Record<string, string>) =>55		browser.post(`/admin/repos/${repo}`, { sort_order: "0", ...fields });5657	beforeAll(async () => {58		setSystemTime(new Date(clock));59		const repos = await makeRepos(["app", "other"]);60		bucketDir = repos.bucketDir;61		save = repos.saves.app as string;62		await writeRelease(bucketDir, "app", {63			version: "1.0.0",64			save,65			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],66		});67		inst = await instance(bucketDir);68		inst.kepr.run("INSERT INTO settings (key, value) VALUES ('handle', 'june')");69		fileIssueDirect(inst, "app");70		browser = new Browser(inst.app);71		await browser.signIn();72	});73	afterAll(() => setSystemTime());7475	test("every repo the sync found is listed, private, with its last sync", async () => {76		const page = await (await browser.get("/admin/repos")).text();77		expect(page).toContain('id="repo-app"');78		expect(page).toContain('id="repo-other"');79		expect(page.match(/action="\/admin\/repos\//g)?.length).toBe(1);80		expect(page).toContain("last sync 2026-09-26 12:00 UTC");81		expect((await statuses()).every((line) => line.endsWith(" 404"))).toBe(true);82	});8384	test("repo selection focuses one settings form without a search form", async () => {85		const page = await (await browser.get("/admin/repos?repo=other")).text();86		expect(page).toContain('action="/admin/repos/other"');87		expect(page).toContain('id="repo-app"');88		expect(page).not.toContain('type="search"');89		const saved = await publish("other", {});90		expect(saved.headers.get("location")).toBe("/admin/repos?repo=other&saved=1#repo-editor");91		expect(await (await browser.get("/admin/repos?repo=other&saved=1")).text()).toContain(92			"Changes saved.",93		);94	});9596	test("bad settings are refused and change nothing", async () => {97		for (const fields of <Record<string, string>[]>[98			{ homepage: "javascript:alert(1)" },99			{ sort_order: "first" },100			{ install1: "one\ntwo" },101			{ category: "x".repeat(501) },102		]) {103			expect((await publish("app", { public: "1", ...fields })).status).toBe(400);104		}105		expect((await inst.app.request("/app")).status).toBe(404);106		expect((await browser.post("/admin/repos/nowhere", { public: "1" })).status).toBe(404);107	});108109	test("publishing opens every public surface, with the settings shown", async () => {110		await publish("other", { public: "1" });111		const response = await publish("app", {112			public: "1",113			category: "tools",114			homepage: "https://app.example",115			install1: "nix profile add app",116		});117		expect(response.status).toBe(303);118		const lines = await statuses();119		expect(lines.filter((line) => line.endsWith(" 404"))).toEqual([]);120		expect(await text("/app")).toContain("https://app.example");121		expect(await text("/app")).toContain("nix profile add app");122		expect(await text("/")).toContain('href="/app"');123		expect(await text("/browse")).toContain('href="/app"');124		expect(await text("/@june")).toContain('href="/app"');125		expect(await text("/sitemap.xml")).toContain("/app</loc>");126	});127128	test("flipping a repo private 404s every surface and drops it from lists and counts", async () => {129		const stat = async () =>130			[...(await text("/@june")).matchAll(/profile-stat-number">([^<]*)</g)].map((m) => m[1]);131		// Both download routes above counted; the issue is the repo's one.132		expect(await stat()).toEqual(["2", "2", "1", "2"]);133		await publish("app", { category: "tools" });134		expect((await statuses()).filter((line) => !line.endsWith(" 404"))).toEqual([]);135		for (const path of ["/", "/browse", "/@june"]) expect(await text(path)).not.toContain("/app");136		expect(await text("/sitemap.xml")).not.toContain("/app<");137		expect(await text("/")).not.toContain("tools");138		expect(await stat()).toEqual(["1", "1", "0", "0"]);139		// Settings survive the flip.140		expect(await (await browser.get("/admin/repos")).text()).toContain('value="tools"');141	});142143	test("sync now reads the bucket at once and records the time", async () => {144		await writeRelease(bucketDir, "other", {145			version: "2.0.0",146			save: save,147			files: [{ platform: "linux-x86_64", name: "other", bytes: new Uint8Array([2]) }],148		});149		tick(60);150		expect((await inst.app.request("/other/releases/latest/version.txt")).status).toBe(404);151		const response = await browser.post("/admin/sync", {});152		expect(response.status).toBe(303);153		expect(await text("/other/releases/latest/version.txt")).toBe("2.0.0");154		expect(await (await browser.get("/admin/repos")).text()).toContain(155			"last sync 2026-09-26 12:01 UTC",156		);157	});158});159160describe("issues: filed, answered and closed end to end through real routes", () => {161	let inst: Instance;162	let operator: Browser;163	let save: string;164	const text = async (path: string) => (await inst.app.request(path)).text();165	const act = (fields: Record<string, string>) => operator.post("/admin/issues/app/1", fields);166167	beforeAll(async () => {168		setSystemTime(new Date(clock));169		const repos = await makeRepos(["app", "hush"]);170		save = repos.saves.app as string;171		inst = await instance(repos.bucketDir);172		operator = new Browser(inst.app);173		await operator.signIn();174		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });175		// A reporter files through the public form, as a browser would.176		const reporter = new Browser(inst.app, "198.51.100.40");177		const form = await (await reporter.request("/app/issues/new")).text();178		const stamp = /name="stamp" value="([^"]+)"/.exec(form)?.[1] ?? "";179		tick(4);180		const filed = await reporter.request("/app/issues/new", {181			method: "POST",182			body: new URLSearchParams({183				stamp,184				website: "",185				title: "Crash <on> start",186				body: "It crashed.",187				type: "bug",188				version: "",189				name: "Ada",190				email: "[email protected]",191			}),192		});193		expect(filed.status).toBe(200);194		fileIssueDirect(inst, "hush");195	});196	afterAll(() => setSystemTime());197198	test("the console lists issues across repos, private ones too, with the contact detail", async () => {199		const list = await (await operator.get("/admin/issues")).text();200		expect(list).toContain('href="/admin/issues/app/1"');201		expect(list).toContain('href="/admin/issues/hush/1"');202		expect(list).toContain("Crash &lt;on&gt; start");203		const page = await (await operator.get("/admin/issues/app/1")).text();204		expect(page).toContain("contact: [email protected]");205		expect(await text("/app/issues/1")).not.toContain("[email protected]");206	});207208	test("an operator reply shows on the public thread, marked", async () => {209		expect((await act({ action: "reply", body: "Fixed <soon>." })).status).toBe(303);210		expect((await act({ action: "reply", body: "  " })).status).toBe(400);211		const thread = await text("/app/issues/1");212		expect(thread).toContain('<span class="issue-badge">operator</span>');213		expect(thread).toContain("Fixed &lt;soon&gt;.");214	});215216	test("close with a fixed-in save prefix, then reopen", async () => {217		expect((await act({ action: "close", fixed_in: "zzzzzz" })).status).toBe(400);218		expect((await act({ action: "close", fixed_in: "0123456789ab" })).status).toBe(400);219		expect((await act({ action: "close", fixed_in: save.slice(0, 6) })).status).toBe(303);220		const thread = await text("/app/issues/1");221		expect(thread).toContain("◈ closed");222		expect(thread).toContain(`fixed in <a class="history-id" href="/app/saves/${save}">`);223		expect(await text("/app/issues?status=closed")).toContain('href="/app/issues/1"');224		expect(await (await operator.get("/admin/issues?filter=closed")).text()).toContain(225			'href="/admin/issues/app/1"',226		);227		expect((await act({ action: "reopen" })).status).toBe(303);228		expect(await text("/app/issues/1")).toContain("◉ open");229		expect(await text("/app/issues/1")).not.toContain("fixed in");230	});231232	test("set type, and hide for spam: gone publicly, kept in the console", async () => {233		expect((await act({ action: "type", type: "nonsense" })).status).toBe(400);234		expect((await act({ action: "type", type: "idea" })).status).toBe(303);235		expect(await text("/app/issues")).toContain("#1 · idea");236		expect((await act({ action: "hide" })).status).toBe(303);237		expect((await inst.app.request("/app/issues/1")).status).toBe(404);238		expect(await (await operator.get("/admin/issues")).text()).not.toContain("/admin/issues/app/1");239		expect(await (await operator.get("/admin/issues?filter=hidden")).text()).toContain(240			'href="/admin/issues/app/1"',241		);242		expect((await act({ action: "unhide" })).status).toBe(303);243		expect((await inst.app.request("/app/issues/1")).status).toBe(200);244		expect((await act({ action: "delete" })).status).toBe(400);245		expect((await operator.post("/admin/issues/app/9", { action: "reopen" })).status).toBe(404);246	});247});248249function fileIssueDirect(inst: Instance, repo: string): void {250	fileIssue(inst.kepr, {251		repo,252		title: `A ${repo} issue`,253		body: "b",254		type: "bug",255		version: "",256		reporterName: "",257		contactEmail: "",258		ipHash: "x",259		filedAt: 1,260	});261}262263describe("releases: counts, displayed notes and hiding", () => {264	let inst: Instance;265	let operator: Browser;266	const text = async (path: string) => (await inst.app.request(path)).text();267	const edit = (version: string, fields: Record<string, string>) =>268		operator.post(`/admin/releases/app/${version}`, fields);269270	beforeAll(async () => {271		const repos = await makeRepos(["app"]);272		const save = repos.saves.app as string;273		for (const version of ["1.0.0", "1.1.0"]) {274			await writeRelease(repos.bucketDir, "app", {275				version,276				save,277				notes: `notes for ${version}`,278				files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],279			});280		}281		inst = await instance(repos.bucketDir);282		operator = new Browser(inst.app);283		await operator.signIn();284		await operator.post("/admin/repos/app", { public: "1", sort_order: "0" });285		await inst.app.request("/app/releases/1.1.0/linux-x86_64");286	});287288	test("every version is listed with its download counts and notes", async () => {289		const page = await (await operator.get("/admin/releases")).text();290		expect(page).toContain('id="app-1.1.0"');291		expect(page).toContain('id="app-1.0.0"');292		expect(page.indexOf('id="app-1.1.0"')).toBeLessThan(page.indexOf('id="app-1.0.0"'));293		expect(page).toContain('<span>1 download</span><span class="muted">linux-x86_64 1</span>');294		expect(page).toContain(">notes for 1.1.0</textarea>");295	});296297	test("edited notes replace the record's everywhere; the record's own text clears the edit", async () => {298		expect((await edit("1.1.0", { notes: "Better <notes>." })).status).toBe(303);299		expect(JSON.parse(await text("/api/app/releases/latest")).notes).toBe("Better <notes>.");300		expect(await text("/app/releases")).toContain("Better &lt;notes&gt;.");301		expect(await (await operator.get("/admin/releases")).text()).toContain("(edited)");302		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);303		const row = inst.kepr.query("SELECT notes FROM release_settings WHERE version = '1.1.0'").get();304		expect(row).toEqual({ notes: null });305		expect((await edit("1.1.0", { notes: "x".repeat(20_001) })).status).toBe(400);306	});307308	test("a hidden version leaves pages, latest and every feed, and stays in the console", async () => {309		expect((await edit("1.1.0", { notes: "notes for 1.1.0", hidden: "1" })).status).toBe(303);310		expect(await text("/app/releases/latest/version.txt")).toBe("1.0.0");311		expect(JSON.parse(await text("/api/app/releases/latest")).version).toBe("1.0.0");312		expect(await text("/api/app/releases/history")).not.toContain("1.1.0");313		expect(await text("/app/releases")).not.toContain("1.1.0");314		expect((await inst.app.request("/app/releases/1.1.0/linux-x86_64")).status).toBe(404);315		const page = await (await operator.get("/admin/releases")).text();316		expect(page).toContain('id="app-1.1.0"');317		expect(page).toContain('value="1" checked> hidden');318		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);319		expect(await text("/app/releases/latest/version.txt")).toBe("1.1.0");320		expect((await edit("9.9.9", { notes: "" })).status).toBe(404);321		expect((await operator.post("/admin/releases/nowhere/1.0.0", { notes: "" })).status).toBe(404);322	});323});324325describe("site and profile settings, and the signature upload", () => {326	let inst: Instance;327	let operator: Browser;328	const PNG = Uint8Array.from(329		atob(330			"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",331		),332		(ch) => ch.charCodeAt(0),333	);334	const settings = {335		site_name: "ada's library",336		tagline: "Small <tools>.",337		branding: "by ada",338		handle: "ada",339		bio: "Writes code.",340		location: "Lisbon",341		link: "https://ada.example",342		building_since: "june 2026",343		support: "1",344	};345	const text = async (path: string) => (await inst.app.request(path)).text();346	const upload = (bytes: Uint8Array | string, name: string, type: string, extra = {}) => {347		const form = new FormData();348		for (const [key, value] of Object.entries({ ...settings, ...extra })) form.set(key, value);349		form.set("signature", new File([bytes], name, { type }));350		return operator.post("/admin/site", form);351	};352	const signature = () =>353		inst.kepr.query("SELECT value FROM settings WHERE key = 'signature'").get();354355	beforeAll(async () => {356		const repos = await makeRepos(["app"]);357		inst = await instance(repos.bucketDir);358		operator = new Browser(inst.app);359		await operator.signIn();360	});361362	test("settings are saved and every public page reads them", async () => {363		expect((await operator.post("/admin/site", settings)).status).toBe(303);364		const front = await text("/");365		expect(front).toContain("<title>ada&#39;s library</title>");366		expect(front).toContain("Small &lt;tools&gt;.");367		expect(front).toContain('<span class="wordmark-by">by ada</span>');368		const profile = await text("/@ada");369		for (const value of [370			"Writes code.",371			"Lisbon",372			'href="https://ada.example"',373			"building since june 2026",374		]) {375			expect(profile).toContain(value);376		}377		expect(await (await operator.get("/admin/site?section=profile")).text()).toContain(378			'value="Lisbon"',379		);380		// Emptying a field removes the setting, so its default comes back.381		expect((await operator.post("/admin/site", { ...settings, site_name: "" })).status).toBe(303);382		expect(await text("/")).toContain("<title>kepr</title>");383	});384385	test("profile and site forms preserve each other's settings and retain invalid input", async () => {386		await operator.post("/admin/site", settings);387		const profile = await (await operator.get("/admin/site?section=profile")).text();388		expect(profile).toContain('aria-label="operator workspace"');389		expect(profile).toContain('href="/@ada"');390		expect(profile).not.toContain('name="site_name"');391		expect(392			(393				await operator.post("/admin/site", {394					section: "profile",395					handle: "ada",396					bio: "New bio",397					link: "https://ada.example",398				})399			).status,400		).toBe(303);401		expect(await text("/")).toContain("Small &lt;tools&gt;.");402		await operator.post("/admin/site", { section: "site", site_name: "New site", support: "1" });403		expect(await text("/@ada")).toContain("New bio");404		const bad = await operator.post("/admin/site", {405			section: "profile",406			handle: "a/b",407			bio: "Keep my draft",408		});409		expect(bad.status).toBe(400);410		expect(await bad.text()).toContain("Keep my draft");411		expect(await text("/@ada")).toContain("New bio");412	});413414	test("the support page is the operator's to write, and to switch off", async () => {415		// Until written, the shipped words show, and the console's box starts from them.416		const shipped = await text("/support");417		expect(await (await operator.get("/admin/site")).text()).toContain("show the support page");418		const own = { ...settings, support_page: "## Help\n\nMail **me**." };419		expect((await operator.post("/admin/site", own)).status).toBe(303);420		const support = await text("/support");421		expect(support).toContain('<h2 id="help">Help</h2>\n<p>Mail <strong>me</strong>.</p>');422		expect(support).not.toBe(shipped);423		const { support: _, ...off } = own;424		expect((await operator.post("/admin/site", off)).status).toBe(303);425		expect((await inst.app.request("/support")).status).toBe(404);426		expect(await text("/")).not.toContain('href="/support"');427		expect((await operator.post("/admin/site", own)).status).toBe(303);428		expect(await text("/")).toContain('<a href="/support">support</a>');429	});430431	test("a bad handle, link or overlong field is refused", async () => {432		for (const bad of [433			{ handle: "a/b" },434			{ link: "javascript:alert(1)" },435			{ bio: "x".repeat(2001) },436		]) {437			expect((await operator.post("/admin/site", { ...settings, ...bad })).status).toBe(400);438		}439		expect((await inst.app.request("/@ada")).status).toBe(200);440	});441442	test("a PNG signature is stored in data/assets and shown on the profile", async () => {443		expect((await upload(PNG, "sig.png", "image/png")).status).toBe(303);444		const name = (signature() as { value: string }).value;445		expect(name).toMatch(/^signature-[0-9a-f]{16}\.png$/);446		expect(await Bun.file(join(inst.dataDir, "assets", name)).exists()).toBe(true);447		expect(await text("/@ada")).toContain(448			`<img class="profile-signature" src="/static/assets/${name}"`,449		);450		const served = await inst.app.request(`/static/assets/${name}`);451		expect(served.headers.get("content-type")).toBe("image/png");452		expect(new Uint8Array(await served.arrayBuffer())).toEqual(PNG);453	});454455	test("SVG, or anything whose bytes are not PNG, JPEG or WebP, is refused whatever it claims", async () => {456		const before = signature();457		const svg = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>';458		expect((await upload(svg, "sig.svg", "image/svg+xml")).status).toBe(400);459		expect((await upload(svg, "sig.png", "image/png")).status).toBe(400);460		expect((await upload("GIF89a", "sig.gif", "image/gif")).status).toBe(400);461		expect(signature()).toEqual(before);462		expect((await upload(new Uint8Array(70 * 1024), "big.png", "image/png")).status).toBe(413);463		// Between the 60 KB limit and the 64 KiB body cap, the limit answers.464		const png = (size: number) => {465			const bytes = new Uint8Array(size);466			bytes.set(PNG);467			return bytes;468		};469		const over = await upload(png(60 * 1024 + 1), "big.png", "image/png");470		expect(over.status).toBe(400);471		expect(await over.text()).toContain("The signature must be under 60 KB.");472		expect(signature()).toEqual(before);473		expect((await upload(png(60 * 1024), "edge.png", "image/png")).status).toBe(303);474	});475476	test("a new signature replaces the old file; removing it brings the handle back", async () => {477		const old = (signature() as { value: string }).value;478		const webp = new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 1]);479		expect((await upload(webp, "sig.webp", "image/webp")).status).toBe(303);480		const name = (signature() as { value: string }).value;481		expect(name).toMatch(/\.webp$/);482		expect(await Bun.file(join(inst.dataDir, "assets", old)).exists()).toBe(false);483		expect(484			(await operator.post("/admin/site", { ...settings, remove_signature: "1" })).status,485		).toBe(303);486		expect(signature()).toBeNull();487		expect(await Bun.file(join(inst.dataDir, "assets", name)).exists()).toBe(false);488		expect(await text("/@ada")).toContain('<h1 class="profile-username">ada</h1>');489	});490});