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 };40 for (const version of ["1.9.0", "1.10.0", "1.2.3"]) {41 const files = [{ platform: "linux-x86_64", name: "app", bytes: bytes(version) }];42 await writeRelease(bucketDir, "app", { version, save, files }, version === "1.10.0");43 }44 await syncAll(target);45 });4647 test("reads every release.json, newest first by number", () => {48 expect(lastError()).toBeNull();49 expect(releases()).toEqual(["1.10.0", "1.9.0", "1.2.3"]);50 });5152 test("a version folder without release.json does not exist, and nothing is re-read until latest moves", async () => {53 await writeFile(join(bucketDir, "app/releases/2.0.0.tmp"), "half-written");54 const orphan = join(bucketDir, "app/releases/3.0.0/linux-x86_64");55 await Bun.write(join(orphan, "app"), "no record yet");56 await writeRelease(57 bucketDir,58 "app",59 {60 version: "1.11.0",61 save,62 files: [{ platform: "linux-x86_64", name: "app", bytes: bytes("x") }],63 },64 false,65 );66 await syncAll(target);67 expect(releases()).toEqual(["1.10.0", "1.9.0", "1.2.3"]);68 await writeFile(join(bucketDir, "app/releases/latest"), "1.11.0");69 await syncAll(target);70 expect(releases()).toEqual(["1.11.0", "1.10.0", "1.9.0", "1.2.3"]);71 });7273 test("hidden versions drop out and a notes override replaces the record's notes", () => {74 target.kepr.run(75 "INSERT INTO release_settings (repo, version, hidden, notes) VALUES ('app', '1.11.0', 1, NULL), ('app', '1.9.0', 0, 'Operator notes.')",76 );77 const shown = visibleReleases(target.kepr, openMirror(target.mirrorDir, "app"), "app");78 expect(shown.map((r) => [r.version, r.notes])).toEqual([79 ["1.10.0", ""],80 ["1.9.0", "Operator notes."],81 ["1.2.3", ""],82 ]);83 target.kepr.run("DELETE FROM release_settings");84 });8586 test("a releases/latest that is not a version is a sync error and is not kept", async () => {87 const mirror = openMirror(target.mirrorDir, "app");88 const before = mirror.query("SELECT value FROM state WHERE key = 'releases_latest'").get();89 await writeFile(join(bucketDir, "app/releases/latest"), "../../1.0");90 await syncAll(target);91 expect(lastError()).toContain('app/releases/latest is not a version: "../../1.0"');92 expect(mirror.query("SELECT value FROM state WHERE key = 'releases_latest'").get()).toEqual(93 before,94 );95 await writeFile(join(bucketDir, "app/releases/latest"), "1.11.0");96 await syncAll(target);97 expect(lastError()).toBeNull();98 });99100 test("a release record that breaks §11.2 is the repo's last sync error", async () => {101 await writeRelease(bucketDir, "app", {102 version: "1.12.0",103 save,104 files: [{ platform: "plan9-mips", name: "app", bytes: bytes("x") }],105 });106 await syncAll(target);107 expect(lastError()).toContain("release record does not match");108 await writeRelease(bucketDir, "app", {109 version: "1.12.0",110 save,111 files: [{ platform: "linux-x86_64", name: "../app", bytes: bytes("x") }],112 });113 await syncAll(target);114 expect(lastError()).toContain("release record does not match");115 });116});117118test("versions compare as numbers, segment by segment", () => {119 const sorted = ["1.10.0", "1.9.9", "2", "1.9", "0.0.0", "1.10", "10.0.0"].sort(compareVersions);120 expect(sorted).toEqual(["0.0.0", "1.9", "1.9.9", "1.10", "1.10.0", "2", "10.0.0"]);121 122 expect(compareVersions("10000000000000000", "9999999999999999")).toBeGreaterThan(0);123 expect(compareVersions("1.10000000000000001", "1.10000000000000000")).toBeGreaterThan(0);124 expect(compareVersions("1.010", "1.9")).toBeGreaterThan(0);125 expect(compareVersions("1.007", "1.7")).not.toBe(0);126});127128describe("the builds panel", () => {129 let app: Hono;130 let saves: Record<string, string>;131 const get = (path: string) => app.request(path);132133 beforeAll(async () => {134 const repos = await makeRepos(["tool", "secret"]);135 saves = repos.saves;136 const tool = saves.tool as string;137 const b = repos.bucketDir;138 await writeRelease(b, "tool", {139 version: "0.9.0",140 save: tool,141 notes: "## Fixes\n\n- the *first* fix <script>",142 files: [{ platform: "linux-x86_64", name: "tool", bytes: bytes("old") }],143 });144 await writeRelease(b, "tool", {145 version: "0.10.0",146 save: tool,147 publishedAt: 1790100000,148 files: [149 { platform: "macos-aarch64", name: "Tool.app.zip", bytes: bytes("mac") },150 { platform: "android-arm64", name: "tool-0.10.0.apk", bytes: bytes("apk") },151 ],152 });153 await writeRelease(b, "tool", {154 version: "0.11.0",155 save: "aaaaaaaaaaaa",156 files: [{ platform: "linux-x86_64", name: "tool", bytes: bytes("new") }],157 });158 await writeRelease(b, "secret", {159 version: "1.0.0",160 save: saves.secret as string,161 files: [{ platform: "linux-x86_64", name: "s", bytes: bytes("s") }],162 });163 app = await releaseApp(b, (kepr) => {164 kepr.run("UPDATE repos SET public = 1 WHERE name = 'tool'");165 kepr.run(166 "INSERT INTO downloads (repo, version, platform, count) VALUES ('tool', '0.10.0', 'android-arm64', 7)",167 );168 });169 });170171 test("lists versions newest first with their save, date, notes and files", async () => {172 const res = await get("/tool/releases");173 expect(res.status).toBe(200);174 const body = await res.text();175 const order = ["v0.11.0", "v0.10.0", "v0.9.0"].map((v) => body.indexOf(`>${v}<`));176 expect(order).toEqual([...order].sort((x, y) => x - y));177 expect(order[0]).toBeGreaterThan(0);178 expect(body).toContain('class="release-latest"');179 180 expect(body).toContain('class="archive-row release-row" id="0.10.0"');181 expect(body).toContain("earlier releases");182 const older = body.slice(183 body.indexOf('id="0.10.0"'),184 body.indexOf("<details", body.indexOf('id="0.10.0"')),185 );186 expect(older).toContain('href="/tool/releases/0.10.0/tool-0.10.0.apk"');187 expect(body).toContain(`href="/tool/saves/${saves.tool}"`);188 expect(body).toContain("2026-09-22 18:00");189 expect(body).toContain("<h2>Fixes</h2>");190 expect(body).toContain("<script>");191 192 expect(body).toContain("<p>release tool</p>");193 expect(body).toContain("<p>Release 0.11.0 of tool.</p>");194 });195196 test("each file shows its label, size, SHA-256, download count and install hint", async () => {197 const body = await (await get("/tool/releases")).text();198 expect(body).toContain("Android · ARM64");199 expect(body).toContain("macOS · Apple Silicon");200 expect(body).toContain('href="/tool/releases/0.10.0/tool-0.10.0.apk"');201 expect(body).toContain("3 B · 7 downloads");202 expect(body).toContain(new Bun.CryptoHasher("sha256").update("apk").digest("hex"));203 expect(body).toContain("unzip and drag to Applications");204 expect(body).toContain(205 "open on your device to install — allow installs from this source if prompted",206 );207 });208209 test("hidden versions don't show, and the repo header names the newest visible release", async () => {210 expect(await (await get("/tool")).text()).toContain(211 `<span class="repo-version">v0.11.0</span> · `,212 );213 const kepr = releaseKepr.get(app);214 kepr?.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('tool', '0.11.0', 1)");215 const body = await (await get("/tool/releases")).text();216 expect(body).not.toContain("0.11.0");217 expect(await (await get("/tool")).text()).toContain(218 `<span class="repo-version">v0.10.0</span> · `,219 );220 kepr?.run("DELETE FROM release_settings");221 });222223 test("a private repo's builds are a 404", async () => {224 expect((await get("/secret/releases")).status).toBe(404);225 });226});227228describe("the §22.1 release routes", () => {229 let app: Hono;230 let kepr: Database;231 let bucketDir: string;232 let checkpoint: string;233 const get = (path: string, headers?: Record<string, string>) => app.request(path, { headers });234 const downloads = () =>235 kepr.query("SELECT version, platform, count FROM downloads ORDER BY version, platform").all();236237 beforeAll(async () => {238 const repos = await makeRepos(["cli", "hush"]);239 bucketDir = repos.bucketDir;240 const save = repos.saves.cli as string;241 await writeRelease(bucketDir, "cli", {242 version: "1.0.0",243 save,244 notes: 'First </script> & "quoted" / slashed\nline — ok',245 files: [246 { platform: "linux-x86_64", name: "cli", bytes: bytes("linux 1") },247 { platform: "android-arm64", name: "cli-1.0.0.apk", bytes: bytes("apk 1") },248 ],249 });250 await writeRelease(bucketDir, "cli", {251 version: "1.1.0",252 save,253 files: [254 { platform: "macos-aarch64", name: "Cli.app.zip", bytes: bytes("mac") },255 { platform: "linux-x86_64", name: "cli", bytes: bytes("linux 2") },256 ],257 });258 await writeRelease(bucketDir, "hush", {259 version: "1.0.0",260 save: repos.saves.hush as string,261 files: [{ platform: "linux-x86_64", name: "h", bytes: bytes("h") }],262 });263 264 repos.koh("cli", "checkpoint", "after the releases");265 checkpoint = JSON.parse(repos.koh("cli", "view", "--json")).head_id;266 app = await releaseApp(bucketDir, (db) => {267 db.run("UPDATE repos SET public = 1 WHERE name = 'cli'");268 });269 kepr = releaseKepr.get(app) as Database;270 });271272 test("latest.json is the release JSON shape, notes omitted when empty, urls on the configured domain", async () => {273 const res = await get("/cli/releases/latest.json");274 expect(res.status).toBe(200);275 expect(res.headers.get("Content-Type")).toBe("application/json; charset=UTF-8");276 const sha = new Bun.CryptoHasher("sha256").update("mac").digest("hex");277 const body = await res.text();278 expect(279 body.startsWith(280 '{"version":"1.1.0","platforms":{"linux-x86_64":{"url":"https://kepr.example/cli/releases/1.1.0/cli","blake3":"',281 ),282 ).toBe(true);283 expect(body).toContain(284 `"macos-aarch64":{"url":"https://kepr.example/cli/releases/1.1.0/Cli.app.zip",`,285 );286 expect(body).toContain(287 `"sha256":"${sha}","provenance":{"version":"1.1.0","platform":"macos-aarch64","checksum":"blake3:`,288 );289 expect(body.endsWith('"source":"koh-offer"},"signature":""}}}')).toBe(true);290 });291292 test("history.json lists every version with notes escaped as Kepr 2 escaped them", async () => {293 const body = await (await get("/cli/releases/history.json")).text();294 expect(body).toBe(295 '[{"version":"1.1.0","notes":""},{"version":"1.0.0","notes":"First \\u003c\\/script\\u003e & \\"quoted\\" \\/ slashed\\nline — ok"}]',296 );297 expect(JSON.parse(body)[1].notes).toBe('First </script> & "quoted" / slashed\nline — ok');298 });299300 test("version.txt and checksums.txt", async () => {301 const version = await get("/cli/releases/latest/version.txt");302 expect([version.headers.get("Content-Type"), await version.text()]).toEqual([303 "text/plain; charset=UTF-8",304 "1.1.0",305 ]);306 const sums = await (await get("/cli/releases/1.0.0/checksums.txt")).text();307 const sha = (text: string) => new Bun.CryptoHasher("sha256").update(text).digest("hex");308 expect(sums).toBe(309 `# 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`,310 );311 });312313 test("downloads count, then redirect to the stored file", async () => {314 const before = downloads().length;315 expect(before).toBe(0);316 const latest = await get("/cli/releases/latest/linux-x86_64");317 expect(latest.status).toBe(302);318 expect(latest.headers.get("Cache-Control")).toBe("no-store");319 expect(latest.headers.get("Location")).toBe(320 `file://${join(bucketDir, "cli/releases/1.1.0/linux-x86_64/cli")}`,321 );322 323 expect((await get("/cli/releases/latest/android-arm64")).headers.get("Location")).toEndWith(324 "/cli/releases/1.0.0/android-arm64/cli-1.0.0.apk",325 );326 for (const path of [327 "/cli/releases/1.0.0/android-arm64",328 "/cli/releases/1.0.0/cli-android-arm64",329 "/cli/releases/1.0.0/cli-1.0.0.apk",330 ]) {331 expect((await get(path)).status).toBe(302);332 }333 expect(downloads()).toEqual([334 { version: "1.0.0", platform: "android-arm64", count: 4 },335 { version: "1.1.0", platform: "linux-x86_64", count: 1 },336 ]);337 });338339 test("a missing release is a 404 that counts nothing, and a private repo looks unknown", async () => {340 const before = downloads();341 for (const path of [342 "/cli/releases/1.0.0/macos-aarch64",343 "/cli/releases/9.9.9/linux-x86_64",344 "/cli/releases/1.0.0/plan9",345 "/cli/releases/latest/windows-x86_64",346 "/cli/releases/9.9.9/checksums.txt",347 ]) {348 const res = await get(path);349 expect([path, res.status, await res.text()]).toEqual([350 path,351 404,352 '{"error":"not_found","message":"release not found"}',353 ]);354 }355 for (const repo of ["hush", "nowhere"]) {356 for (const suffix of ["/releases/latest.json", "/releases/1.0.0/linux-x86_64", "/meta"]) {357 const res = await get(`/${repo}${suffix}`);358 expect([res.status, await res.text()]).toEqual([359 404,360 '{"error":"repo_not_found","message":"repo not found"}',361 ]);362 }363 }364 expect(downloads()).toEqual(before);365 });366367 test("a hidden version leaves latest, the feeds and its downloads", async () => {368 kepr.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('cli', '1.1.0', 1)");369 expect(await (await get("/cli/releases/latest/version.txt")).text()).toBe("1.0.0");370 expect(await (await get("/cli/releases/history.json")).text()).not.toContain("1.1.0");371 expect((await get("/cli/releases/1.1.0/linux-x86_64")).status).toBe(404);372 expect((await get("/cli/releases/latest/linux-x86_64")).headers.get("Location")).toEndWith(373 "/1.0.0/linux-x86_64/cli",374 );375 kepr.run("DELETE FROM release_settings");376 });377378 test("update-info's head is the head save even when it is a checkpoint", async () => {379 const info = (await (await get("/cli/update-info")).json()) as {380 head: { save_id: string; version: string };381 latest_build: { version: string };382 };383 expect([info.head.save_id, info.head.version]).toEqual([checkpoint, checkpoint]);384 expect(info.latest_build.version).toBe("1.1.0");385 });386387 test("the /api feeds send CORS on their 404s too", async () => {388 for (const path of ["/api/hush/releases/latest", "/api/nowhere/releases/history"]) {389 const res = await get(path);390 expect([res.status, res.headers.get("Access-Control-Allow-Origin")]).toEqual([404, "*"]);391 }392 expect(393 (await get("/hush/releases/latest.json")).headers.get("Access-Control-Allow-Origin"),394 ).toBeNull();395 });396397 test("releases/latest sends old links to the builds panel", async () => {398 const res = await get("/cli/releases/latest");399 expect([res.status, res.headers.get("Location")]).toEqual([302, "/cli/releases"]);400 expect((await get("/hush/releases/latest")).status).toBe(404);401 });402403 test("a Nix fetch of a tarball counts as an install; redirects and other clients don't", async () => {404 const installs = () =>405 (406 kepr.query("SELECT count FROM installs WHERE repo = 'cli'").get() as {407 count: number;408 } | null409 )?.count ?? 0;410 const nix = { "User-Agent": "curl/8.7.1 Nix/2.24.0 nix/2.24.0" };411 const head = await get("/cli/archive/HEAD.tar.gz", nix);412 expect(installs()).toBe(0);413 const tarball = head.headers.get("Location") as string;414 expect((await get(tarball, nix)).status).toBe(200);415 expect((await get(tarball, { "User-Agent": "curl/8.7.1" })).status).toBe(200);416 expect(installs()).toBe(1);417 });418});419420test("release files are served with §22.2's content types", () => {421 expect(releaseContentType("peach-2.9.28-android-arm64.apk")).toBe(422 "application/vnd.android.package-archive",423 );424 expect(releaseContentType("Haven.app.ZIP")).toBe("application/zip");425 expect(releaseContentType("koh")).toBe("application/octet-stream");426});427428const releaseKepr = new WeakMap<Hono, Database>();429430431async function releaseApp(bucketDir: string, setup: (kepr: Database) => void): Promise<Hono> {432 const dataDir = await mkdtemp(join(tmpdir(), "kepr-release-app-"));433 const kepr = openKeprDb(dataDir);434 const bucket = openBucket({ kind: "folder", path: bucketDir });435 await syncAll({436 bucket,437 kepr,438 mirrorDir: join(dataDir, "mirror"),439 });440 setup(kepr);441 const app = createApp(442 {443 domain: "kepr.example",444 dataDir,445 adminPasswordHash: "$argon2id$x",446 bucket: { kind: "folder", path: bucketDir },447 },448 kepr,449 );450 releaseKepr.set(app, kepr);451 return app;452}