1import type { Database } from "bun:sqlite";2import { beforeAll, describe, expect, test } from "bun:test";3import { existsSync } from "node:fs";4import { mkdtemp } from "node:fs/promises";5import { tmpdir } from "node:os";6import { join } from "node:path";7import type { Hono } from "hono";8import { openBucket } from "../src/bucket/reader.ts";9import type { ReleaseFile } from "../src/bucket/repo.ts";10import { syncAll } from "../src/bucket/sync.ts";11import { compareVersions } from "../src/releases/releases.ts";12import { createApp } from "../src/server/app.ts";13import { openKeprDb } from "../src/server/db.ts";14import { makeRepos, writeRelease } from "./fixture.ts";15161718192021const FIXTURES = join(import.meta.dir, "fixtures/kepr2");22const RELEASED = ["haven", "kepr", "koh", "nina", "peach-android"];23const ZERO = "0".repeat(64);2425type Recording = { status: number; headers: Map<string, string>; body: string };2627const slugOf = (path: string) => path.replace(/^\//, "").replaceAll("/", "_");2829async function recorded(path: string): Promise<Recording> {30 const slug = slugOf(path);31 const [statusLine, ...lines] = (await Bun.file(join(FIXTURES, `${slug}.headers`)).text())32 .trimEnd()33 .split("\n");34 const headers = new Map(35 lines.map((line) => {36 const at = line.indexOf(":");37 return [line.slice(0, at).toLowerCase(), line.slice(at + 1).trim()];38 }),39 );40 const body = Bun.file(join(FIXTURES, `${slug}.body`));41 return {42 status: Number((statusLine as string).split(" ")[1]),43 headers,44 body: (await body.exists()) ? await body.text() : "",45 };46}4748type Latest = { version: string; notes?: string; files: ReleaseFile[] };495051525354async function publishRecorded(bucketDir: string, repo: string, save: string): Promise<Latest> {55 const version = (await recorded(`/${repo}/releases/latest/version.txt`)).body;56 const { notes } = JSON.parse((await recorded(`/api/${repo}/releases/latest`)).body);57 const sums = (await recorded(`/${repo}/releases/${version}/checksums.txt`)).body;58 const hash = (algorithm: string, platform: string) =>59 new RegExp(`^${algorithm}:([0-9a-f]{64}) ${repo}-${platform}$`, "m").exec(sums)?.[1] as string;60 const platforms = [...sums.matchAll(new RegExp(`^sha256:[0-9a-f]{64} ${repo}-(.+)$`, "gm"))].map(61 (m) => m[1] as string,62 );63 64 65 const downloaded = platforms.find((p) =>66 existsSync(join(FIXTURES, `${slugOf(`/${repo}/releases/${version}/${p}`)}.headers`)),67 ) as string;68 const download = await recorded(`/${repo}/releases/${version}/${downloaded}`);69 const name = /filename="([^"]+)"/.exec(download.headers.get("content-disposition") ?? "")?.[1];70 71 const files = [downloaded, ...platforms.filter((p) => p !== downloaded)].map((platform, i) => ({72 platform,73 name: i === 0 && name ? name : `${repo}-${platform}`,74 size: 1,75 blake3: hash("blake3", platform),76 sha256: hash("sha256", platform),77 }));78 const history = await recorded(`/api/${repo}/releases/history`);79 const older = history.status === 200 ? (JSON.parse(history.body) as Latest[]) : [];80 for (const old of older.filter((h) => h.version !== version)) {81 const standIn = [82 { platform: "linux-x86_64", name: "stand-in", size: 1, blake3: ZERO, sha256: ZERO },83 ];84 await writeRelease(bucketDir, repo, { ...old, save, files: standIn }, false);85 }86 await writeRelease(bucketDir, repo, { version, save, notes: notes ?? "", files });87 return { version, notes, files };88}8990async function serve(bucketDir: string, setup: (kepr: Database) => void): Promise<Hono> {91 const dataDir = await mkdtemp(join(tmpdir(), "kepr-contract-"));92 const kepr = openKeprDb(dataDir);93 await syncAll({94 bucket: openBucket({ kind: "folder", path: bucketDir }),95 kepr,96 mirrorDir: join(dataDir, "mirror"),97 tarballDir: join(dataDir, "tarballs"),98 });99 setup(kepr);100 const config = {101 domain: "kepr.uk",102 dataDir,103 adminPasswordHash: "$argon2id$x",104 bucket: { kind: "folder", path: bucketDir } as const,105 };106 return createApp(config, kepr);107}108109function expectHeaders(res: Response, recording: Recording, names: string[]): void {110 for (const name of names) {111 expect([name, res.headers.get(name)]).toEqual([name, recording.headers.get(name) ?? null]);112 }113}114115describe("Kepr 2's generic release routes", () => {116 let app: Hono;117 let bucketDir: string;118 let saves: Record<string, string>;119 const latest = new Map<string, Latest>();120 const get = (path: string) => app.request(path);121122 beforeAll(async () => {123 const repos = await makeRepos([...RELEASED, "peach-relay"]);124 bucketDir = repos.bucketDir;125 saves = repos.saves;126 for (const repo of RELEASED) {127 latest.set(repo, await publishRecorded(bucketDir, repo, saves[repo] as string));128 }129 app = await serve(bucketDir, (kepr) => {130 kepr.run("UPDATE repos SET public = 1");131 kepr.run("UPDATE repos SET category = 'utility' WHERE name IN ('haven', 'peach-relay')");132 });133 });134135 136 test.each(RELEASED.filter((r) => r !== "koh"))(137 "/api/%s/releases/latest is byte for byte Kepr 2's, with CORS",138 async (repo) => {139 const path = `/api/${repo}/releases/latest`;140 const [res, rec] = [await get(path), await recorded(path)];141 expect(res.status).toBe(rec.status);142 expectHeaders(res, rec, ["content-type", "access-control-allow-origin"]);143 expect(await res.text()).toBe(rec.body);144 },145 );146147 148 149 test.each(RELEASED.filter((r) => r !== "koh"))(150 "/api/%s/releases/history has Kepr 2's entries, newest version first",151 async (repo) => {152 const path = `/api/${repo}/releases/history`;153 const [res, rec] = [await get(path), await recorded(path)];154 expectHeaders(res, rec, ["content-type", "access-control-allow-origin"]);155 const entries = rec.body.slice(1, -1).split(/,(?=\{"version":)/);156 const version = (entry: string) => (JSON.parse(entry) as { version: string }).version;157 entries.sort((a, b) => compareVersions(version(b), version(a)));158 expect(await res.text()).toBe(`[${entries.join(",")}]`);159 expect(await (await get(`/${repo}/releases/history.json`)).text()).toBe(160 `[${entries.join(",")}]`,161 );162 },163 );164165 test.each(RELEASED)(166 "/%s/releases/latest/version.txt and checksums.txt are Kepr 2's",167 async (repo) => {168 for (const path of [169 `/${repo}/releases/latest/version.txt`,170 `/${repo}/releases/${latest.get(repo)?.version}/checksums.txt`,171 ]) {172 const [res, rec] = [await get(path), await recorded(path)];173 expect([path, res.status]).toEqual([path, rec.status]);174 expectHeaders(res, rec, ["content-type"]);175 expect(await res.text()).toBe(rec.body);176 }177 },178 );179180 181 182 test.each([...RELEASED, "peach-relay"])(183 "/%s/update-info keeps Kepr 2's latest_build",184 async (repo) => {185 const path = `/${repo}/update-info`;186 const [res, rec] = [await get(path), await recorded(path)];187 expectHeaders(res, rec, ["content-type"]);188 const body = await res.text();189 const build = (text: string) => text.slice(text.indexOf(',"latest_build":'));190 expect(build(body)).toBe(build(rec.body));191 expect(rec.body.startsWith('{"head":null,')).toBe(true);192 const head = JSON.parse(body).head;193 expect(Object.keys(head)).toEqual(["save_id", "version", "timestamp"]);194 195 const version = latest.get(repo)?.version ?? saves[repo];196 expect([head.save_id, head.version]).toEqual([saves[repo], version]);197 },198 );199200 201 202 test.each([...RELEASED, "peach-relay"])("/%s/meta has Kepr 2's fields in order", async (repo) => {203 const path = `/${repo}/meta`;204 const [res, rec] = [await get(path), await recorded(path)];205 expectHeaders(res, rec, ["content-type"]);206 type Meta = Record<string, unknown>;207 const [ours, theirs] = [(await res.json()) as Meta, JSON.parse(rec.body) as Meta];208 expect(Object.keys(ours)).toEqual(Object.keys(theirs));209 210 for (const key of Object.keys(theirs)) {211 const type = (value: unknown) => (value === null ? "string" : typeof value);212 expect([key, type(ours[key])]).toEqual([key, type(theirs[key])]);213 }214 expect(ours.latest_release).toBe(theirs.latest_release);215 expect(ours.latest_version).toBe(theirs.latest_release);216 expect(ours.description).toBe(`The ${repo} project.`);217 expect([ours.save_count, ours.repo]).toEqual([1, repo]);218 if (repo === "haven" || repo === "peach-relay") expect(ours.categories).toBe(theirs.categories);219 });220221 222 test.each(RELEASED)("/%s/releases/latest redirects to the builds panel", async (repo) => {223 const path = `/${repo}/releases/latest`;224 const [res, rec] = [await get(path), await recorded(path)];225 expect(res.status).toBe(rec.status);226 expect(rec.headers.get("location")).toBe(`/${repo}/releases/${latest.get(repo)?.version}`);227 expect(res.headers.get("location")).toBe(`/${repo}/releases`);228 });229230 231 test.each(RELEASED)(232 "/%s/releases/<version>/<platform> redirects to the stored file",233 async (repo) => {234 const release = latest.get(repo) as Latest;235 const platform = release.files[0]?.platform as string;236 for (const tail of [platform, `${repo}-${platform}`]) {237 const path = `/${repo}/releases/${release.version}/${tail}`;238 const [res, rec] = [await get(path), await recorded(path)];239 expect(rec.status).toBe(200);240 const name = /filename="([^"]+)"/.exec(rec.headers.get("content-disposition") ?? "")?.[1];241 expect(res.status).toBe(302);242 expect(res.headers.get("location")).toBe(243 `file://${join(bucketDir, repo, "releases", release.version, platform, name as string)}`,244 );245 }246 },247 );248249 test("the 404s of a repo without releases, an unknown repo and a missing Koh 2 file are Kepr 2's", async () => {250 for (const path of [251 "/api/peach-relay/releases/latest",252 "/api/peach-relay/releases/history",253 "/api/nowhere/releases/latest",254 "/api/koh/releases/2.1.16/linux-x86_64",255 ]) {256 const [res, rec] = [await get(path), await recorded(path)];257 expect([path, res.status, await res.text()]).toEqual([path, rec.status, rec.body]);258 expectHeaders(res, rec, ["content-type"]);259 260 const cors = path.startsWith("/api/koh/") ? null : "*";261 expect([path, res.headers.get("access-control-allow-origin")]).toEqual([path, cors]);262 }263 });264});265266describe("the frozen Koh 2 route", () => {267 let app: Hono;268 let bucketDir: string;269 const get = (path: string) => app.request(path);270 const FROZEN = "2.1.16";271272 beforeAll(async () => {273 const repos = await makeRepos(["koh"]);274 bucketDir = repos.bucketDir;275 const save = repos.saves.koh as string;276 277 const rec = JSON.parse(278 (await recorded("/api/koh/releases/latest")).body.replaceAll("2.1.0", FROZEN),279 );280 const sums = (await recorded("/koh/releases/2.1.0/checksums.txt")).body;281 const files = Object.entries(rec.platforms as Record<string, { checksum: string }>).map(282 ([platform, { checksum }]) => ({283 platform,284 name: "koh",285 size: 1,286 blake3: checksum.slice("blake3:".length),287 sha256: new RegExp(`sha256:([0-9a-f]{64}) koh-${platform}`).exec(sums)?.[1] as string,288 }),289 );290 await writeRelease(bucketDir, "koh", { version: FROZEN, save, notes: rec.notes, files });291 const newer = [{ platform: "linux-x86_64", name: "koh", size: 1, blake3: ZERO, sha256: ZERO }];292 await writeRelease(bucketDir, "koh", { version: "3.0.0", save, files: newer });293 app = await serve(bucketDir, (kepr) => kepr.run("UPDATE repos SET public = 1"));294 });295296 297 298 test("serves Kepr 2's legacy shape for 2.1.16, never the newest release", async () => {299 const res = await get("/api/koh/releases/latest");300 const rec = await recorded("/api/koh/releases/latest");301 expect(res.status).toBe(200);302 expectHeaders(res, rec, ["content-type", "access-control-allow-origin"]);303 type Legacy = { platforms: Record<string, { provenance: object }> };304 const ours = (await res.json()) as Legacy;305 const theirs = JSON.parse(rec.body.replaceAll("2.1.0", FROZEN)) as Legacy;306 expect(ours).toEqual(theirs);307 expect(Object.keys(ours)).toEqual(Object.keys(theirs));308 for (const [platform, entry] of Object.entries(theirs.platforms)) {309 const mine = ours.platforms[platform] as { provenance: object };310 expect(Object.keys(mine)).toEqual(Object.keys(entry));311 expect(Object.keys(mine.provenance)).toEqual(Object.keys(entry.provenance));312 }313 expect(await (await get("/koh/releases/latest/version.txt")).text()).toBe("3.0.0");314 });315316 test("its download URLs redirect to the file, and without 2.1.16 it has nothing to offer", async () => {317 const res = await get(`/api/koh/releases/${FROZEN}/macos-aarch64`);318 expect(res.status).toBe(302);319 expect(res.headers.get("location")).toEndWith(`/koh/releases/${FROZEN}/macos-aarch64/koh`);320 app = await serve(bucketDir, (kepr) => {321 kepr.run("UPDATE repos SET public = 1");322 kepr.run(323 `INSERT INTO release_settings (repo, version, hidden) VALUES ('koh', '${FROZEN}', 1)`,324 );325 });326 const hidden = await get("/api/koh/releases/latest");327 expect([hidden.status, await hidden.text()]).toEqual([328 404,329 '{"error":"no_releases","message":"no releases available"}',330 ]);331 });332});