kohremote.test.ts

7.0 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		tarballDir: join(inst.dataDir, "tarballs"),30	});31}3233describe.serial("Koh's remote layout from the mirror", () => {34	test("the format, the head and the caches the addendum names", async () => {35		const format = await get("format");36		expect(format.status).toBe(200);37		expect(await format.text()).toBe("koh-remote 1");38		expect(format.headers.get("cache-control")).toBe("private, max-age=86400");39		const headResponse = await get("head");40		expect(headResponse.status).toBe(200);41		expect(await headResponse.text()).toBe(head);42		expect(headResponse.headers.get("cache-control")).toBe("no-cache");43	});4445	test("a save record is Koh's, field for field, and checkpoints are part of the chain", async () => {46		const response = await get(`saves/${head}`);47		expect(response.status).toBe(200);48		expect(response.headers.get("cache-control")).toBe("private, max-age=60");49		const save = await record(head);50		expect(Object.keys(save)).toEqual([51			"id",52			"parent",53			"message",54			"created_at",55			"machine",56			"is_checkpoint",57			"size_bytes",58			"agent",59			"session",60			"restores",61			"replayed_from",62			"files",63		]);64		expect(save.id).toBe(head);65		expect(save.parent).toBe(checkpoint);66		// The mirror only keeps columns, so this is the proof that what it re-serves is what Koh67		// wrote, byte shape and all: empty optionals as "", booleans as booleans, no extra member.68		const written = await Bun.file(join(fixture.bucketDir, REPO, "saves", head)).json();69		expect(save).toEqual(written);70		expect(typeof save.size_bytes).toBe("number");71		expect(typeof save.is_checkpoint).toBe("boolean");72		expect(save.agent).toBe("");73		expect(Object.keys(save.files[0])).toEqual(["path", "hash", "executable"]);74		// The chain needs checkpoints, so these routes serve what the pages hide.75		const chain = await get(`saves/${checkpoint}`);76		expect(chain.status).toBe(200);77		expect((await record(checkpoint)).is_checkpoint).toBe(true);78		expect((await inst.app.request(`/${REPO}/saves/${checkpoint}`)).status).toBe(404);79	});8081	test("the save and object prefixes list what a clone walks", async () => {82		const saves = await get("saves");83		expect(saves.status).toBe(200);84		expect(saves.headers.get("cache-control")).toBe("no-cache");85		expect((await saves.text()).trim().split("\n")).toContain(head);86		const save = await record(head);87		const objects = await get("objects");88		expect(objects.status).toBe(200);89		expect(objects.headers.get("cache-control")).toBe("no-cache");90		expect((await objects.text()).trim().split("\n")).toContain(save.files[0].hash);91	});9293	test("an object is the stored zlib bytes and hashes back to its key", async () => {94		const save = await record(head);95		const file = save.files.find((entry: { path: string }) => entry.path === "src/main.ts");96		const object = await get(`objects/${file.hash}`);97		expect(object.status).toBe(200);98		expect(object.headers.get("cache-control")).toBe("private, max-age=31536000");99		expect(object.headers.get("cache-control")).not.toContain("immutable");100		const bytes = new Uint8Array(await object.arrayBuffer());101		expect(bytesToHex(blake3(inflateSync(bytes)))).toBe(file.hash);102		expect(new TextDecoder().decode(inflateSync(bytes))).toContain("hello");103	});104105	test("the events are IDs, one per line, and one event is Koh's record", async () => {106		const events = await get("events");107		expect(events.status).toBe(200);108		expect(events.headers.get("cache-control")).toBe("no-cache");109		const ids = (await events.text()).trim().split("\n");110		expect(ids.length).toBeGreaterThan(0);111		const one = await get(`events/${ids[0]}`);112		expect(one.status).toBe(200);113		expect(one.headers.get("cache-control")).toBe("private, max-age=86400");114		const event = JSON.parse(await one.text());115		expect(Object.keys(event)).toEqual([116			"id",117			"kind",118			"save_id",119			"path",120			"reason",121			"created_at",122			"machine",123		]);124		const written = await Bun.file(join(fixture.bucketDir, REPO, "events", ids[0] ?? "")).json();125		expect(event).toEqual(written);126	});127128	test("a private repo answers exactly as an unknown one", async () => {129		inst.kepr.run("UPDATE repos SET public = 0");130		try {131			for (const path of [132				"format",133				"head",134				"events",135				"saves",136				"objects",137				`saves/${head}`,138				`objects/${"a".repeat(64)}`,139				`events/${"b".repeat(12)}`,140			]) {141				const private_ = await get(path);142				const unknown = await inst.app.request(`/no-such-repo/koh/${path}`);143				const mine = `${private_.status} ${await private_.text()}`;144				expect(mine).toBe(`${unknown.status} ${await unknown.text()}`);145				expect(mine).toBe(`404 ${NOT_FOUND}`);146				expect(private_.headers.get("content-type")).toBe(unknown.headers.get("content-type"));147				expect(private_.headers.get("cache-control")).toBe(unknown.headers.get("cache-control"));148			}149		} finally {150			inst.kepr.run("UPDATE repos SET public = 1");151		}152	});153154	test("an ID or hash the mirror does not hold answers the same 404", async () => {155		for (const path of [156			`saves/${"0".repeat(12)}`,157			"saves/not-an-id",158			`objects/${"f".repeat(64)}`,159		]) {160			const response = await get(path);161			expect(`${response.status} ${await response.text()}`).toBe(`404 ${NOT_FOUND}`);162		}163	});164165	test("a stripped path leaves the record and the object the mirror serves", async () => {166		const before = await record(oldest);167		const secret = before.files.find((entry: { path: string }) => entry.path === "secret.txt");168		expect(secret).toBeDefined();169		await fixture.strip("secret.txt");170		await resync();171		const after = await record(oldest);172		expect(after.files.some((entry: { path: string }) => entry.path === "secret.txt")).toBe(false);173		expect((await get(`objects/${secret.hash}`)).status).toBe(404);174		const events = await (await get("events")).text();175		const strip = events176			.trim()177			.split("\n")178			.filter((id) => id.length > 0);179		expect(strip.length).toBeGreaterThan(0);180	});181});