075f6d740acc
test every recorded kepr 2 route against kepr 3: byte-exact feeds, history, version.txt, checksums and 404s, and the shape where the spec makes kepr 3 differ, with the frozen koh 2 route on 2.1.16 (v3.0.23)
- time
- 2026-09-26 18:54 UTC · 2 weeks ago
- machine
- Junes-MacBook-Pro.local
- agent
- claude-opus-5-5
- session
- kepr 3 implementation
- parent
- 0f7e06f66cce
↓ kepr-075f6d74.tar.gzatlas at this save
files changed2 files
package.json+1 −1tests/contract.test.ts+329 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.22",3+ "version": "3.0.23",44 "private": true,55 "type": "module",66 "scripts": {tests/contract.test.ts+329 −0
@@ -0,0 +1,329 @@1+import type { Database } from "bun:sqlite";2+import { beforeAll, describe, expect, test } from "bun:test";3+import { existsSync } from "node:fs";4+import { mkdtemp } from "node:fs/promises";5+import { tmpdir } from "node:os";6+import { join } from "node:path";7+import type { Hono } from "hono";8+import { openBucket } from "../src/bucket/reader.ts";9+import type { ReleaseFile } from "../src/bucket/repo.ts";10+import { syncAll } from "../src/bucket/sync.ts";11+import { compareVersions } from "../src/releases/releases.ts";12+import { createApp } from "../src/server/app.ts";13+import { openKeprDb } from "../src/server/db.ts";14+import { makeRepos, writeRelease } from "./fixture.ts";15+16+// §22.4's contract: Kepr 3 answers every route recorded from kepr.uk (tests/fixtures/kepr2, made by17+// record.sh) with the recorded bytes, except where the spec makes Kepr 3 differ; each such case says18+// why and asserts the shape instead. The bucket is rebuilt from the recordings: each release.json19+// carries the recorded version, notes, platforms, file names and hashes.20+21+const FIXTURES = join(import.meta.dir, "fixtures/kepr2");22+const RELEASED = ["haven", "kepr", "koh", "nina", "peach-android"];23+const ZERO = "0".repeat(64);24+25+type Recording = { status: number; headers: Map<string, string>; body: string };26+27+const slugOf = (path: string) => path.replace(/^\//, "").replaceAll("/", "_");28+29+async 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+}47+48+type Latest = { version: string; notes?: string; files: ReleaseFile[] };49+50+/**51+ * Writes a repo's recorded releases into the bucket: the newest in full (version.txt, its notes and52+ * checksums.txt's hashes), older ones by their history notes. Kepr 2 had no history route for koh.53+ */54+async 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+ // A file's stored name is the filename Kepr 2's download of it carried; one download per repo64+ // was recorded (record.sh takes the first platform of the /api feed).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+ // The recorded download comes first, so the redirect test below can find its name.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+}89+90+async 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+}108+109+function 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+}114+115+describe("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);121+122+ 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+ });134+135+ // koh's /api route is the frozen Koh 2 feed, tested on its own below.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+ );146+147+ // Kepr 2 ordered history by build time; §21.9 and §22.1 order it by version number, so the148+ // recorded entries are compared byte for byte in that order.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+ );164+165+ 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+ );179+180+ // latest_build is Kepr 2's to the byte. Kepr 2's head was always null (it looked for a `main`181+ // lane that Koh 2 offers never set); §22.4 makes it the newest save on the line.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+ // The fixture's releases are built from its only save, so head's version is the newest release.195+ const version = latest.get(repo)?.version ?? saves[repo];196+ expect([head.save_id, head.version]).toEqual([saves[repo], version]);197+ },198+ );199+200+ // The fields, their order and their types are Kepr 2's. The values are this fixture's, except201+ // latest_version, which §22.4 makes the newest release (Kepr 2 read a "v…" from save messages).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+ // Counts are numbers; every other field is a string or, as Kepr 2 also sent, null.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+ });220+221+ // §22.4 sends old links to the builds panel, where Kepr 2 went to the version's page.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+ });229+230+ // Kepr 2 streamed the file itself; §22.2 redirects to the stored file, named as Kepr 2 named it.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+ );248+249+ 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", "access-control-allow-origin"]);259+ }260+ });261+});262+263+describe("the frozen Koh 2 route", () => {264+ let app: Hono;265+ let bucketDir: string;266+ const get = (path: string) => app.request(path);267+ const FROZEN = "2.1.16";268+269+ beforeAll(async () => {270+ const repos = await makeRepos(["koh"]);271+ bucketDir = repos.bucketDir;272+ const save = repos.saves.koh as string;273+ // Kepr 2 never had a 2.1.16 release, so the recorded 2.1.0 stands in for it under the pinned version.274+ const rec = JSON.parse(275+ (await recorded("/api/koh/releases/latest")).body.replaceAll("2.1.0", FROZEN),276+ );277+ const sums = (await recorded("/koh/releases/2.1.0/checksums.txt")).body;278+ const files = Object.entries(rec.platforms as Record<string, { checksum: string }>).map(279+ ([platform, { checksum }]) => ({280+ platform,281+ name: "koh",282+ size: 1,283+ blake3: checksum.slice("blake3:".length),284+ sha256: new RegExp(`sha256:([0-9a-f]{64}) koh-${platform}`).exec(sums)?.[1] as string,285+ }),286+ );287+ await writeRelease(bucketDir, "koh", { version: FROZEN, save, notes: rec.notes, files });288+ const newer = [{ platform: "linux-x86_64", name: "koh", size: 1, blake3: ZERO, sha256: ZERO }];289+ await writeRelease(bucketDir, "koh", { version: "3.0.0", save, files: newer });290+ app = await serve(bucketDir, (kepr) => kepr.run("UPDATE repos SET public = 1"));291+ });292+293+ // Kepr 2 listed platforms by build time; Kepr 3 lists them by name, as the generic shape always294+ // did. Koh 2 reads them by key, so the object is compared as parsed, and every field order exactly.295+ test("serves Kepr 2's legacy shape for 2.1.16, never the newest release", async () => {296+ const res = await get("/api/koh/releases/latest");297+ const rec = await recorded("/api/koh/releases/latest");298+ expect(res.status).toBe(200);299+ expectHeaders(res, rec, ["content-type", "access-control-allow-origin"]);300+ type Legacy = { platforms: Record<string, { provenance: object }> };301+ const ours = (await res.json()) as Legacy;302+ const theirs = JSON.parse(rec.body.replaceAll("2.1.0", FROZEN)) as Legacy;303+ expect(ours).toEqual(theirs);304+ expect(Object.keys(ours)).toEqual(Object.keys(theirs));305+ for (const [platform, entry] of Object.entries(theirs.platforms)) {306+ const mine = ours.platforms[platform] as { provenance: object };307+ expect(Object.keys(mine)).toEqual(Object.keys(entry));308+ expect(Object.keys(mine.provenance)).toEqual(Object.keys(entry.provenance));309+ }310+ expect(await (await get("/koh/releases/latest/version.txt")).text()).toBe("3.0.0");311+ });312+313+ test("its download URLs redirect to the file, and without 2.1.16 it has nothing to offer", async () => {314+ const res = await get(`/api/koh/releases/${FROZEN}/macos-aarch64`);315+ expect(res.status).toBe(302);316+ expect(res.headers.get("location")).toEndWith(`/koh/releases/${FROZEN}/macos-aarch64/koh`);317+ app = await serve(bucketDir, (kepr) => {318+ kepr.run("UPDATE repos SET public = 1");319+ kepr.run(320+ `INSERT INTO release_settings (repo, version, hidden) VALUES ('koh', '${FROZEN}', 1)`,321+ );322+ });323+ const hidden = await get("/api/koh/releases/latest");324+ expect([hidden.status, await hidden.text()]).toEqual([325+ 404,326+ '{"error":"no_releases","message":"no releases available"}',327+ ]);328+ });329+});