releases.test.ts

17.2 KB · raw

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 releases/latest that is not a version is a sync error and is not kept", async () => {88		const mirror = openMirror(target.mirrorDir, "app");89		const before = mirror.query("SELECT value FROM state WHERE key = 'releases_latest'").get();90		await writeFile(join(bucketDir, "app/releases/latest"), "../../1.0");91		await syncAll(target);92		expect(lastError()).toContain('app/releases/latest is not a version: "../../1.0"');93		expect(mirror.query("SELECT value FROM state WHERE key = 'releases_latest'").get()).toEqual(94			before,95		);96		await writeFile(join(bucketDir, "app/releases/latest"), "1.11.0");97		await syncAll(target);98		expect(lastError()).toBeNull();99	});100101	test("a release record that breaks §11.2 is the repo's last sync error", async () => {102		await writeRelease(bucketDir, "app", {103			version: "1.12.0",104			save,105			files: [{ platform: "plan9-mips", name: "app", bytes: bytes("x") }],106		});107		await syncAll(target);108		expect(lastError()).toContain("release record does not match");109		await writeRelease(bucketDir, "app", {110			version: "1.12.0",111			save,112			files: [{ platform: "linux-x86_64", name: "../app", bytes: bytes("x") }],113		});114		await syncAll(target);115		expect(lastError()).toContain("release record does not match");116	});117});118119test("versions compare as numbers, segment by segment", () => {120	const sorted = ["1.10.0", "1.9.9", "2", "1.9", "0.0.0", "1.10", "10.0.0"].sort(compareVersions);121	expect(sorted).toEqual(["0.0.0", "1.9", "1.9.9", "1.10", "1.10.0", "2", "10.0.0"]);122	// Beyond a float's precision, and with leading zeros.123	expect(compareVersions("10000000000000000", "9999999999999999")).toBeGreaterThan(0);124	expect(compareVersions("1.10000000000000001", "1.10000000000000000")).toBeGreaterThan(0);125	expect(compareVersions("1.010", "1.9")).toBeGreaterThan(0);126	expect(compareVersions("1.007", "1.7")).not.toBe(0);127});128129describe("the builds panel", () => {130	let app: Hono;131	let saves: Record<string, string>;132	const get = (path: string) => app.request(path);133134	beforeAll(async () => {135		const repos = await makeRepos(["tool", "secret"]);136		saves = repos.saves;137		const tool = saves.tool as string;138		const b = repos.bucketDir;139		await writeRelease(b, "tool", {140			version: "0.9.0",141			save: tool,142			notes: "## Fixes\n\n- the *first* fix <script>",143			files: [{ platform: "linux-x86_64", name: "tool", bytes: bytes("old") }],144		});145		await writeRelease(b, "tool", {146			version: "0.10.0",147			save: tool,148			publishedAt: 1790100000,149			files: [150				{ platform: "macos-aarch64", name: "Tool.app.zip", bytes: bytes("mac") },151				{ platform: "android-arm64", name: "tool-0.10.0.apk", bytes: bytes("apk") },152			],153		});154		await writeRelease(b, "tool", {155			version: "0.11.0",156			save: "aaaaaaaaaaaa",157			files: [{ platform: "linux-x86_64", name: "tool", bytes: bytes("new") }],158		});159		await writeRelease(b, "secret", {160			version: "1.0.0",161			save: saves.secret as string,162			files: [{ platform: "linux-x86_64", name: "s", bytes: bytes("s") }],163		});164		app = await releaseApp(b, (kepr) => {165			kepr.run("UPDATE repos SET public = 1 WHERE name = 'tool'");166			kepr.run(167				"INSERT INTO downloads (repo, version, platform, count) VALUES ('tool', '0.10.0', 'android-arm64', 7)",168			);169		});170	});171172	test("lists versions newest first with their save, date, notes and files", async () => {173		const res = await get("/tool/releases");174		expect(res.status).toBe(200);175		const body = await res.text();176		const order = ["v0.11.0", "v0.10.0", "v0.9.0"].map((v) => body.indexOf(`>${v}<`));177		expect(order).toEqual([...order].sort((x, y) => x - y));178		expect(order[0]).toBeGreaterThan(0);179		expect(body).toContain(`href="/tool/saves/${saves.tool}"`);180		expect(body).toContain("2026-09-22 18:00");181		expect(body).toContain("<h2>Fixes</h2>");182		expect(body).toContain("&lt;script&gt;");183		// Empty notes: the save's message, or Kepr 2's line when the save is unknown.184		expect(body).toContain("<p>release tool</p>");185		expect(body).toContain("<p>Release 0.11.0 of tool.</p>");186	});187188	test("each file shows its label, size, SHA-256, download count and install hint", async () => {189		const body = await (await get("/tool/releases")).text();190		expect(body).toContain("Android · ARM64");191		expect(body).toContain("macOS · Apple Silicon");192		expect(body).toContain('href="/tool/releases/0.10.0/tool-android-arm64"');193		expect(body).toContain("3 B · 7 downloads");194		expect(body).toContain(new Bun.CryptoHasher("sha256").update("apk").digest("hex"));195		expect(body).toContain("unzip and drag to Applications");196		expect(body).toContain(197			"open on your device to install — allow installs from this source if prompted",198		);199	});200201	test("hidden versions don't show, and the repo header names the newest visible release", async () => {202		expect(await (await get("/tool")).text()).toContain("v0.11.0 · ");203		const kepr = releaseKepr.get(app);204		kepr?.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('tool', '0.11.0', 1)");205		const body = await (await get("/tool/releases")).text();206		expect(body).not.toContain("0.11.0");207		expect(await (await get("/tool")).text()).toContain("v0.10.0 · ");208		kepr?.run("DELETE FROM release_settings");209	});210211	test("a private repo's builds are a 404", async () => {212		expect((await get("/secret/releases")).status).toBe(404);213	});214});215216describe("the §22.1 release routes", () => {217	let app: Hono;218	let kepr: Database;219	let bucketDir: string;220	let checkpoint: string;221	const get = (path: string, headers?: Record<string, string>) => app.request(path, { headers });222	const downloads = () =>223		kepr.query("SELECT version, platform, count FROM downloads ORDER BY version, platform").all();224225	beforeAll(async () => {226		const repos = await makeRepos(["cli", "hush"]);227		bucketDir = repos.bucketDir;228		const save = repos.saves.cli as string;229		await writeRelease(bucketDir, "cli", {230			version: "1.0.0",231			save,232			notes: 'First </script> & "quoted" / slashed\nline — ok',233			files: [234				{ platform: "linux-x86_64", name: "cli", bytes: bytes("linux 1") },235				{ platform: "android-arm64", name: "cli-1.0.0.apk", bytes: bytes("apk 1") },236			],237		});238		await writeRelease(bucketDir, "cli", {239			version: "1.1.0",240			save,241			files: [242				{ platform: "macos-aarch64", name: "Cli.app.zip", bytes: bytes("mac") },243				{ platform: "linux-x86_64", name: "cli", bytes: bytes("linux 2") },244			],245		});246		await writeRelease(bucketDir, "hush", {247			version: "1.0.0",248			save: repos.saves.hush as string,249			files: [{ platform: "linux-x86_64", name: "h", bytes: bytes("h") }],250		});251		// cli's head is a checkpoint made after its releases (koh3 offers it on its own).252		repos.koh("cli", "checkpoint", "after the releases");253		checkpoint = JSON.parse(repos.koh("cli", "view", "--json")).head_id;254		app = await releaseApp(bucketDir, (db) => {255			db.run("UPDATE repos SET public = 1 WHERE name = 'cli'");256		});257		kepr = releaseKepr.get(app) as Database;258	});259260	test("latest.json is the release JSON shape, notes omitted when empty, urls on the configured domain", async () => {261		const res = await get("/cli/releases/latest.json");262		expect(res.status).toBe(200);263		expect(res.headers.get("Content-Type")).toBe("application/json; charset=UTF-8");264		const sha = new Bun.CryptoHasher("sha256").update("mac").digest("hex");265		const body = await res.text();266		expect(267			body.startsWith(268				'{"version":"1.1.0","platforms":{"linux-x86_64":{"url":"https://kepr.example/cli/releases/1.1.0/linux-x86_64","blake3":"',269			),270		).toBe(true);271		expect(body).toContain(272			`"macos-aarch64":{"url":"https://kepr.example/cli/releases/1.1.0/macos-aarch64",`,273		);274		expect(body).toContain(275			`"sha256":"${sha}","provenance":{"version":"1.1.0","platform":"macos-aarch64","checksum":"blake3:`,276		);277		expect(body.endsWith('"source":"koh-offer"},"signature":""}}}')).toBe(true);278	});279280	test("history.json lists every version with notes escaped as Kepr 2 escaped them", async () => {281		const body = await (await get("/cli/releases/history.json")).text();282		expect(body).toBe(283			'[{"version":"1.1.0","notes":""},{"version":"1.0.0","notes":"First \\u003c\\/script\\u003e & \\"quoted\\" \\/ slashed\\nline — ok"}]',284		);285		expect(JSON.parse(body)[1].notes).toBe('First </script> & "quoted" / slashed\nline — ok');286	});287288	test("version.txt and checksums.txt", async () => {289		const version = await get("/cli/releases/latest/version.txt");290		expect([version.headers.get("Content-Type"), await version.text()]).toEqual([291			"text/plain; charset=UTF-8",292			"1.1.0",293		]);294		const sums = await (await get("/cli/releases/1.0.0/checksums.txt")).text();295		const sha = (text: string) => new Bun.CryptoHasher("sha256").update(text).digest("hex");296		expect(sums).toBe(297			`# 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`,298		);299	});300301	test("downloads count, then redirect to the stored file", async () => {302		const before = downloads().length;303		expect(before).toBe(0);304		const latest = await get("/cli/releases/latest/linux-x86_64");305		expect(latest.status).toBe(302);306		expect(latest.headers.get("Cache-Control")).toBe("no-store");307		expect(latest.headers.get("Location")).toBe(308			`file://${join(bucketDir, "cli/releases/1.1.0/linux-x86_64/cli")}`,309		);310		// The newest release that has the platform: 1.1.0 has no APK.311		expect((await get("/cli/releases/latest/android-arm64")).headers.get("Location")).toEndWith(312			"/cli/releases/1.0.0/android-arm64/cli-1.0.0.apk",313		);314		for (const path of [315			"/cli/releases/1.0.0/android-arm64",316			"/cli/releases/1.0.0/cli-android-arm64",317		]) {318			expect((await get(path)).status).toBe(302);319		}320		expect(downloads()).toEqual([321			{ version: "1.0.0", platform: "android-arm64", count: 3 },322			{ version: "1.1.0", platform: "linux-x86_64", count: 1 },323		]);324	});325326	test("a missing release is a 404 that counts nothing, and a private repo looks unknown", async () => {327		const before = downloads();328		for (const path of [329			"/cli/releases/1.0.0/macos-aarch64",330			"/cli/releases/9.9.9/linux-x86_64",331			"/cli/releases/1.0.0/plan9",332			"/cli/releases/latest/windows-x86_64",333			"/cli/releases/9.9.9/checksums.txt",334		]) {335			const res = await get(path);336			expect([path, res.status, await res.text()]).toEqual([337				path,338				404,339				'{"error":"not_found","message":"release not found"}',340			]);341		}342		for (const repo of ["hush", "nowhere"]) {343			for (const suffix of ["/releases/latest.json", "/releases/1.0.0/linux-x86_64", "/meta"]) {344				const res = await get(`/${repo}${suffix}`);345				expect([res.status, await res.text()]).toEqual([346					404,347					'{"error":"repo_not_found","message":"repo not found"}',348				]);349			}350		}351		expect(downloads()).toEqual(before);352	});353354	test("a hidden version leaves latest, the feeds and its downloads", async () => {355		kepr.run("INSERT INTO release_settings (repo, version, hidden) VALUES ('cli', '1.1.0', 1)");356		expect(await (await get("/cli/releases/latest/version.txt")).text()).toBe("1.0.0");357		expect(await (await get("/cli/releases/history.json")).text()).not.toContain("1.1.0");358		expect((await get("/cli/releases/1.1.0/linux-x86_64")).status).toBe(404);359		expect((await get("/cli/releases/latest/linux-x86_64")).headers.get("Location")).toEndWith(360			"/1.0.0/linux-x86_64/cli",361		);362		kepr.run("DELETE FROM release_settings");363	});364365	test("update-info's head is the head save even when it is a checkpoint", async () => {366		const info = (await (await get("/cli/update-info")).json()) as {367			head: { save_id: string; version: string };368			latest_build: { version: string };369		};370		expect([info.head.save_id, info.head.version]).toEqual([checkpoint, checkpoint]);371		expect(info.latest_build.version).toBe("1.1.0");372	});373374	test("the /api feeds send CORS on their 404s too", async () => {375		for (const path of ["/api/hush/releases/latest", "/api/nowhere/releases/history"]) {376			const res = await get(path);377			expect([res.status, res.headers.get("Access-Control-Allow-Origin")]).toEqual([404, "*"]);378		}379		expect(380			(await get("/hush/releases/latest.json")).headers.get("Access-Control-Allow-Origin"),381		).toBeNull();382	});383384	test("releases/latest sends old links to the builds panel", async () => {385		const res = await get("/cli/releases/latest");386		expect([res.status, res.headers.get("Location")]).toEqual([302, "/cli/releases"]);387		expect((await get("/hush/releases/latest")).status).toBe(404);388	});389390	test("a Nix fetch of a tarball counts as an install; redirects and other clients don't", async () => {391		const installs = () =>392			(393				kepr.query("SELECT count FROM installs WHERE repo = 'cli'").get() as {394					count: number;395				} | null396			)?.count ?? 0;397		const nix = { "User-Agent": "curl/8.7.1 Nix/2.24.0 nix/2.24.0" };398		const head = await get("/cli/archive/HEAD.tar.gz", nix);399		expect(installs()).toBe(0);400		const tarball = head.headers.get("Location") as string;401		expect((await get(tarball, nix)).status).toBe(200);402		expect((await get(tarball, { "User-Agent": "curl/8.7.1" })).status).toBe(200);403		expect(installs()).toBe(1);404	});405});406407test("release files are served with §22.2's content types", () => {408	expect(releaseContentType("peach-2.9.28-android-arm64.apk")).toBe(409		"application/vnd.android.package-archive",410	);411	expect(releaseContentType("Haven.app.ZIP")).toBe("application/zip");412	expect(releaseContentType("koh")).toBe("application/octet-stream");413});414415const releaseKepr = new WeakMap<Hono, Database>();416417/** Syncs a folder remote into a fresh data dir and serves it as kepr.example. */418async function releaseApp(bucketDir: string, setup: (kepr: Database) => void): Promise<Hono> {419	const dataDir = await mkdtemp(join(tmpdir(), "kepr-release-app-"));420	const kepr = openKeprDb(dataDir);421	const bucket = openBucket({ kind: "folder", path: bucketDir });422	await syncAll({423		bucket,424		kepr,425		mirrorDir: join(dataDir, "mirror"),426		tarballDir: join(dataDir, "tarballs"),427	});428	setup(kepr);429	const app = createApp(430		{431			domain: "kepr.example",432			dataDir,433			adminPasswordHash: "$argon2id$x",434			bucket: { kind: "folder", path: bucketDir },435		},436		kepr,437	);438	releaseKepr.set(app, kepr);439	return app;440}