contract.test.ts

14.2 KB · raw

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";1516// §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.2021const 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[] };4950/**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 */54async 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}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	});98	setup(kepr);99	const config = {100		domain: "kepr.uk",101		dataDir,102		adminPasswordHash: "$argon2id$x",103		bucket: { kind: "folder", path: bucketDir } as const,104	};105	return createApp(config, kepr);106}107108function expectHeaders(res: Response, recording: Recording, names: string[]): void {109	for (const name of names) {110		expect([name, res.headers.get(name)]).toEqual([name, recording.headers.get(name) ?? null]);111	}112}113114describe("Kepr 2's generic release routes", () => {115	let app: Hono;116	let bucketDir: string;117	let saves: Record<string, string>;118	const latest = new Map<string, Latest>();119	const get = (path: string) => app.request(path);120121	beforeAll(async () => {122		const repos = await makeRepos([...RELEASED, "peach-relay"]);123		bucketDir = repos.bucketDir;124		saves = repos.saves;125		for (const repo of RELEASED) {126			latest.set(repo, await publishRecorded(bucketDir, repo, saves[repo] as string));127		}128		app = await serve(bucketDir, (kepr) => {129			kepr.run("UPDATE repos SET public = 1");130			kepr.run("UPDATE repos SET category = 'utility' WHERE name IN ('haven', 'peach-relay')");131		});132	});133134	// koh's /api route is the frozen Koh 2 feed, tested on its own below.135	test.each(RELEASED.filter((r) => r !== "koh"))(136		"/api/%s/releases/latest is byte for byte Kepr 2's, with CORS",137		async (repo) => {138			const path = `/api/${repo}/releases/latest`;139			const [res, rec] = [await get(path), await recorded(path)];140			expect(res.status).toBe(rec.status);141			expectHeaders(res, rec, ["content-type", "access-control-allow-origin"]);142			expect(await res.text()).toBe(rec.body);143		},144	);145146	// Kepr 2 ordered history by build time; §21.9 and §22.1 order it by version number, so the147	// recorded entries are compared byte for byte in that order.148	test.each(RELEASED.filter((r) => r !== "koh"))(149		"/api/%s/releases/history has Kepr 2's entries, newest version first",150		async (repo) => {151			const path = `/api/${repo}/releases/history`;152			const [res, rec] = [await get(path), await recorded(path)];153			expectHeaders(res, rec, ["content-type", "access-control-allow-origin"]);154			const entries = rec.body.slice(1, -1).split(/,(?=\{"version":)/);155			const version = (entry: string) => (JSON.parse(entry) as { version: string }).version;156			entries.sort((a, b) => compareVersions(version(b), version(a)));157			expect(await res.text()).toBe(`[${entries.join(",")}]`);158			expect(await (await get(`/${repo}/releases/history.json`)).text()).toBe(159				`[${entries.join(",")}]`,160			);161		},162	);163164	test.each(RELEASED)(165		"/%s/releases/latest/version.txt and checksums.txt are Kepr 2's",166		async (repo) => {167			for (const path of [168				`/${repo}/releases/latest/version.txt`,169				`/${repo}/releases/${latest.get(repo)?.version}/checksums.txt`,170			]) {171				const [res, rec] = [await get(path), await recorded(path)];172				expect([path, res.status]).toEqual([path, rec.status]);173				expectHeaders(res, rec, ["content-type"]);174				expect(await res.text()).toBe(rec.body);175			}176		},177	);178179	// latest_build is Kepr 2's to the byte. Kepr 2's head was always null (it looked for a `main`180	// lane that Koh 2 offers never set); §22.4 makes it the newest save on the line.181	test.each([...RELEASED, "peach-relay"])(182		"/%s/update-info keeps Kepr 2's latest_build",183		async (repo) => {184			const path = `/${repo}/update-info`;185			const [res, rec] = [await get(path), await recorded(path)];186			expectHeaders(res, rec, ["content-type"]);187			const body = await res.text();188			const build = (text: string) => text.slice(text.indexOf(',"latest_build":'));189			expect(build(body)).toBe(build(rec.body));190			expect(rec.body.startsWith('{"head":null,')).toBe(true);191			const head = JSON.parse(body).head;192			expect(Object.keys(head)).toEqual(["save_id", "version", "timestamp"]);193			// The fixture's releases are built from its only save, so head's version is the newest release.194			const version = latest.get(repo)?.version ?? saves[repo];195			expect([head.save_id, head.version]).toEqual([saves[repo], version]);196		},197	);198199	// The fields, their order and their types are Kepr 2's. The values are this fixture's, except200	// latest_version, which §22.4 makes the newest release (Kepr 2 read a "v…" from save messages).201	test.each([...RELEASED, "peach-relay"])("/%s/meta has Kepr 2's fields in order", async (repo) => {202		const path = `/${repo}/meta`;203		const [res, rec] = [await get(path), await recorded(path)];204		expectHeaders(res, rec, ["content-type"]);205		type Meta = Record<string, unknown>;206		const [ours, theirs] = [(await res.json()) as Meta, JSON.parse(rec.body) as Meta];207		expect(Object.keys(ours)).toEqual(Object.keys(theirs));208		// Counts are numbers; every other field is a string or, as Kepr 2 also sent, null.209		for (const key of Object.keys(theirs)) {210			const type = (value: unknown) => (value === null ? "string" : typeof value);211			expect([key, type(ours[key])]).toEqual([key, type(theirs[key])]);212		}213		expect(ours.latest_release).toBe(theirs.latest_release);214		expect(ours.latest_version).toBe(theirs.latest_release);215		expect(ours.description).toBe(`The ${repo} project.`);216		expect([ours.save_count, ours.repo]).toEqual([1, repo]);217		if (repo === "haven" || repo === "peach-relay") expect(ours.categories).toBe(theirs.categories);218	});219220	// §22.4 sends old links to the builds panel, where Kepr 2 went to the version's page.221	test.each(RELEASED)("/%s/releases/latest redirects to the builds panel", async (repo) => {222		const path = `/${repo}/releases/latest`;223		const [res, rec] = [await get(path), await recorded(path)];224		expect(res.status).toBe(rec.status);225		expect(rec.headers.get("location")).toBe(`/${repo}/releases/${latest.get(repo)?.version}`);226		expect(res.headers.get("location")).toBe(`/${repo}/releases`);227	});228229	// Kepr 2 streamed the file itself; §22.2 redirects to the stored file, named as Kepr 2 named it.230	test.each(RELEASED)(231		"/%s/releases/<version>/<platform> redirects to the stored file",232		async (repo) => {233			const release = latest.get(repo) as Latest;234			const platform = release.files[0]?.platform as string;235			for (const tail of [platform, `${repo}-${platform}`]) {236				const path = `/${repo}/releases/${release.version}/${tail}`;237				const [res, rec] = [await get(path), await recorded(path)];238				expect(rec.status).toBe(200);239				const name = /filename="([^"]+)"/.exec(rec.headers.get("content-disposition") ?? "")?.[1];240				expect(res.status).toBe(302);241				expect(res.headers.get("location")).toBe(242					`file://${join(bucketDir, repo, "releases", release.version, platform, name as string)}`,243				);244			}245		},246	);247248	test("the 404s of a repo without releases, an unknown repo and a missing Koh 2 file are Kepr 2's", async () => {249		for (const path of [250			"/api/peach-relay/releases/latest",251			"/api/peach-relay/releases/history",252			"/api/nowhere/releases/latest",253			"/api/koh/releases/2.1.16/linux-x86_64",254		]) {255			const [res, rec] = [await get(path), await recorded(path)];256			expect([path, res.status, await res.text()]).toEqual([path, rec.status, rec.body]);257			expectHeaders(res, rec, ["content-type"]);258			// Kepr 2 sent CORS only on success; §22.4 puts it on the /api feed routes, so it wins.259			const cors = path.startsWith("/api/koh/") ? null : "*";260			expect([path, res.headers.get("access-control-allow-origin")]).toEqual([path, cors]);261		}262	});263});264265describe("the frozen Koh 2 route", () => {266	let app: Hono;267	let bucketDir: string;268	const get = (path: string) => app.request(path);269	const FROZEN = "2.1.16";270271	beforeAll(async () => {272		const repos = await makeRepos(["koh"]);273		bucketDir = repos.bucketDir;274		const save = repos.saves.koh as string;275		// Kepr 2 never had a 2.1.16 release, so the recorded 2.1.0 stands in for it under the pinned version.276		const rec = JSON.parse(277			(await recorded("/api/koh/releases/latest")).body.replaceAll("2.1.0", FROZEN),278		);279		const sums = (await recorded("/koh/releases/2.1.0/checksums.txt")).body;280		const files = Object.entries(rec.platforms as Record<string, { checksum: string }>).map(281			([platform, { checksum }]) => ({282				platform,283				name: "koh",284				size: 1,285				blake3: checksum.slice("blake3:".length),286				sha256: new RegExp(`sha256:([0-9a-f]{64})  koh-${platform}`).exec(sums)?.[1] as string,287			}),288		);289		await writeRelease(bucketDir, "koh", { version: FROZEN, save, notes: rec.notes, files });290		const newer = [{ platform: "linux-x86_64", name: "koh", size: 1, blake3: ZERO, sha256: ZERO }];291		await writeRelease(bucketDir, "koh", { version: "3.0.0", save, files: newer });292		app = await serve(bucketDir, (kepr) => kepr.run("UPDATE repos SET public = 1"));293	});294295	// Kepr 2 listed platforms by build time; Kepr 3 lists them by name, as the generic shape always296	// did. Koh 2 reads them by key, so the object is compared as parsed, and every field order exactly.297	test("always returns the frozen Koh 2 no-releases body", async () => {298		const res = await get("/api/koh/releases/latest");299		expect(res.status).toBe(404);300		expect(await res.text()).toBe('{"error":"no_releases","message":"no releases available"}');301		expect(await (await get("/koh/releases/latest/version.txt")).text()).toBe("3.0.0");302	});303304	test("returns the frozen Koh 2 no-releases body when 2.1.16 is unavailable", async () => {305		app = await serve(bucketDir, (kepr) => {306			kepr.run("UPDATE repos SET public = 1");307			kepr.run(308				`INSERT INTO release_settings (repo, version, hidden) VALUES ('koh', '${FROZEN}', 1)`,309			);310		});311		const res = await get("/api/koh/releases/latest");312		expect(res.status).toBe(404);313		// Required compatibility body: Koh 2's updater parses this exact JSON response.314		expect(await res.text()).toBe('{"error":"no_releases","message":"no releases available"}');315	});316317	test("its download URLs redirect to the file", async () => {318		app = await serve(bucketDir, (kepr) => kepr.run("UPDATE repos SET public = 1"));319		const res = await get(`/api/koh/releases/${FROZEN}/macos-aarch64`);320		expect(res.status).toBe(302);321		expect(res.headers.get("location")).toEndWith(`/koh/releases/${FROZEN}/macos-aarch64/koh`);322	});323});