console.test.ts

27.2 KB · raw

1import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";2import { join } from "node:path";3import { type Bucket, openBucket } from "../src/bucket/reader.ts";4import { fileIssue } from "../src/issues/store.ts";5import { openMirror } from "../src/mirror/store.ts";6import { deleteRelease, operatorReleases } from "../src/releases/releases.ts";7import { Browser, type Instance, instance } from "./browser.ts";8import { makeRepos, writeRelease } from "./fixture.ts";910let clock = Date.parse("2026-09-26T12:00:00Z");11const tick = (seconds: number) => {12	clock += seconds * 1000;13	setSystemTime(new Date(clock));14};1516describe("repos and visibility", () => {17	let inst: Instance;18	let browser: Browser;19	let bucketDir: string;20	let save: string;21	const publicPaths = () => [22		"/app",23		"/app/files/README.md",24		"/app/raw/README.md",25		`/app/saves/${save}`,26		`/app/archive/${save}.tar.gz`,27		"/app/archive/HEAD.tar.gz",28		"/app/archives",29		"/app/releases",30		"/app/releases/latest",31		"/app/releases/latest.json",32		"/app/releases/history.json",33		"/app/releases/latest/version.txt",34		"/app/releases/latest/linux-x86_64",35		"/app/releases/1.0.0/checksums.txt",36		"/app/releases/1.0.0/linux-x86_64",37		"/app/install.sh",38		"/api/app/releases/latest",39		"/api/app/releases/history",40		"/app/update-info",41		"/app/meta",42		"/app/issues",43		"/app/issues/new",44		"/app/issues/1",45		"/app?panel=issues",46		"/app/file?path=README.md",47		"/app/bundle",48		"/app/bundle.blake3",49		"/app/releases/1.0.0",50	];51	const statuses = async () => {52		const out: string[] = [];53		for (const path of publicPaths()) out.push(`${path} ${(await inst.app.request(path)).status}`);54		return out;55	};56	const text = async (path: string) => (await inst.app.request(path)).text();57	const publish = (repo: string, fields: Record<string, string>) =>58		browser.post(`/admin/repos/${repo}`, fields);5960	beforeAll(async () => {61		setSystemTime(new Date(clock));62		const repos = await makeRepos(["app", "other"]);63		bucketDir = repos.bucketDir;64		save = repos.saves.app as string;65		await writeRelease(bucketDir, "app", {66			version: "1.0.0",67			save,68			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],69		});70		inst = await instance(bucketDir);71		inst.kepr.run("INSERT INTO settings (key, value) VALUES ('handle', 'june')");72		fileIssueDirect(inst, "app");73		browser = new Browser(inst.app);74		await browser.signIn();75	});76	afterAll(() => setSystemTime());7778	test("every repo the sync found is listed, private, with its last sync", async () => {79		const page = await (await browser.get("/admin/repos?tab=settings")).text();80		expect(page).toContain('id="repo-app"');81		expect(page).toContain('id="repo-other"');82		expect(page.match(/action="\/admin\/repos\/[^/"]+"/g)?.length).toBe(1);83		expect(page).toContain("last sync 2026-09-26 12:00 UTC");84		expect((await statuses()).every((line) => line.endsWith(" 404"))).toBe(true);85	});8687	test("repo selection focuses one settings form without a search form", async () => {88		const page = await (await browser.get("/admin/repos?repo=other&tab=settings")).text();89		expect(page).toContain('action="/admin/repos/other"');90		expect(page).toContain('id="repo-app"');91		expect(page).not.toContain('type="search"');92		const saved = await publish("other", {});93		expect(saved.headers.get("location")).toBe("/admin/repos?repo=other&tab=settings&saved=1");94		expect(await (await browser.get("/admin/repos?repo=other&saved=1")).text()).toContain(95			"Changes saved.",96		);97	});9899	test("bad settings are refused and change nothing", async () => {100		for (const fields of <Record<string, string>[]>[101			{ homepage: "javascript:alert(1)" },102			{ category: "x".repeat(501) },103		]) {104			expect((await publish("app", { public: "1", ...fields })).status).toBe(400);105		}106		expect((await inst.app.request("/app")).status).toBe(404);107		expect((await browser.post("/admin/repos/nowhere", { public: "1" })).status).toBe(404);108	});109110	test("publishing opens every public surface, with the settings shown", async () => {111		await publish("other", { public: "1" });112		const response = await publish("app", {113			public: "1",114			category: "tools",115			homepage: "https://app.example",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("/")).toContain('href="/app"');122		expect(await text("/browse")).toContain('href="/app"');123		expect(await text("/@june")).toContain('href="/app"');124		expect(await text("/sitemap.xml")).toContain("/app</loc>");125	});126127	test("flipping a repo private 404s every surface and drops it from lists and counts", async () => {128		const stat = async () =>129			[...(await text("/@june")).matchAll(/profile-stat-number">([^<]*)</g)].map((m) => m[1]);130		// Both download routes above counted; the issue is the repo's one.131		expect(await stat()).toEqual(["2", "2", "1", "2"]);132		await publish("app", { category: "tools" });133		expect((await statuses()).filter((line) => !line.endsWith(" 404"))).toEqual([]);134		for (const path of ["/", "/browse", "/@june"]) expect(await text(path)).not.toContain("/app");135		expect(await text("/sitemap.xml")).not.toContain("/app<");136		expect(await text("/")).not.toContain("tools");137		expect(await stat()).toEqual(["1", "1", "0", "0"]);138		// Settings survive the flip.139		expect(await (await browser.get("/admin/repos?tab=settings")).text()).toContain(140			'value="tools"',141		);142	});143144	test("sync now reads the bucket at once and records the time", async () => {145		await writeRelease(bucketDir, "other", {146			version: "2.0.0",147			save: save,148			files: [{ platform: "linux-x86_64", name: "other", bytes: new Uint8Array([2]) }],149		});150		tick(60);151		expect((await inst.app.request("/other/releases/latest/version.txt")).status).toBe(404);152		const response = await browser.post("/admin/sync", {});153		expect(response.status).toBe(303);154		expect(await text("/other/releases/latest/version.txt")).toBe("2.0.0");155		expect(await (await browser.get("/admin/repos")).text()).toContain(156			"last sync 2026-09-26 12:01 UTC",157		);158	});159});160161describe("issues: filed, answered and closed end to end through real routes", () => {162	let inst: Instance;163	let operator: Browser;164	let save: string;165	const text = async (path: string) => (await inst.app.request(path)).text();166	const act = (fields: Record<string, string>) => operator.post("/admin/issues/app/1", fields);167168	beforeAll(async () => {169		setSystemTime(new Date(clock));170		const repos = await makeRepos(["app", "hush"]);171		save = repos.saves.app as string;172		inst = await instance(repos.bucketDir);173		operator = new Browser(inst.app);174		await operator.signIn();175		await operator.post("/admin/repos/app", { public: "1" });176		// A reporter files through the public form, as a browser would.177		const reporter = new Browser(inst.app, "198.51.100.40");178		const form = await (await reporter.request("/app/issues/new")).text();179		const stamp = /name="stamp" value="([^"]+)"/.exec(form)?.[1] ?? "";180		tick(4);181		const filed = await reporter.request("/app/issues/new", {182			method: "POST",183			body: new URLSearchParams({184				stamp,185				website: "",186				title: "Crash <on> start",187				body: "It crashed.",188				type: "bug",189				version: "",190				name: "Ada",191				email: "[email protected]",192			}),193		});194		expect(filed.status).toBe(200);195		fileIssueDirect(inst, "hush");196	});197	afterAll(() => setSystemTime());198199	test("the console lists issues across repos, private ones too, with the contact detail", async () => {200		const list = await (await operator.get("/admin/issues")).text();201		expect(list).toContain('href="/admin/issues/app/1"');202		expect(list).toContain('href="/admin/issues/hush/1"');203		expect(list).toContain("Crash &lt;on&gt; start");204		const page = await (await operator.get("/admin/issues/app/1")).text();205		expect(page).toContain("contact: [email protected]");206		expect(await text("/app/issues/1")).not.toContain("[email protected]");207	});208209	test("an operator reply shows on the public thread, marked", async () => {210		expect((await act({ action: "reply", body: "Fixed <soon>." })).status).toBe(303);211		expect((await act({ action: "reply", body: "  " })).status).toBe(400);212		const thread = await text("/app/issues/1");213		expect(thread).toContain('<span class="issue-badge">operator</span>');214		expect(thread).toContain("Fixed &lt;soon&gt;.");215	});216217	test("close with a fixed-in save prefix, then reopen", async () => {218		expect((await act({ action: "close", fixed_in: "zzzzzz" })).status).toBe(400);219		expect((await act({ action: "close", fixed_in: "0123456789ab" })).status).toBe(400);220		expect((await act({ action: "close", fixed_in: save.slice(0, 6) })).status).toBe(303);221		const thread = await text("/app/issues/1");222		expect(thread).toContain("◈ closed");223		expect(thread).toContain(`fixed in <a class="history-id" href="/app/saves/${save}">`);224		expect(await text("/app/issues?status=closed")).toContain('href="/app/issues/1"');225		expect(await (await operator.get("/admin/issues?filter=closed")).text()).toContain(226			'href="/admin/issues/app/1"',227		);228		expect((await act({ action: "reopen" })).status).toBe(303);229		expect(await text("/app/issues/1")).toContain("◉ open");230		expect(await text("/app/issues/1")).not.toContain("fixed in");231	});232233	test("set type, and hide for spam: gone publicly, kept in the console", async () => {234		expect((await act({ action: "type", type: "nonsense" })).status).toBe(400);235		expect((await act({ action: "type", type: "idea" })).status).toBe(303);236		expect(await text("/app/issues")).toContain("#1 · idea");237		expect((await act({ action: "hide" })).status).toBe(303);238		expect((await inst.app.request("/app/issues/1")).status).toBe(404);239		expect(await (await operator.get("/admin/issues")).text()).not.toContain("/admin/issues/app/1");240		expect(await (await operator.get("/admin/issues?filter=hidden")).text()).toContain(241			'href="/admin/issues/app/1"',242		);243		expect((await act({ action: "unhide" })).status).toBe(303);244		expect((await inst.app.request("/app/issues/1")).status).toBe(200);245		expect((await act({ action: "delete" })).status).toBe(400);246		expect((await operator.post("/admin/issues/app/9", { action: "reopen" })).status).toBe(404);247	});248});249250function fileIssueDirect(inst: Instance, repo: string): void {251	fileIssue(inst.kepr, {252		repo,253		title: `A ${repo} issue`,254		body: "b",255		type: "bug",256		version: "",257		reporterName: "",258		contactEmail: "",259		ipHash: "x",260		filedAt: 1,261	});262}263264describe("releases: counts, displayed notes and hiding", () => {265	let inst: Instance;266	let operator: Browser;267	const text = async (path: string) => (await inst.app.request(path)).text();268	const edit = (version: string, fields: Record<string, string>) =>269		operator.post(`/admin/releases/app/${version}`, fields);270271	beforeAll(async () => {272		const repos = await makeRepos(["app"]);273		const save = repos.saves.app as string;274		for (const version of ["1.0.0", "1.1.0"]) {275			await writeRelease(repos.bucketDir, "app", {276				version,277				save,278				notes: `notes for ${version}`,279				files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],280			});281		}282		inst = await instance(repos.bucketDir);283		operator = new Browser(inst.app);284		await operator.signIn();285		await operator.post("/admin/repos/app", { public: "1" });286		await inst.app.request("/app/releases/1.1.0/linux-x86_64");287	});288289	test("every version is listed with its download counts and notes", async () => {290		const page = await (await operator.get("/admin/repos?repo=app&tab=builds")).text();291		expect(page).toContain('id="app-1.1.0"');292		expect(page).toContain('id="app-1.0.0"');293		expect(page.indexOf('id="app-1.1.0"')).toBeLessThan(page.indexOf('id="app-1.0.0"'));294		expect(page).toContain('<span>1 download</span><span class="muted">linux-x86_64 1</span>');295		expect(page).toContain(">notes for 1.1.0</textarea>");296	});297298	test("edited notes replace the record's everywhere; the record's own text clears the edit", async () => {299		const saved = await edit("1.1.0", { notes: "Better <notes>." });300		expect(saved.headers.get("location")).toBe(301			"/admin/repos?repo=app&tab=builds&saved=1#app-1.1.0",302		);303		expect(JSON.parse(await text("/api/app/releases/latest")).notes).toBe("Better <notes>.");304		expect(await text("/app/releases")).toContain("Better &lt;notes&gt;.");305		expect(await (await operator.get("/admin/repos?repo=app&tab=builds")).text()).toContain(306			"(edited)",307		);308		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);309		const row = inst.kepr.query("SELECT notes FROM release_settings WHERE version = '1.1.0'").get();310		expect(row).toEqual({ notes: null });311		expect((await edit("1.1.0", { notes: "x".repeat(20_001) })).status).toBe(400);312	});313314	test("a hidden version leaves pages, latest and every feed, and stays in the console", async () => {315		expect((await edit("1.1.0", { notes: "notes for 1.1.0", hidden: "1" })).status).toBe(303);316		expect(await text("/app/releases/latest/version.txt")).toBe("1.0.0");317		expect(JSON.parse(await text("/api/app/releases/latest")).version).toBe("1.0.0");318		expect(await text("/api/app/releases/history")).not.toContain("1.1.0");319		expect(await text("/app/releases")).not.toContain("1.1.0");320		expect((await inst.app.request("/app/releases/1.1.0/linux-x86_64")).status).toBe(404);321		const page = await (await operator.get("/admin/repos?repo=app&tab=builds")).text();322		expect(page).toContain('id="app-1.1.0"');323		expect(page).toContain('value="1" checked> hidden');324		expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);325		expect(await text("/app/releases/latest/version.txt")).toBe("1.1.0");326		expect((await edit("9.9.9", { notes: "" })).status).toBe(404);327		expect((await operator.post("/admin/releases/nowhere/1.0.0", { notes: "" })).status).toBe(404);328	});329});330331describe("deleting a build from the bucket", () => {332	let inst: Instance;333	let operator: Browser;334	let bucketDir: string;335	const text = async (path: string) => (await inst.app.request(path)).text();336	const remove = (version: string, fields: Record<string, string> = { confirm: "1" }) =>337		operator.post(`/admin/releases/app/${version}/delete`, fields);338339	beforeAll(async () => {340		const repos = await makeRepos(["app"]);341		bucketDir = repos.bucketDir;342		for (const version of ["1.0.0", "1.1.0"]) {343			await writeRelease(bucketDir, "app", {344				version,345				save: repos.saves.app as string,346				files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],347			});348		}349		inst = await instance(bucketDir);350		operator = new Browser(inst.app);351		await operator.signIn();352		await operator.post("/admin/repos/app", { public: "1" });353		await inst.app.request("/app/releases/1.1.0/linux-x86_64");354	});355356	test("needs its confirmation, and an unknown version is a 404", async () => {357		expect((await remove("1.1.0", {})).status).toBe(400);358		expect(await Bun.file(join(bucketDir, "app/releases/1.1.0/release.json")).exists()).toBe(true);359		expect((await remove("9.9.9")).status).toBe(404);360	});361362	test("the newest build goes, latest falls back, and a sync does not bring it back", async () => {363		const response = await remove("1.1.0");364		expect(response.headers.get("location")).toBe("/admin/repos?repo=app&tab=builds&saved=1");365		expect(await Bun.file(join(bucketDir, "app/releases/1.1.0/release.json")).exists()).toBe(false);366		expect(await Bun.file(join(bucketDir, "app/releases/1.1.0/linux-x86_64/app")).exists()).toBe(367			false,368		);369		expect(await Bun.file(join(bucketDir, "app/releases/latest")).text()).toBe("1.0.0");370		expect(JSON.parse(await text("/api/app/releases/latest")).version).toBe("1.0.0");371		expect(inst.kepr.query("SELECT * FROM downloads WHERE version = '1.1.0'").all()).toEqual([]);372		await operator.post("/admin/sync", {});373		const page = await (await operator.get("/admin/repos?repo=app&tab=builds")).text();374		expect(page).not.toContain('id="app-1.1.0"');375		expect(page).toContain('id="app-1.0.0"');376	});377378	test("the last build goes, and latest with it", async () => {379		expect((await remove("1.0.0")).status).toBe(303);380		expect(await Bun.file(join(bucketDir, "app/releases/latest")).exists()).toBe(false);381		expect((await inst.app.request("/app/releases/latest/version.txt")).status).toBe(404);382	});383});384385describe("deleting one file of a build", () => {386	let inst: Instance;387	let operator: Browser;388	let bucketDir: string;389	const record = async () =>390		JSON.parse(await Bun.file(join(bucketDir, "app/releases/1.0.0/release.json")).text());391	const removeFile = (platform: string, fields: Record<string, string> = { confirm: "1" }) =>392		operator.post(`/admin/releases/app/1.0.0/${platform}/delete`, fields);393394	beforeAll(async () => {395		const repos = await makeRepos(["app"]);396		bucketDir = repos.bucketDir;397		await writeRelease(bucketDir, "app", {398			version: "1.0.0",399			save: repos.saves.app as string,400			files: [401				{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) },402				{ platform: "macos-aarch64", name: "app", bytes: new Uint8Array([2]) },403			],404		});405		inst = await instance(bucketDir);406		operator = new Browser(inst.app);407		await operator.signIn();408		await operator.post("/admin/repos/app", { public: "1" });409		await inst.app.request("/app/releases/1.0.0/macos-aarch64");410	});411412	test("needs its confirmation, and an unknown platform is a 404", async () => {413		expect((await removeFile("macos-aarch64", {})).status).toBe(400);414		expect((await removeFile("windows-x86_64")).status).toBe(404);415		expect((await record()).files).toHaveLength(2);416	});417418	test("the file leaves the record, the bucket, the counts and the public routes", async () => {419		const response = await removeFile("macos-aarch64");420		expect(response.headers.get("location")).toBe(421			"/admin/repos?repo=app&tab=builds&saved=1#app-1.0.0",422		);423		const files = (await record()).files.map((file: { platform: string }) => file.platform);424		expect(files).toEqual(["linux-x86_64"]);425		const gone = join(bucketDir, "app/releases/1.0.0/macos-aarch64/app");426		expect(await Bun.file(gone).exists()).toBe(false);427		expect(428			inst.kepr.query("SELECT * FROM downloads WHERE platform = 'macos-aarch64'").all(),429		).toEqual([]);430		expect((await inst.app.request("/app/releases/1.0.0/macos-aarch64")).status).toBe(404);431		expect((await inst.app.request("/app/releases/1.0.0/linux-x86_64")).status).toBe(302);432	});433434	test("the last file can't go alone", async () => {435		expect((await removeFile("linux-x86_64")).status).toBe(400);436		expect((await record()).files).toHaveLength(1);437	});438});439440describe("deleting builds when a step fails or two run at once", () => {441	let inst: Instance;442	let bucketDir: string;443	const folder = () => openBucket({ kind: "folder", path: bucketDir });444	const mirror = () => openMirror(join(inst.dataDir, "mirror"), "app");445	const versions = () => operatorReleases(inst.kepr, mirror(), "app").map((r) => r.version);446	const latest = () => Bun.file(join(bucketDir, "app/releases/latest")).text();447448	beforeAll(async () => {449		const repos = await makeRepos(["app"]);450		bucketDir = repos.bucketDir;451		for (const version of ["1.0.0", "2.0.0", "3.0.0", "4.0.0"]) {452			await writeRelease(bucketDir, "app", {453				version,454				save: repos.saves.app as string,455				files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],456			});457		}458		inst = await instance(bucketDir);459	});460461	test("a failed file delete leaves the build whole, and deleting it again finishes", async () => {462		const failing: Bucket = {463			...folder(),464			async remove(key) {465				if (key.endsWith("/app")) throw new Error("network down");466				return folder().remove(key);467			},468		};469		await expect(deleteRelease(failing, inst.kepr, mirror(), "app", "4.0.0")).rejects.toThrow(470			"network down",471		);472		expect(await Bun.file(join(bucketDir, "app/releases/4.0.0/release.json")).exists()).toBe(true);473		expect(versions()).toContain("4.0.0");474		expect(await latest()).toBe("3.0.0");475		await deleteRelease(folder(), inst.kepr, mirror(), "app", "4.0.0");476		expect(await Bun.file(join(bucketDir, "app/releases/4.0.0/release.json")).exists()).toBe(false);477		expect(versions()).not.toContain("4.0.0");478	});479480	test("two deletes at once leave latest naming a build that still exists", async () => {481		await Promise.all([482			deleteRelease(folder(), inst.kepr, mirror(), "app", "3.0.0"),483			deleteRelease(folder(), inst.kepr, mirror(), "app", "2.0.0"),484		]);485		expect(await latest()).toBe("1.0.0");486		expect(versions()).toEqual(["1.0.0"]);487	});488});489490describe("site and profile settings, and the signature upload", () => {491	let inst: Instance;492	let operator: Browser;493	const PNG = Uint8Array.from(494		atob(495			"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",496		),497		(ch) => ch.charCodeAt(0),498	);499	const settings = {500		site_name: "ada's library",501		tagline: "Small <tools>.",502		branding: "by ada",503		handle: "ada",504		bio: "Writes code.",505		location: "Lisbon",506		link: "https://ada.example",507		building_since: "june 2026",508		support: "1",509	};510	const text = async (path: string) => (await inst.app.request(path)).text();511	const upload = (bytes: Uint8Array | string, name: string, type: string, extra = {}) => {512		const form = new FormData();513		for (const [key, value] of Object.entries({ ...settings, ...extra })) form.set(key, value);514		form.set("signature", new File([bytes], name, { type }));515		return operator.post("/admin/site", form);516	};517	const signature = () =>518		inst.kepr.query("SELECT value FROM settings WHERE key = 'signature'").get();519520	beforeAll(async () => {521		const repos = await makeRepos(["app"]);522		inst = await instance(repos.bucketDir);523		operator = new Browser(inst.app);524		await operator.signIn();525	});526527	test("settings are saved and every public page reads them", async () => {528		expect((await operator.post("/admin/site", settings)).status).toBe(303);529		const front = await text("/");530		expect(front).toContain("<title>ada&#39;s library</title>");531		expect(front).toContain("Small &lt;tools&gt;.");532		expect(front).toContain('<span class="wordmark-by">by ada</span>');533		const profile = await text("/@ada");534		for (const value of [535			"Writes code.",536			"Lisbon",537			'href="https://ada.example"',538			"building since june 2026",539		]) {540			expect(profile).toContain(value);541		}542		expect(await (await operator.get("/admin/site?section=profile")).text()).toContain(543			'value="Lisbon"',544		);545		// Emptying a field removes the setting, so its default comes back.546		expect((await operator.post("/admin/site", { ...settings, site_name: "" })).status).toBe(303);547		expect(await text("/")).toContain("<title>kepr</title>");548	});549550	test("profile and site forms preserve each other's settings and retain invalid input", async () => {551		await operator.post("/admin/site", settings);552		const profile = await (await operator.get("/admin/site?section=profile")).text();553		expect(profile).toContain('aria-label="operator workspace"');554		expect(profile).toContain('href="/@ada"');555		expect(profile).not.toContain('name="site_name"');556		expect(profile).not.toContain('name="signature"');557		expect(profile).not.toContain('name="remove_signature"');558		expect(559			(560				await operator.post("/admin/site", {561					section: "profile",562					handle: "ada",563					bio: "New bio",564					link: "https://ada.example",565				})566			).status,567		).toBe(303);568		expect(await text("/")).toContain("Small &lt;tools&gt;.");569		await operator.post("/admin/site", { section: "site", site_name: "New site", support: "1" });570		expect(await text("/@ada")).toContain("New bio");571		const bad = await operator.post("/admin/site", {572			section: "profile",573			handle: "a/b",574			bio: "Keep my draft",575		});576		expect(bad.status).toBe(400);577		expect(await bad.text()).toContain("Keep my draft");578		expect(await text("/@ada")).toContain("New bio");579	});580581	test("the support page is the operator's to write, and to switch off", async () => {582		// Until written, the shipped words show, and the console's box starts from them.583		const shipped = await text("/support");584		expect(await (await operator.get("/admin/site")).text()).toContain("show the support page");585		const own = { ...settings, support_page: "## Help\n\nMail **me**." };586		expect((await operator.post("/admin/site", own)).status).toBe(303);587		const support = await text("/support");588		expect(support).toContain('<h2 id="help">Help</h2>\n<p>Mail <strong>me</strong>.</p>');589		expect(support).not.toBe(shipped);590		const { support: _, ...off } = own;591		expect((await operator.post("/admin/site", off)).status).toBe(303);592		expect((await inst.app.request("/support")).status).toBe(404);593		expect(await text("/")).not.toContain('href="/support"');594		expect((await operator.post("/admin/site", own)).status).toBe(303);595		expect(await text("/")).toContain('<a href="/support">support</a>');596	});597598	test("a bad handle, link or overlong field is refused", async () => {599		for (const bad of [600			{ handle: "a/b" },601			{ link: "javascript:alert(1)" },602			{ bio: "x".repeat(2001) },603		]) {604			expect((await operator.post("/admin/site", { ...settings, ...bad })).status).toBe(400);605		}606		expect((await inst.app.request("/@ada")).status).toBe(200);607	});608609	test("a PNG signature is stored in data/assets and shown on the profile", async () => {610		expect((await upload(PNG, "sig.png", "image/png")).status).toBe(303);611		const name = (signature() as { value: string }).value;612		expect(name).toMatch(/^signature-[0-9a-f]{16}\.png$/);613		expect(await Bun.file(join(inst.dataDir, "assets", name)).exists()).toBe(true);614		expect(await text("/@ada")).toContain(615			`<img class="profile-signature" src="/static/assets/${name}"`,616		);617		const served = await inst.app.request(`/static/assets/${name}`);618		expect(served.headers.get("content-type")).toBe("image/png");619		expect(new Uint8Array(await served.arrayBuffer())).toEqual(PNG);620	});621622	test("SVG, or anything whose bytes are not PNG, JPEG or WebP, is refused whatever it claims", async () => {623		const before = signature();624		const svg = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>';625		expect((await upload(svg, "sig.svg", "image/svg+xml")).status).toBe(400);626		expect((await upload(svg, "sig.png", "image/png")).status).toBe(400);627		expect((await upload("GIF89a", "sig.gif", "image/gif")).status).toBe(400);628		expect(signature()).toEqual(before);629		expect((await upload(new Uint8Array(70 * 1024), "big.png", "image/png")).status).toBe(413);630		// Between the 60 KB limit and the 64 KiB body cap, the limit answers.631		const png = (size: number) => {632			const bytes = new Uint8Array(size);633			bytes.set(PNG);634			return bytes;635		};636		const over = await upload(png(60 * 1024 + 1), "big.png", "image/png");637		expect(over.status).toBe(400);638		expect(await over.text()).toContain("The signature must be under 60 KB.");639		expect(signature()).toEqual(before);640		expect((await upload(png(60 * 1024), "edge.png", "image/png")).status).toBe(303);641	});642643	test("a new signature replaces the old file; removing it brings the handle back", async () => {644		const old = (signature() as { value: string }).value;645		const webp = new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 1]);646		expect((await upload(webp, "sig.webp", "image/webp")).status).toBe(303);647		const name = (signature() as { value: string }).value;648		expect(name).toMatch(/\.webp$/);649		expect(await Bun.file(join(inst.dataDir, "assets", old)).exists()).toBe(false);650		expect(651			(await operator.post("/admin/site", { ...settings, remove_signature: "1" })).status,652		).toBe(303);653		expect(signature()).toBeNull();654		expect(await Bun.file(join(inst.dataDir, "assets", name)).exists()).toBe(false);655		expect(await text("/@ada")).toContain('<h1 class="profile-username">ada</h1>');656	});657});