1import type { Database } from "bun:sqlite";2import { beforeAll, describe, expect, test } from "bun:test";3import { mkdtemp, writeFile } from "node:fs/promises";4import { tmpdir } from "node:os";5import { join } from "node:path";6import { blake3 } from "@noble/hashes/blake3.js";7import { bytesToHex } from "@noble/hashes/utils.js";8import type { Hono } from "hono";9import { openBucket } from "../src/bucket/reader.ts";10import { type SyncTarget, syncAll } from "../src/bucket/sync.ts";11import { openMirror } from "../src/mirror/store.ts";12import { compareVersions, releaseContentType, visibleReleases } from "../src/releases/releases.ts";13import { createApp } from "../src/server/app.ts";14import { openKeprDb } from "../src/server/db.ts";15import { makeRepos, writeRelease } from "./fixture.ts";1617const bytes = (text: string) => new TextEncoder().encode(text);18const blake3Hex = (text: string) => bytesToHex(blake3(bytes(text)));1920describe("release reading in sync", () => {21 let bucketDir: string;22 let save: string;23 let target: SyncTarget;24 const releases = () =>25 visibleReleases(target.kepr, openMirror(target.mirrorDir, "app"), "app").map((r) => r.version);26 const lastError = () =>27 (target.kepr.query("SELECT last_sync_error FROM repos").get() as { last_sync_error: string })28 .last_sync_error;2930 beforeAll(async () => {31 const repos = await makeRepos(["app"]);32 bucketDir = repos.bucketDir;33 save = repos.saves.app as string;34 const dataDir = await mkdtemp(join(tmpdir(), "kepr-releases-"));35 target = {36 bucket: openBucket({ kind: "folder", path: bucketDir }),37 kepr: openKeprDb(dataDir),38 mirrorDir: join(dataDir, "mirror"),39 tarballDir: join(dataDir, "tarballs"),40 };41 for (const version of ["1.9.0", "1.10.0", "1.2.3"]) {42 const files = [{ platform: "linux-x86_64", name: "app", bytes: bytes(version) }];43 await writeRelease(bucketDir, "app", { version, save, files }, version === "1.10.0");44 }45 await syncAll(target);46 });4748 test("reads every release.json, newest first by number", () => {49 expect(lastError()).toBeNull();50 expect(releases()).toEqual(["1.10.0", "1.9.0", "1.2.3"]);51 });5253 test("a version folder without release.json does not exist, and nothing is re-read until latest moves", async () => {54 await writeFile(join(bucketDir, "app/releases/2.0.0.tmp"), "half-written");55 const orphan = join(bucketDir, "app/releases/3.0.0/linux-x86_64");56 await Bun.write(join(orphan, "app"), "no record yet");57 await writeRelease(58 bucketDir,59 "app",60 {61 version: "1.11.0",62 save,63 files: [{ platform: "linux-x86_64", name: "app", bytes: bytes("x") }],64 },65 false,66 );67 await syncAll(target);68 expect(releases()).toEqual(["1.10.0", "1.9.0", "1.2.3"]);69 await writeFile(join(bucketDir, "app/releases/latest"), "1.11.0");70 await syncAll(target);71 expect(releases()).toEqual(["1.11.0", "1.10.0", "1.9.0", "1.2.3"]);72 });7374 test("hidden versions drop out and a notes override replaces the record's notes", () => {75 target.kepr.run(76 "INSERT INTO release_settings (repo, version, hidden, notes) VALUES ('app', '1.11.0', 1, NULL), ('app', '1.9.0', 0, 'Operator notes.')",77 );78 const shown = visibleReleases(target.kepr, openMirror(target.mirrorDir, "app"), "app");79 expect(shown.map((r) => [r.version, r.notes])).toEqual([80 ["1.10.0", ""],81 ["1.9.0", "Operator notes."],82 ["1.2.3", ""],83 ]);84 target.kepr.run("DELETE FROM release_settings");85 });8687 test("a release record that breaks §11.2 is the repo's last sync error", async () => {88 await writeRelease(bucketDir, "app", {89 version: "1.12.0",90 save,91 files: [{ platform: "plan9-mips", name: "app", bytes: bytes("x") }],92 });93 await syncAll(target);94 expect(lastError()).toContain("release record does not match");95 await writeRelease(bucketDir, "app", {96 version: "1.12.0",97 save,98 files: [{ platform: "linux-x86_64", name: "../app", bytes: bytes("x") }],99 });100 await syncAll(target);101 expect(lastError()).toContain("release record does not match");102 });103});104105test("versions compare as numbers, segment by segment", () => {106 const sorted = ["1.10.0", "1.9.9", "2", "1.9", "0.0.0", "1.10", "10.0.0"].sort(compareVersions);107 expect(sorted).toEqual(["0.0.0", "1.9", "1.9.9", "1.10", "1.10.0", "2", "10.0.0"]);108});109110describe("the builds panel", () => {111 let app: Hono;112 let saves: Record<string, string>;113 const get = (path: string) => app.request(path);114115 beforeAll(async () => {116 const repos = await makeRepos(["tool", "secret"]);117 saves = repos.saves;118 const tool = saves.tool as string;119 const b = repos.bucketDir;120 await writeRelease(b, "tool", {121 version: "0.9.0",122 save: tool,123 notes: "## Fixes\n\n- the *first* fix <script>",124 files: [{ platform: "linux-x86_64", name: "tool", bytes: bytes("old") }],125 });126 await writeRelease(b, "tool", {127 version: "0.10.0",128 save: tool,129 publishedAt: 1790100000,130 files: [131 { platform: "macos-aarch64", name: "Tool.app.zip", bytes: bytes("mac") },132 { platform: "android-arm64", name: "tool-0.10.0.apk", bytes: bytes("apk") },133 ],134 });135 await writeRelease(b, "tool", {136 version: "0.11.0",137 save: "aaaaaaaaaaaa",138 files: [{ platform: "linux-x86_64", name: "tool", bytes: bytes("new") }],139 });140 await writeRelease(b, "secret", {141 version: "1.0.0",142 save: saves.secret as string,143 files: [{ platform: "linux-x86_64", name: "s", bytes: bytes("s") }],144 });145 app = await releaseApp(b, (kepr) => {146 kepr.run("UPDATE repos SET public = 1 WHERE name = 'tool'");147 kepr.run(148 "INSERT INTO downloads (repo, version, platform, count) VALUES ('tool', '0.10.0', 'android-arm64', 7)",149 );150 });151 });152153 test("lists versions newest first with their save, date, notes and files", async () => {154 const res = await get("/tool/releases");155 expect(res.status).toBe(200);156 const body = await res.text();157 const order = ["v0.11.0", "v0.10.0", "v0.9.0"].map((v) => body.indexOf(`>${v}<`));158 expect(order).toEqual([...order].sort((x, y) => x - y));159 expect(order[0]).toBeGreaterThan(0);160 expect(body).toContain(`href="/tool/saves/${saves.tool}"`);161 expect(body).toContain("2026-09-22 18:00");162 expect(body).toContain("<h2>Fixes</h2>");163 expect(body).toContain("<script>");164 165 expect(body).toContain("<p>release tool</p>");166 expect(body).toContain("<p>Release 0.11.0 of tool.</p>");167 });168169 test("each file shows its label, size, SHA-256, download count and install hint", async () => {170 const body = await (await get("/tool/releases")).text();171 expect(body).toContain("Android · ARM64");172 expect(body).toContain("macOS · Apple Silicon");173 expect(body).toContain('href="/tool/releases/0.10.0/tool-android-arm64"');174 expect(body).toContain("3 B · 7 downloads");175 expect(body).toContain(new Bun.CryptoHasher("sha256").update("apk").digest("hex"));176 expect(body).toContain("unzip and drag to Applications");177 expect(body).toContain(178 "open on your device to install — allow installs from this source if prompted",179 );180 });181182 test("hidden versions don't show, and the repo header names the newest visible release", async () => {183 expect(await (await get("/tool")).text()).toContain("v0.11.0 · ");184 const kepr = releaseKepr.get(app);185 kepr?.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('tool', '0.11.0', 1)");186 const body = await (await get("/tool/releases")).text();187 expect(body).not.toContain("0.11.0");188 expect(await (await get("/tool")).text()).toContain("v0.10.0 · ");189 kepr?.run("DELETE FROM release_settings");190 });191192 test("a private repo's builds are a 404", async () => {193 expect((await get("/secret/releases")).status).toBe(404);194 });195});196197describe("the §22.1 release routes", () => {198 let app: Hono;199 let kepr: Database;200 let bucketDir: string;201 const get = (path: string, headers?: Record<string, string>) => app.request(path, { headers });202 const downloads = () =>203 kepr.query("SELECT version, platform, count FROM downloads ORDER BY version, platform").all();204205 beforeAll(async () => {206 const repos = await makeRepos(["cli", "hush"]);207 bucketDir = repos.bucketDir;208 const save = repos.saves.cli as string;209 await writeRelease(bucketDir, "cli", {210 version: "1.0.0",211 save,212 notes: 'First </script> & "quoted" / slashed\nline — ok',213 files: [214 { platform: "linux-x86_64", name: "cli", bytes: bytes("linux 1") },215 { platform: "android-arm64", name: "cli-1.0.0.apk", bytes: bytes("apk 1") },216 ],217 });218 await writeRelease(bucketDir, "cli", {219 version: "1.1.0",220 save,221 files: [222 { platform: "macos-aarch64", name: "Cli.app.zip", bytes: bytes("mac") },223 { platform: "linux-x86_64", name: "cli", bytes: bytes("linux 2") },224 ],225 });226 await writeRelease(bucketDir, "hush", {227 version: "1.0.0",228 save: repos.saves.hush as string,229 files: [{ platform: "linux-x86_64", name: "h", bytes: bytes("h") }],230 });231 app = await releaseApp(bucketDir, (db) => {232 db.run("UPDATE repos SET public = 1 WHERE name = 'cli'");233 });234 kepr = releaseKepr.get(app) as Database;235 });236237 test("latest.json is the release JSON shape, notes omitted when empty, urls on the configured domain", async () => {238 const res = await get("/cli/releases/latest.json");239 expect(res.status).toBe(200);240 expect(res.headers.get("Content-Type")).toBe("application/json; charset=UTF-8");241 const sha = new Bun.CryptoHasher("sha256").update("mac").digest("hex");242 const body = await res.text();243 expect(244 body.startsWith(245 '{"version":"1.1.0","platforms":{"linux-x86_64":{"url":"https://kepr.example/cli/releases/1.1.0/linux-x86_64","blake3":"',246 ),247 ).toBe(true);248 expect(body).toContain(249 `"macos-aarch64":{"url":"https://kepr.example/cli/releases/1.1.0/macos-aarch64",`,250 );251 expect(body).toContain(252 `"sha256":"${sha}","provenance":{"version":"1.1.0","platform":"macos-aarch64","checksum":"blake3:`,253 );254 expect(body.endsWith('"source":"koh-offer"},"signature":""}}}')).toBe(true);255 });256257 test("history.json lists every version with notes escaped as Kepr 2 escaped them", async () => {258 const body = await (await get("/cli/releases/history.json")).text();259 expect(body).toBe(260 '[{"version":"1.1.0","notes":""},{"version":"1.0.0","notes":"First \\u003c\\/script\\u003e & \\"quoted\\" \\/ slashed\\nline — ok"}]',261 );262 expect(JSON.parse(body)[1].notes).toBe('First </script> & "quoted" / slashed\nline — ok');263 });264265 test("version.txt and checksums.txt", async () => {266 const version = await get("/cli/releases/latest/version.txt");267 expect([version.headers.get("Content-Type"), await version.text()]).toEqual([268 "text/plain; charset=UTF-8",269 "1.1.0",270 ]);271 const sums = await (await get("/cli/releases/1.0.0/checksums.txt")).text();272 const sha = (text: string) => new Bun.CryptoHasher("sha256").update(text).digest("hex");273 expect(sums).toBe(274 `# cli 1.0.0\n# built by kepr\n\nsha256:${sha("apk 1")} cli-android-arm64\nsha256:${sha("linux 1")} cli-linux-x86_64\nblake3:${blake3Hex("apk 1")} cli-android-arm64\nblake3:${blake3Hex("linux 1")} cli-linux-x86_64\n`,275 );276 });277278 test("downloads count, then redirect to the stored file", async () => {279 const before = downloads().length;280 expect(before).toBe(0);281 const latest = await get("/cli/releases/latest/linux-x86_64");282 expect(latest.status).toBe(302);283 expect(latest.headers.get("Cache-Control")).toBe("no-store");284 expect(latest.headers.get("Location")).toBe(285 `file://${join(bucketDir, "cli/releases/1.1.0/linux-x86_64/cli")}`,286 );287 288 expect((await get("/cli/releases/latest/android-arm64")).headers.get("Location")).toEndWith(289 "/cli/releases/1.0.0/android-arm64/cli-1.0.0.apk",290 );291 for (const path of [292 "/cli/releases/1.0.0/android-arm64",293 "/cli/releases/1.0.0/cli-android-arm64",294 ]) {295 expect((await get(path)).status).toBe(302);296 }297 expect(downloads()).toEqual([298 { version: "1.0.0", platform: "android-arm64", count: 3 },299 { version: "1.1.0", platform: "linux-x86_64", count: 1 },300 ]);301 });302303 test("a missing release is a 404 that counts nothing, and a private repo looks unknown", async () => {304 const before = downloads();305 for (const path of [306 "/cli/releases/1.0.0/macos-aarch64",307 "/cli/releases/9.9.9/linux-x86_64",308 "/cli/releases/1.0.0/plan9",309 "/cli/releases/latest/windows-x86_64",310 "/cli/releases/9.9.9/checksums.txt",311 ]) {312 const res = await get(path);313 expect([path, res.status, await res.text()]).toEqual([314 path,315 404,316 '{"error":"not_found","message":"release not found"}',317 ]);318 }319 for (const repo of ["hush", "nowhere"]) {320 for (const suffix of ["/releases/latest.json", "/releases/1.0.0/linux-x86_64", "/meta"]) {321 const res = await get(`/${repo}${suffix}`);322 expect([res.status, await res.text()]).toEqual([323 404,324 '{"error":"repo_not_found","message":"repo not found"}',325 ]);326 }327 }328 expect(downloads()).toEqual(before);329 });330331 test("a hidden version leaves latest, the feeds and its downloads", async () => {332 kepr.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('cli', '1.1.0', 1)");333 expect(await (await get("/cli/releases/latest/version.txt")).text()).toBe("1.0.0");334 expect(await (await get("/cli/releases/history.json")).text()).not.toContain("1.1.0");335 expect((await get("/cli/releases/1.1.0/linux-x86_64")).status).toBe(404);336 expect((await get("/cli/releases/latest/linux-x86_64")).headers.get("Location")).toEndWith(337 "/1.0.0/linux-x86_64/cli",338 );339 kepr.run("DELETE FROM release_settings");340 });341342 test("releases/latest sends old links to the builds panel", async () => {343 const res = await get("/cli/releases/latest");344 expect([res.status, res.headers.get("Location")]).toEqual([302, "/cli/releases"]);345 expect((await get("/hush/releases/latest")).status).toBe(404);346 });347348 test("a Nix fetch of a tarball counts as an install; redirects and other clients don't", async () => {349 const installs = () =>350 (351 kepr.query("SELECT count FROM installs WHERE repo = 'cli'").get() as {352 count: number;353 } | null354 )?.count ?? 0;355 const nix = { "User-Agent": "curl/8.7.1 Nix/2.24.0 nix/2.24.0" };356 const head = await get("/cli/archive/HEAD.tar.gz", nix);357 expect(installs()).toBe(0);358 const tarball = head.headers.get("Location") as string;359 expect((await get(tarball, nix)).status).toBe(200);360 expect((await get(tarball, { "User-Agent": "curl/8.7.1" })).status).toBe(200);361 expect(installs()).toBe(1);362 });363});364365test("release files are served with §22.2's content types", () => {366 expect(releaseContentType("peach-2.9.28-android-arm64.apk")).toBe(367 "application/vnd.android.package-archive",368 );369 expect(releaseContentType("Haven.app.ZIP")).toBe("application/zip");370 expect(releaseContentType("koh")).toBe("application/octet-stream");371});372373const releaseKepr = new WeakMap<Hono, Database>();374375376async function releaseApp(bucketDir: string, setup: (kepr: Database) => void): Promise<Hono> {377 const dataDir = await mkdtemp(join(tmpdir(), "kepr-release-app-"));378 const kepr = openKeprDb(dataDir);379 const bucket = openBucket({ kind: "folder", path: bucketDir });380 await syncAll({381 bucket,382 kepr,383 mirrorDir: join(dataDir, "mirror"),384 tarballDir: join(dataDir, "tarballs"),385 });386 setup(kepr);387 const app = createApp(388 {389 domain: "kepr.example",390 dataDir,391 adminPasswordHash: "$argon2id$x",392 bucket: { kind: "folder", path: bucketDir },393 },394 kepr,395 );396 releaseKepr.set(app, kepr);397 return app;398}