kohremote.test.ts

8.5 KB · raw

1// Covers addendum A2 part 2: Koh's own remote layout, served read-only from a public repo's mirror.2import { describe, expect, test } from "bun:test";3import { join } from "node:path";4import { inflateSync } from "node:zlib";5import { blake3 } from "@noble/hashes/blake3.js";6import { bytesToHex } from "@noble/hashes/utils.js";7import { openBucket } from "../src/bucket/reader.ts";8import { syncAll } from "../src/bucket/sync.ts";9import { instance } from "./browser.ts";10import { makeKohBucket } from "./fixture.ts";1112const REPO = "mirror";13const NOT_FOUND = '{"error":"repo_not_found","message":"repo not found"}';14const fixture = await makeKohBucket(REPO);15const inst = await instance(fixture.bucketDir);16inst.kepr.run("UPDATE repos SET public = 1");1718const head = fixture.saveIds[0] ?? "";19const checkpoint = fixture.saveIds[1] ?? "";20const oldest = fixture.saveIds[fixture.saveIds.length - 1] ?? "";21const get = (path: string) => inst.app.request(`/${REPO}/koh/${path}`);22const record = async (id: string) => JSON.parse(await (await get(`saves/${id}`)).text());2324async function resync() {25	await syncAll({26		bucket: openBucket(inst.config.bucket),27		kepr: inst.kepr,28		mirrorDir: join(inst.dataDir, "mirror"),29	});30}3132describe.serial("Koh's remote layout from the mirror", () => {33	test("the format, the head and the caches the addendum names", async () => {34		const format = await get("format");35		expect(format.status).toBe(200);36		expect(await format.text()).toBe("koh-remote 1");37		expect(format.headers.get("cache-control")).toBe("private, max-age=86400");38		const headResponse = await get("head");39		expect(headResponse.status).toBe(200);40		expect(await headResponse.text()).toBe(head);41		expect(headResponse.headers.get("cache-control")).toBe("no-cache");42	});4344	test("a save record is Koh's, field for field, and checkpoints are part of the chain", async () => {45		const response = await get(`saves/${head}`);46		expect(response.status).toBe(200);47		expect(response.headers.get("cache-control")).toBe("private, max-age=60");48		const save = await record(head);49		expect(Object.keys(save)).toEqual([50			"id",51			"parent",52			"message",53			"created_at",54			"machine",55			"is_checkpoint",56			"size_bytes",57			"agent",58			"session",59			"restores",60			"replayed_from",61			"files",62		]);63		expect(save.id).toBe(head);64		expect(save.parent).toBe(checkpoint);65		// The mirror only keeps columns, so this is the proof that what it re-serves is what Koh66		// wrote, byte shape and all: empty optionals as "", booleans as booleans, no extra member.67		const written = await Bun.file(join(fixture.bucketDir, REPO, "saves", head)).json();68		expect(save).toEqual(written);69		expect(typeof save.size_bytes).toBe("number");70		expect(typeof save.is_checkpoint).toBe("boolean");71		expect(save.agent).toBe("");72		expect(Object.keys(save.files[0])).toEqual(["path", "hash", "executable"]);73		// The chain needs checkpoints, so these routes serve what the pages hide.74		const chain = await get(`saves/${checkpoint}`);75		expect(chain.status).toBe(200);76		expect((await record(checkpoint)).is_checkpoint).toBe(true);77		expect((await inst.app.request(`/${REPO}/saves/${checkpoint}`)).status).toBe(404);78	});7980	test("the save and object prefixes list what a steal walks", async () => {81		const saves = await get("saves");82		expect(saves.status).toBe(200);83		expect(saves.headers.get("cache-control")).toBe("no-cache");84		expect((await saves.text()).trim().split("\n")).toContain(head);85		const save = await record(head);86		const objects = await get("objects");87		expect(objects.status).toBe(200);88		expect(objects.headers.get("cache-control")).toBe("no-cache");89		expect((await objects.text()).trim().split("\n")).toContain(save.files[0].hash);90	});9192	test("an object is the stored zlib bytes and hashes back to its key", async () => {93		const save = await record(head);94		const file = save.files.find((entry: { path: string }) => entry.path === "src/main.ts");95		const object = await get(`objects/${file.hash}`);96		expect(object.status).toBe(200);97		expect(object.headers.get("cache-control")).toBe("private, max-age=31536000");98		expect(object.headers.get("cache-control")).not.toContain("immutable");99		const bytes = new Uint8Array(await object.arrayBuffer());100		expect(bytesToHex(blake3(inflateSync(bytes)))).toBe(file.hash);101		expect(new TextDecoder().decode(inflateSync(bytes))).toContain("hello");102	});103104	test("the events are IDs, one per line, and one event is Koh's record", async () => {105		const events = await get("events");106		expect(events.status).toBe(200);107		expect(events.headers.get("cache-control")).toBe("no-cache");108		const ids = (await events.text()).trim().split("\n");109		expect(ids.length).toBeGreaterThan(0);110		const one = await get(`events/${ids[0]}`);111		expect(one.status).toBe(200);112		expect(one.headers.get("cache-control")).toBe("private, max-age=86400");113		const event = JSON.parse(await one.text());114		expect(Object.keys(event)).toEqual([115			"id",116			"kind",117			"save_id",118			"path",119			"reason",120			"created_at",121			"machine",122		]);123		const written = await Bun.file(join(fixture.bucketDir, REPO, "events", ids[0] ?? "")).json();124		expect(event).toEqual(written);125	});126127	test("the bundle holds every save, event and object, byte for byte as the single routes", async () => {128		const response = await get("bundle");129		expect(response.status).toBe(200);130		expect(response.headers.get("cache-control")).toBe("no-cache");131		const body = new Uint8Array(await response.arrayBuffer());132		const entries = new Map<string, Uint8Array>();133		let at = 0;134		while (at < body.length) {135			const newline = body.indexOf(10, at);136			const [key = "", length = ""] = new TextDecoder()137				.decode(body.subarray(at, newline))138				.split(" ");139			at = newline + 1 + Number(length);140			entries.set(key, body.subarray(newline + 1, at));141		}142		const listed = async (prefix: string) =>143			(await (await get(prefix)).text())144				.trim()145				.split("\n")146				.map((id) => `${prefix}/${id}`);147		const keys = [148			...(await listed("saves")),149			...(await listed("events")),150			...(await listed("objects")),151		];152		expect(new TextDecoder().decode(body.subarray(body.length - 6))).toBe("end 0\n");153		entries.delete("end");154		expect([...entries.keys()].sort()).toEqual(keys.sort());155		for (const key of keys) {156			const single = new Uint8Array(await (await get(key)).arrayBuffer());157			expect(entries.get(key)).toEqual(single);158		}159	});160161	test("two bundles read at once each hold the whole repo", async () => {162		const [first, second] = await Promise.all(163			[get("bundle"), get("bundle")].map(async (response) => (await response).arrayBuffer()),164		);165		const whole = await (await get("bundle")).arrayBuffer();166		expect(first?.byteLength).toBe(whole.byteLength);167		expect(second?.byteLength).toBe(whole.byteLength);168	});169170	test("a private repo answers exactly as an unknown one", async () => {171		inst.kepr.run("UPDATE repos SET public = 0");172		try {173			for (const path of [174				"format",175				"head",176				"events",177				"saves",178				"objects",179				"bundle",180				`saves/${head}`,181				`objects/${"a".repeat(64)}`,182				`events/${"b".repeat(12)}`,183			]) {184				const private_ = await get(path);185				const unknown = await inst.app.request(`/no-such-repo/koh/${path}`);186				const mine = `${private_.status} ${await private_.text()}`;187				expect(mine).toBe(`${unknown.status} ${await unknown.text()}`);188				expect(mine).toBe(`404 ${NOT_FOUND}`);189				expect(private_.headers.get("content-type")).toBe(unknown.headers.get("content-type"));190				expect(private_.headers.get("cache-control")).toBe(unknown.headers.get("cache-control"));191			}192		} finally {193			inst.kepr.run("UPDATE repos SET public = 1");194		}195	});196197	test("an ID or hash the mirror does not hold answers the same 404", async () => {198		for (const path of [199			`saves/${"0".repeat(12)}`,200			"saves/not-an-id",201			`objects/${"f".repeat(64)}`,202		]) {203			const response = await get(path);204			expect(`${response.status} ${await response.text()}`).toBe(`404 ${NOT_FOUND}`);205		}206	});207208	test("a stripped path leaves the record and the object the mirror serves", async () => {209		const before = await record(oldest);210		const secret = before.files.find((entry: { path: string }) => entry.path === "secret.txt");211		expect(secret).toBeDefined();212		await fixture.strip("secret.txt");213		await resync();214		const after = await record(oldest);215		expect(after.files.some((entry: { path: string }) => entry.path === "secret.txt")).toBe(false);216		expect((await get(`objects/${secret.hash}`)).status).toBe(404);217		const events = await (await get("events")).text();218		const strip = events219			.trim()220			.split("\n")221			.filter((id) => id.length > 0);222		expect(strip.length).toBeGreaterThan(0);223	});224});