import type { Database } from "bun:sqlite"; import { beforeAll, describe, expect, test } from "bun:test"; import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { blake3 } from "@noble/hashes/blake3.js"; import { bytesToHex } from "@noble/hashes/utils.js"; import type { Hono } from "hono"; import { openBucket } from "../src/bucket/reader.ts"; import { type SyncTarget, syncAll } from "../src/bucket/sync.ts"; import { openMirror } from "../src/mirror/store.ts"; import { compareVersions, releaseContentType, visibleReleases } from "../src/releases/releases.ts"; import { createApp } from "../src/server/app.ts"; import { openKeprDb } from "../src/server/db.ts"; import { makeRepos, writeRelease } from "./fixture.ts"; const bytes = (text: string) => new TextEncoder().encode(text); const blake3Hex = (text: string) => bytesToHex(blake3(bytes(text))); describe("release reading in sync", () => { let bucketDir: string; let save: string; let target: SyncTarget; const releases = () => visibleReleases(target.kepr, openMirror(target.mirrorDir, "app"), "app").map((r) => r.version); const lastError = () => (target.kepr.query("SELECT last_sync_error FROM repos").get() as { last_sync_error: string }) .last_sync_error; beforeAll(async () => { const repos = await makeRepos(["app"]); bucketDir = repos.bucketDir; save = repos.saves.app as string; const dataDir = await mkdtemp(join(tmpdir(), "kepr-releases-")); target = { bucket: openBucket({ kind: "folder", path: bucketDir }), kepr: openKeprDb(dataDir), mirrorDir: join(dataDir, "mirror"), }; for (const version of ["1.9.0", "1.10.0", "1.2.3"]) { const files = [{ platform: "linux-x86_64", name: "app", bytes: bytes(version) }]; await writeRelease(bucketDir, "app", { version, save, files }, version === "1.10.0"); } await syncAll(target); }); test("reads every release.json, newest first by number", () => { expect(lastError()).toBeNull(); expect(releases()).toEqual(["1.10.0", "1.9.0", "1.2.3"]); }); test("a version folder without release.json does not exist, and nothing is re-read until latest moves", async () => { await writeFile(join(bucketDir, "app/releases/2.0.0.tmp"), "half-written"); const orphan = join(bucketDir, "app/releases/3.0.0/linux-x86_64"); await Bun.write(join(orphan, "app"), "no record yet"); await writeRelease( bucketDir, "app", { version: "1.11.0", save, files: [{ platform: "linux-x86_64", name: "app", bytes: bytes("x") }], }, false, ); await syncAll(target); expect(releases()).toEqual(["1.10.0", "1.9.0", "1.2.3"]); await writeFile(join(bucketDir, "app/releases/latest"), "1.11.0"); await syncAll(target); expect(releases()).toEqual(["1.11.0", "1.10.0", "1.9.0", "1.2.3"]); }); test("hidden versions drop out and a notes override replaces the record's notes", () => { target.kepr.run( "INSERT INTO release_settings (repo, version, hidden, notes) VALUES ('app', '1.11.0', 1, NULL), ('app', '1.9.0', 0, 'Operator notes.')", ); const shown = visibleReleases(target.kepr, openMirror(target.mirrorDir, "app"), "app"); expect(shown.map((r) => [r.version, r.notes])).toEqual([ ["1.10.0", ""], ["1.9.0", "Operator notes."], ["1.2.3", ""], ]); target.kepr.run("DELETE FROM release_settings"); }); test("a releases/latest that is not a version is a sync error and is not kept", async () => { const mirror = openMirror(target.mirrorDir, "app"); const before = mirror.query("SELECT value FROM state WHERE key = 'releases_latest'").get(); await writeFile(join(bucketDir, "app/releases/latest"), "../../1.0"); await syncAll(target); expect(lastError()).toContain('app/releases/latest is not a version: "../../1.0"'); expect(mirror.query("SELECT value FROM state WHERE key = 'releases_latest'").get()).toEqual( before, ); await writeFile(join(bucketDir, "app/releases/latest"), "1.11.0"); await syncAll(target); expect(lastError()).toBeNull(); }); test("a release record that breaks §11.2 is the repo's last sync error", async () => { await writeRelease(bucketDir, "app", { version: "1.12.0", save, files: [{ platform: "plan9-mips", name: "app", bytes: bytes("x") }], }); await syncAll(target); expect(lastError()).toContain("release record does not match"); await writeRelease(bucketDir, "app", { version: "1.12.0", save, files: [{ platform: "linux-x86_64", name: "../app", bytes: bytes("x") }], }); await syncAll(target); expect(lastError()).toContain("release record does not match"); }); }); test("versions compare as numbers, segment by segment", () => { const sorted = ["1.10.0", "1.9.9", "2", "1.9", "0.0.0", "1.10", "10.0.0"].sort(compareVersions); expect(sorted).toEqual(["0.0.0", "1.9", "1.9.9", "1.10", "1.10.0", "2", "10.0.0"]); // Beyond a float's precision, and with leading zeros. expect(compareVersions("10000000000000000", "9999999999999999")).toBeGreaterThan(0); expect(compareVersions("1.10000000000000001", "1.10000000000000000")).toBeGreaterThan(0); expect(compareVersions("1.010", "1.9")).toBeGreaterThan(0); expect(compareVersions("1.007", "1.7")).not.toBe(0); }); describe("the builds panel", () => { let app: Hono; let saves: Record; const get = (path: string) => app.request(path); beforeAll(async () => { const repos = await makeRepos(["tool", "secret"]); saves = repos.saves; const tool = saves.tool as string; const b = repos.bucketDir; await writeRelease(b, "tool", { version: "0.9.0", save: tool, notes: "## Fixes\n\n- the *first* fix & "quoted" / slashed\nline — ok', files: [ { platform: "linux-x86_64", name: "cli", bytes: bytes("linux 1") }, { platform: "android-arm64", name: "cli-1.0.0.apk", bytes: bytes("apk 1") }, ], }); await writeRelease(bucketDir, "cli", { version: "1.1.0", save, files: [ { platform: "macos-aarch64", name: "Cli.app.zip", bytes: bytes("mac") }, { platform: "linux-x86_64", name: "cli", bytes: bytes("linux 2") }, ], }); await writeRelease(bucketDir, "hush", { version: "1.0.0", save: repos.saves.hush as string, files: [{ platform: "linux-x86_64", name: "h", bytes: bytes("h") }], }); // cli's head is a checkpoint made after its releases (koh3 offers it on its own). repos.koh("cli", "checkpoint", "after the releases"); checkpoint = JSON.parse(repos.koh("cli", "view", "--json")).head_id; app = await releaseApp(bucketDir, (db) => { db.run("UPDATE repos SET public = 1 WHERE name = 'cli'"); }); kepr = releaseKepr.get(app) as Database; }); test("latest.json is the release JSON shape, notes omitted when empty, urls on the configured domain", async () => { const res = await get("/cli/releases/latest.json"); expect(res.status).toBe(200); expect(res.headers.get("Content-Type")).toBe("application/json; charset=UTF-8"); const sha = new Bun.CryptoHasher("sha256").update("mac").digest("hex"); const body = await res.text(); expect( body.startsWith( '{"version":"1.1.0","platforms":{"linux-x86_64":{"url":"https://kepr.example/cli/releases/1.1.0/linux-x86_64","blake3":"', ), ).toBe(true); expect(body).toContain( `"macos-aarch64":{"url":"https://kepr.example/cli/releases/1.1.0/macos-aarch64",`, ); expect(body).toContain( `"sha256":"${sha}","provenance":{"version":"1.1.0","platform":"macos-aarch64","checksum":"blake3:`, ); expect(body.endsWith('"source":"koh-offer"},"signature":""}}}')).toBe(true); }); test("history.json lists every version with notes escaped as Kepr 2 escaped them", async () => { const body = await (await get("/cli/releases/history.json")).text(); expect(body).toBe( '[{"version":"1.1.0","notes":""},{"version":"1.0.0","notes":"First \\u003c\\/script\\u003e & \\"quoted\\" \\/ slashed\\nline — ok"}]', ); expect(JSON.parse(body)[1].notes).toBe('First & "quoted" / slashed\nline — ok'); }); test("version.txt and checksums.txt", async () => { const version = await get("/cli/releases/latest/version.txt"); expect([version.headers.get("Content-Type"), await version.text()]).toEqual([ "text/plain; charset=UTF-8", "1.1.0", ]); const sums = await (await get("/cli/releases/1.0.0/checksums.txt")).text(); const sha = (text: string) => new Bun.CryptoHasher("sha256").update(text).digest("hex"); expect(sums).toBe( `# 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`, ); }); test("downloads count, then redirect to the stored file", async () => { const before = downloads().length; expect(before).toBe(0); const latest = await get("/cli/releases/latest/linux-x86_64"); expect(latest.status).toBe(302); expect(latest.headers.get("Cache-Control")).toBe("no-store"); expect(latest.headers.get("Location")).toBe( `file://${join(bucketDir, "cli/releases/1.1.0/linux-x86_64/cli")}`, ); // The newest release that has the platform: 1.1.0 has no APK. expect((await get("/cli/releases/latest/android-arm64")).headers.get("Location")).toEndWith( "/cli/releases/1.0.0/android-arm64/cli-1.0.0.apk", ); for (const path of [ "/cli/releases/1.0.0/android-arm64", "/cli/releases/1.0.0/cli-android-arm64", ]) { expect((await get(path)).status).toBe(302); } expect(downloads()).toEqual([ { version: "1.0.0", platform: "android-arm64", count: 3 }, { version: "1.1.0", platform: "linux-x86_64", count: 1 }, ]); }); test("a missing release is a 404 that counts nothing, and a private repo looks unknown", async () => { const before = downloads(); for (const path of [ "/cli/releases/1.0.0/macos-aarch64", "/cli/releases/9.9.9/linux-x86_64", "/cli/releases/1.0.0/plan9", "/cli/releases/latest/windows-x86_64", "/cli/releases/9.9.9/checksums.txt", ]) { const res = await get(path); expect([path, res.status, await res.text()]).toEqual([ path, 404, '{"error":"not_found","message":"release not found"}', ]); } for (const repo of ["hush", "nowhere"]) { for (const suffix of ["/releases/latest.json", "/releases/1.0.0/linux-x86_64", "/meta"]) { const res = await get(`/${repo}${suffix}`); expect([res.status, await res.text()]).toEqual([ 404, '{"error":"repo_not_found","message":"repo not found"}', ]); } } expect(downloads()).toEqual(before); }); test("a hidden version leaves latest, the feeds and its downloads", async () => { kepr.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('cli', '1.1.0', 1)"); expect(await (await get("/cli/releases/latest/version.txt")).text()).toBe("1.0.0"); expect(await (await get("/cli/releases/history.json")).text()).not.toContain("1.1.0"); expect((await get("/cli/releases/1.1.0/linux-x86_64")).status).toBe(404); expect((await get("/cli/releases/latest/linux-x86_64")).headers.get("Location")).toEndWith( "/1.0.0/linux-x86_64/cli", ); kepr.run("DELETE FROM release_settings"); }); test("update-info's head is the head save even when it is a checkpoint", async () => { const info = (await (await get("/cli/update-info")).json()) as { head: { save_id: string; version: string }; latest_build: { version: string }; }; expect([info.head.save_id, info.head.version]).toEqual([checkpoint, checkpoint]); expect(info.latest_build.version).toBe("1.1.0"); }); test("the /api feeds send CORS on their 404s too", async () => { for (const path of ["/api/hush/releases/latest", "/api/nowhere/releases/history"]) { const res = await get(path); expect([res.status, res.headers.get("Access-Control-Allow-Origin")]).toEqual([404, "*"]); } expect( (await get("/hush/releases/latest.json")).headers.get("Access-Control-Allow-Origin"), ).toBeNull(); }); test("releases/latest sends old links to the builds panel", async () => { const res = await get("/cli/releases/latest"); expect([res.status, res.headers.get("Location")]).toEqual([302, "/cli/releases"]); expect((await get("/hush/releases/latest")).status).toBe(404); }); test("a Nix fetch of a tarball counts as an install; redirects and other clients don't", async () => { const installs = () => ( kepr.query("SELECT count FROM installs WHERE repo = 'cli'").get() as { count: number; } | null )?.count ?? 0; const nix = { "User-Agent": "curl/8.7.1 Nix/2.24.0 nix/2.24.0" }; const head = await get("/cli/archive/HEAD.tar.gz", nix); expect(installs()).toBe(0); const tarball = head.headers.get("Location") as string; expect((await get(tarball, nix)).status).toBe(200); expect((await get(tarball, { "User-Agent": "curl/8.7.1" })).status).toBe(200); expect(installs()).toBe(1); }); }); test("release files are served with §22.2's content types", () => { expect(releaseContentType("peach-2.9.28-android-arm64.apk")).toBe( "application/vnd.android.package-archive", ); expect(releaseContentType("Haven.app.ZIP")).toBe("application/zip"); expect(releaseContentType("koh")).toBe("application/octet-stream"); }); const releaseKepr = new WeakMap(); /** Syncs a folder remote into a fresh data dir and serves it as kepr.example. */ async function releaseApp(bucketDir: string, setup: (kepr: Database) => void): Promise { const dataDir = await mkdtemp(join(tmpdir(), "kepr-release-app-")); const kepr = openKeprDb(dataDir); const bucket = openBucket({ kind: "folder", path: bucketDir }); await syncAll({ bucket, kepr, mirrorDir: join(dataDir, "mirror"), }); setup(kepr); const app = createApp( { domain: "kepr.example", dataDir, adminPasswordHash: "$argon2id$x", bucket: { kind: "folder", path: bucketDir }, }, kepr, ); releaseKepr.set(app, kepr); return app; }