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}`, 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 { category: "x".repeat(501) },100 ]) {101 expect((await publish("app", { public: "1", ...fields })).status).toBe(400);102 }103 expect((await inst.app.request("/app")).status).toBe(404);104 expect((await browser.post("/admin/repos/nowhere", { public: "1" })).status).toBe(404);105 });106107 test("publishing opens every public surface, with the settings shown", async () => {108 await publish("other", { public: "1" });109 const response = await publish("app", {110 public: "1",111 category: "tools",112 homepage: "https://app.example",113 });114 expect(response.status).toBe(303);115 const lines = await statuses();116 expect(lines.filter((line) => line.endsWith(" 404"))).toEqual([]);117 expect(await text("/app")).toContain("https://app.example");118 expect(await text("/")).toContain('href="/app"');119 expect(await text("/browse")).toContain('href="/app"');120 expect(await text("/@june")).toContain('href="/app"');121 expect(await text("/sitemap.xml")).toContain("/app</loc>");122 });123124 test("flipping a repo private 404s every surface and drops it from lists and counts", async () => {125 const stat = async () =>126 [...(await text("/@june")).matchAll(/profile-stat-number">([^<]*)</g)].map((m) => m[1]);127 128 expect(await stat()).toEqual(["2", "2", "1", "2"]);129 await publish("app", { category: "tools" });130 expect((await statuses()).filter((line) => !line.endsWith(" 404"))).toEqual([]);131 for (const path of ["/", "/browse", "/@june"]) expect(await text(path)).not.toContain("/app");132 expect(await text("/sitemap.xml")).not.toContain("/app<");133 expect(await text("/")).not.toContain("tools");134 expect(await stat()).toEqual(["1", "1", "0", "0"]);135 136 expect(await (await browser.get("/admin/repos")).text()).toContain('value="tools"');137 });138139 test("sync now reads the bucket at once and records the time", async () => {140 await writeRelease(bucketDir, "other", {141 version: "2.0.0",142 save: save,143 files: [{ platform: "linux-x86_64", name: "other", bytes: new Uint8Array([2]) }],144 });145 tick(60);146 expect((await inst.app.request("/other/releases/latest/version.txt")).status).toBe(404);147 const response = await browser.post("/admin/sync", {});148 expect(response.status).toBe(303);149 expect(await text("/other/releases/latest/version.txt")).toBe("2.0.0");150 expect(await (await browser.get("/admin/repos")).text()).toContain(151 "last sync 2026-09-26 12:01 UTC",152 );153 });154});155156describe("issues: filed, answered and closed end to end through real routes", () => {157 let inst: Instance;158 let operator: Browser;159 let save: string;160 const text = async (path: string) => (await inst.app.request(path)).text();161 const act = (fields: Record<string, string>) => operator.post("/admin/issues/app/1", fields);162163 beforeAll(async () => {164 setSystemTime(new Date(clock));165 const repos = await makeRepos(["app", "hush"]);166 save = repos.saves.app as string;167 inst = await instance(repos.bucketDir);168 operator = new Browser(inst.app);169 await operator.signIn();170 await operator.post("/admin/repos/app", { public: "1" });171 172 const reporter = new Browser(inst.app, "198.51.100.40");173 const form = await (await reporter.request("/app/issues/new")).text();174 const stamp = /name="stamp" value="([^"]+)"/.exec(form)?.[1] ?? "";175 tick(4);176 const filed = await reporter.request("/app/issues/new", {177 method: "POST",178 body: new URLSearchParams({179 stamp,180 website: "",181 title: "Crash <on> start",182 body: "It crashed.",183 type: "bug",184 version: "",185 name: "Ada",186 email: "[email protected]",187 }),188 });189 expect(filed.status).toBe(200);190 fileIssueDirect(inst, "hush");191 });192 afterAll(() => setSystemTime());193194 test("the console lists issues across repos, private ones too, with the contact detail", async () => {195 const list = await (await operator.get("/admin/issues")).text();196 expect(list).toContain('href="/admin/issues/app/1"');197 expect(list).toContain('href="/admin/issues/hush/1"');198 expect(list).toContain("Crash <on> start");199 const page = await (await operator.get("/admin/issues/app/1")).text();200 expect(page).toContain("contact: [email protected]");201 expect(await text("/app/issues/1")).not.toContain("[email protected]");202 });203204 test("an operator reply shows on the public thread, marked", async () => {205 expect((await act({ action: "reply", body: "Fixed <soon>." })).status).toBe(303);206 expect((await act({ action: "reply", body: " " })).status).toBe(400);207 const thread = await text("/app/issues/1");208 expect(thread).toContain('<span class="issue-badge">operator</span>');209 expect(thread).toContain("Fixed <soon>.");210 });211212 test("close with a fixed-in save prefix, then reopen", async () => {213 expect((await act({ action: "close", fixed_in: "zzzzzz" })).status).toBe(400);214 expect((await act({ action: "close", fixed_in: "0123456789ab" })).status).toBe(400);215 expect((await act({ action: "close", fixed_in: save.slice(0, 6) })).status).toBe(303);216 const thread = await text("/app/issues/1");217 expect(thread).toContain("◈ closed");218 expect(thread).toContain(`fixed in <a class="history-id" href="/app/saves/${save}">`);219 expect(await text("/app/issues?status=closed")).toContain('href="/app/issues/1"');220 expect(await (await operator.get("/admin/issues?filter=closed")).text()).toContain(221 'href="/admin/issues/app/1"',222 );223 expect((await act({ action: "reopen" })).status).toBe(303);224 expect(await text("/app/issues/1")).toContain("◉ open");225 expect(await text("/app/issues/1")).not.toContain("fixed in");226 });227228 test("set type, and hide for spam: gone publicly, kept in the console", async () => {229 expect((await act({ action: "type", type: "nonsense" })).status).toBe(400);230 expect((await act({ action: "type", type: "idea" })).status).toBe(303);231 expect(await text("/app/issues")).toContain("#1 · idea");232 expect((await act({ action: "hide" })).status).toBe(303);233 expect((await inst.app.request("/app/issues/1")).status).toBe(404);234 expect(await (await operator.get("/admin/issues")).text()).not.toContain("/admin/issues/app/1");235 expect(await (await operator.get("/admin/issues?filter=hidden")).text()).toContain(236 'href="/admin/issues/app/1"',237 );238 expect((await act({ action: "unhide" })).status).toBe(303);239 expect((await inst.app.request("/app/issues/1")).status).toBe(200);240 expect((await act({ action: "delete" })).status).toBe(400);241 expect((await operator.post("/admin/issues/app/9", { action: "reopen" })).status).toBe(404);242 });243});244245function fileIssueDirect(inst: Instance, repo: string): void {246 fileIssue(inst.kepr, {247 repo,248 title: `A ${repo} issue`,249 body: "b",250 type: "bug",251 version: "",252 reporterName: "",253 contactEmail: "",254 ipHash: "x",255 filedAt: 1,256 });257}258259describe("releases: counts, displayed notes and hiding", () => {260 let inst: Instance;261 let operator: Browser;262 const text = async (path: string) => (await inst.app.request(path)).text();263 const edit = (version: string, fields: Record<string, string>) =>264 operator.post(`/admin/releases/app/${version}`, fields);265266 beforeAll(async () => {267 const repos = await makeRepos(["app"]);268 const save = repos.saves.app as string;269 for (const version of ["1.0.0", "1.1.0"]) {270 await writeRelease(repos.bucketDir, "app", {271 version,272 save,273 notes: `notes for ${version}`,274 files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],275 });276 }277 inst = await instance(repos.bucketDir);278 operator = new Browser(inst.app);279 await operator.signIn();280 await operator.post("/admin/repos/app", { public: "1" });281 await inst.app.request("/app/releases/1.1.0/linux-x86_64");282 });283284 test("every version is listed with its download counts and notes", async () => {285 const page = await (await operator.get("/admin/repos?repo=app")).text();286 expect(page).toContain('id="app-1.1.0"');287 expect(page).toContain('id="app-1.0.0"');288 expect(page.indexOf('id="app-1.1.0"')).toBeLessThan(page.indexOf('id="app-1.0.0"'));289 expect(page).toContain('<span>1 download</span><span class="muted">linux-x86_64 1</span>');290 expect(page).toContain(">notes for 1.1.0</textarea>");291 });292293 test("edited notes replace the record's everywhere; the record's own text clears the edit", async () => {294 const saved = await edit("1.1.0", { notes: "Better <notes>." });295 expect(saved.headers.get("location")).toBe("/admin/repos?repo=app&saved=1#app-1.1.0");296 expect(JSON.parse(await text("/api/app/releases/latest")).notes).toBe("Better <notes>.");297 expect(await text("/app/releases")).toContain("Better <notes>.");298 expect(await (await operator.get("/admin/repos?repo=app")).text()).toContain("(edited)");299 expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);300 const row = inst.kepr.query("SELECT notes FROM release_settings WHERE version = '1.1.0'").get();301 expect(row).toEqual({ notes: null });302 expect((await edit("1.1.0", { notes: "x".repeat(20_001) })).status).toBe(400);303 });304305 test("a hidden version leaves pages, latest and every feed, and stays in the console", async () => {306 expect((await edit("1.1.0", { notes: "notes for 1.1.0", hidden: "1" })).status).toBe(303);307 expect(await text("/app/releases/latest/version.txt")).toBe("1.0.0");308 expect(JSON.parse(await text("/api/app/releases/latest")).version).toBe("1.0.0");309 expect(await text("/api/app/releases/history")).not.toContain("1.1.0");310 expect(await text("/app/releases")).not.toContain("1.1.0");311 expect((await inst.app.request("/app/releases/1.1.0/linux-x86_64")).status).toBe(404);312 const page = await (await operator.get("/admin/repos?repo=app")).text();313 expect(page).toContain('id="app-1.1.0"');314 expect(page).toContain('value="1" checked> hidden');315 expect((await edit("1.1.0", { notes: "notes for 1.1.0" })).status).toBe(303);316 expect(await text("/app/releases/latest/version.txt")).toBe("1.1.0");317 expect((await edit("9.9.9", { notes: "" })).status).toBe(404);318 expect((await operator.post("/admin/releases/nowhere/1.0.0", { notes: "" })).status).toBe(404);319 });320});321322describe("deleting a build from the bucket", () => {323 let inst: Instance;324 let operator: Browser;325 let bucketDir: string;326 const text = async (path: string) => (await inst.app.request(path)).text();327 const remove = (version: string, fields: Record<string, string> = { confirm: "1" }) =>328 operator.post(`/admin/releases/app/${version}/delete`, fields);329330 beforeAll(async () => {331 const repos = await makeRepos(["app"]);332 bucketDir = repos.bucketDir;333 for (const version of ["1.0.0", "1.1.0"]) {334 await writeRelease(bucketDir, "app", {335 version,336 save: repos.saves.app as string,337 files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],338 });339 }340 inst = await instance(bucketDir);341 operator = new Browser(inst.app);342 await operator.signIn();343 await operator.post("/admin/repos/app", { public: "1" });344 await inst.app.request("/app/releases/1.1.0/linux-x86_64");345 });346347 test("needs its confirmation, and an unknown version is a 404", async () => {348 expect((await remove("1.1.0", {})).status).toBe(400);349 expect(await Bun.file(join(bucketDir, "app/releases/1.1.0/release.json")).exists()).toBe(true);350 expect((await remove("9.9.9")).status).toBe(404);351 });352353 test("the newest build goes, latest falls back, and a sync does not bring it back", async () => {354 const response = await remove("1.1.0");355 expect(response.headers.get("location")).toBe("/admin/repos?repo=app&saved=1#builds");356 expect(await Bun.file(join(bucketDir, "app/releases/1.1.0/release.json")).exists()).toBe(false);357 expect(await Bun.file(join(bucketDir, "app/releases/1.1.0/linux-x86_64/app")).exists()).toBe(358 false,359 );360 expect(await Bun.file(join(bucketDir, "app/releases/latest")).text()).toBe("1.0.0");361 expect(JSON.parse(await text("/api/app/releases/latest")).version).toBe("1.0.0");362 expect(inst.kepr.query("SELECT * FROM downloads WHERE version = '1.1.0'").all()).toEqual([]);363 await operator.post("/admin/sync", {});364 const page = await (await operator.get("/admin/repos?repo=app")).text();365 expect(page).not.toContain('id="app-1.1.0"');366 expect(page).toContain('id="app-1.0.0"');367 });368369 test("the last build goes, and latest with it", async () => {370 expect((await remove("1.0.0")).status).toBe(303);371 expect(await Bun.file(join(bucketDir, "app/releases/latest")).exists()).toBe(false);372 expect((await inst.app.request("/app/releases/latest/version.txt")).status).toBe(404);373 });374});375376describe("site and profile settings, and the signature upload", () => {377 let inst: Instance;378 let operator: Browser;379 const PNG = Uint8Array.from(380 atob(381 "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",382 ),383 (ch) => ch.charCodeAt(0),384 );385 const settings = {386 site_name: "ada's library",387 tagline: "Small <tools>.",388 branding: "by ada",389 handle: "ada",390 bio: "Writes code.",391 location: "Lisbon",392 link: "https://ada.example",393 building_since: "june 2026",394 support: "1",395 };396 const text = async (path: string) => (await inst.app.request(path)).text();397 const upload = (bytes: Uint8Array | string, name: string, type: string, extra = {}) => {398 const form = new FormData();399 for (const [key, value] of Object.entries({ ...settings, ...extra })) form.set(key, value);400 form.set("signature", new File([bytes], name, { type }));401 return operator.post("/admin/site", form);402 };403 const signature = () =>404 inst.kepr.query("SELECT value FROM settings WHERE key = 'signature'").get();405406 beforeAll(async () => {407 const repos = await makeRepos(["app"]);408 inst = await instance(repos.bucketDir);409 operator = new Browser(inst.app);410 await operator.signIn();411 });412413 test("settings are saved and every public page reads them", async () => {414 expect((await operator.post("/admin/site", settings)).status).toBe(303);415 const front = await text("/");416 expect(front).toContain("<title>ada's library</title>");417 expect(front).toContain("Small <tools>.");418 expect(front).toContain('<span class="wordmark-by">by ada</span>');419 const profile = await text("/@ada");420 for (const value of [421 "Writes code.",422 "Lisbon",423 'href="https://ada.example"',424 "building since june 2026",425 ]) {426 expect(profile).toContain(value);427 }428 expect(await (await operator.get("/admin/site?section=profile")).text()).toContain(429 'value="Lisbon"',430 );431 432 expect((await operator.post("/admin/site", { ...settings, site_name: "" })).status).toBe(303);433 expect(await text("/")).toContain("<title>kepr</title>");434 });435436 test("profile and site forms preserve each other's settings and retain invalid input", async () => {437 await operator.post("/admin/site", settings);438 const profile = await (await operator.get("/admin/site?section=profile")).text();439 expect(profile).toContain('aria-label="operator workspace"');440 expect(profile).toContain('href="/@ada"');441 expect(profile).not.toContain('name="site_name"');442 expect(profile).not.toContain('name="signature"');443 expect(profile).not.toContain('name="remove_signature"');444 expect(445 (446 await operator.post("/admin/site", {447 section: "profile",448 handle: "ada",449 bio: "New bio",450 link: "https://ada.example",451 })452 ).status,453 ).toBe(303);454 expect(await text("/")).toContain("Small <tools>.");455 await operator.post("/admin/site", { section: "site", site_name: "New site", support: "1" });456 expect(await text("/@ada")).toContain("New bio");457 const bad = await operator.post("/admin/site", {458 section: "profile",459 handle: "a/b",460 bio: "Keep my draft",461 });462 expect(bad.status).toBe(400);463 expect(await bad.text()).toContain("Keep my draft");464 expect(await text("/@ada")).toContain("New bio");465 });466467 test("the support page is the operator's to write, and to switch off", async () => {468 469 const shipped = await text("/support");470 expect(await (await operator.get("/admin/site")).text()).toContain("show the support page");471 const own = { ...settings, support_page: "## Help\n\nMail **me**." };472 expect((await operator.post("/admin/site", own)).status).toBe(303);473 const support = await text("/support");474 expect(support).toContain('<h2 id="help">Help</h2>\n<p>Mail <strong>me</strong>.</p>');475 expect(support).not.toBe(shipped);476 const { support: _, ...off } = own;477 expect((await operator.post("/admin/site", off)).status).toBe(303);478 expect((await inst.app.request("/support")).status).toBe(404);479 expect(await text("/")).not.toContain('href="/support"');480 expect((await operator.post("/admin/site", own)).status).toBe(303);481 expect(await text("/")).toContain('<a href="/support">support</a>');482 });483484 test("a bad handle, link or overlong field is refused", async () => {485 for (const bad of [486 { handle: "a/b" },487 { link: "javascript:alert(1)" },488 { bio: "x".repeat(2001) },489 ]) {490 expect((await operator.post("/admin/site", { ...settings, ...bad })).status).toBe(400);491 }492 expect((await inst.app.request("/@ada")).status).toBe(200);493 });494495 test("a PNG signature is stored in data/assets and shown on the profile", async () => {496 expect((await upload(PNG, "sig.png", "image/png")).status).toBe(303);497 const name = (signature() as { value: string }).value;498 expect(name).toMatch(/^signature-[0-9a-f]{16}\.png$/);499 expect(await Bun.file(join(inst.dataDir, "assets", name)).exists()).toBe(true);500 expect(await text("/@ada")).toContain(501 `<img class="profile-signature" src="/static/assets/${name}"`,502 );503 const served = await inst.app.request(`/static/assets/${name}`);504 expect(served.headers.get("content-type")).toBe("image/png");505 expect(new Uint8Array(await served.arrayBuffer())).toEqual(PNG);506 });507508 test("SVG, or anything whose bytes are not PNG, JPEG or WebP, is refused whatever it claims", async () => {509 const before = signature();510 const svg = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>';511 expect((await upload(svg, "sig.svg", "image/svg+xml")).status).toBe(400);512 expect((await upload(svg, "sig.png", "image/png")).status).toBe(400);513 expect((await upload("GIF89a", "sig.gif", "image/gif")).status).toBe(400);514 expect(signature()).toEqual(before);515 expect((await upload(new Uint8Array(70 * 1024), "big.png", "image/png")).status).toBe(413);516 517 const png = (size: number) => {518 const bytes = new Uint8Array(size);519 bytes.set(PNG);520 return bytes;521 };522 const over = await upload(png(60 * 1024 + 1), "big.png", "image/png");523 expect(over.status).toBe(400);524 expect(await over.text()).toContain("The signature must be under 60 KB.");525 expect(signature()).toEqual(before);526 expect((await upload(png(60 * 1024), "edge.png", "image/png")).status).toBe(303);527 });528529 test("a new signature replaces the old file; removing it brings the handle back", async () => {530 const old = (signature() as { value: string }).value;531 const webp = new Uint8Array([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50, 1]);532 expect((await upload(webp, "sig.webp", "image/webp")).status).toBe(303);533 const name = (signature() as { value: string }).value;534 expect(name).toMatch(/\.webp$/);535 expect(await Bun.file(join(inst.dataDir, "assets", old)).exists()).toBe(false);536 expect(537 (await operator.post("/admin/site", { ...settings, remove_signature: "1" })).status,538 ).toBe(303);539 expect(signature()).toBeNull();540 expect(await Bun.file(join(inst.dataDir, "assets", name)).exists()).toBe(false);541 expect(await text("/@ada")).toContain('<h1 class="profile-username">ada</h1>');542 });543});