12import { 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 66 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 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([...entries.keys()].sort()).toEqual(keys.sort());153 for (const key of keys) {154 const single = new Uint8Array(await (await get(key)).arrayBuffer());155 expect(entries.get(key)).toEqual(single);156 }157 });158159 test("a private repo answers exactly as an unknown one", async () => {160 inst.kepr.run("UPDATE repos SET public = 0");161 try {162 for (const path of [163 "format",164 "head",165 "events",166 "saves",167 "objects",168 "bundle",169 `saves/${head}`,170 `objects/${"a".repeat(64)}`,171 `events/${"b".repeat(12)}`,172 ]) {173 const private_ = await get(path);174 const unknown = await inst.app.request(`/no-such-repo/koh/${path}`);175 const mine = `${private_.status} ${await private_.text()}`;176 expect(mine).toBe(`${unknown.status} ${await unknown.text()}`);177 expect(mine).toBe(`404 ${NOT_FOUND}`);178 expect(private_.headers.get("content-type")).toBe(unknown.headers.get("content-type"));179 expect(private_.headers.get("cache-control")).toBe(unknown.headers.get("cache-control"));180 }181 } finally {182 inst.kepr.run("UPDATE repos SET public = 1");183 }184 });185186 test("an ID or hash the mirror does not hold answers the same 404", async () => {187 for (const path of [188 `saves/${"0".repeat(12)}`,189 "saves/not-an-id",190 `objects/${"f".repeat(64)}`,191 ]) {192 const response = await get(path);193 expect(`${response.status} ${await response.text()}`).toBe(`404 ${NOT_FOUND}`);194 }195 });196197 test("a stripped path leaves the record and the object the mirror serves", async () => {198 const before = await record(oldest);199 const secret = before.files.find((entry: { path: string }) => entry.path === "secret.txt");200 expect(secret).toBeDefined();201 await fixture.strip("secret.txt");202 await resync();203 const after = await record(oldest);204 expect(after.files.some((entry: { path: string }) => entry.path === "secret.txt")).toBe(false);205 expect((await get(`objects/${secret.hash}`)).status).toBe(404);206 const events = await (await get("events")).text();207 const strip = events208 .trim()209 .split("\n")210 .filter((id) => id.length > 0);211 expect(strip.length).toBeGreaterThan(0);212 });213});