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 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 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 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 <on> 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 <soon>.");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 <notes>.");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's library</title>");531 expect(front).toContain("Small <tools>.");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 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 <tools>.");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 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 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});