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 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 67 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 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.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 (await get("objects")).text();87 expect(objects.trim().split("\n")).toContain(save.files[0].hash);88 });8990 test("an object is the stored zlib bytes and hashes back to its key", async () => {91 const save = await record(head);92 const file = save.files.find((entry: { path: string }) => entry.path === "src/main.ts");93 const object = await get(`objects/${file.hash}`);94 expect(object.status).toBe(200);95 expect(object.headers.get("cache-control")).toBe("private, max-age=31536000");96 expect(object.headers.get("cache-control")).not.toContain("immutable");97 const bytes = new Uint8Array(await object.arrayBuffer());98 expect(bytesToHex(blake3(inflateSync(bytes)))).toBe(file.hash);99 expect(new TextDecoder().decode(inflateSync(bytes))).toContain("hello");100 });101102 test("the events are IDs, one per line, and one event is Koh's record", async () => {103 const events = await get("events");104 expect(events.status).toBe(200);105 expect(events.headers.get("cache-control")).toBe("no-cache");106 const ids = (await events.text()).trim().split("\n");107 expect(ids.length).toBeGreaterThan(0);108 const one = await get(`events/${ids[0]}`);109 expect(one.status).toBe(200);110 expect(one.headers.get("cache-control")).toBe("private, max-age=86400");111 const event = JSON.parse(await one.text());112 expect(Object.keys(event)).toEqual([113 "id",114 "kind",115 "save_id",116 "path",117 "reason",118 "created_at",119 "machine",120 ]);121 const written = await Bun.file(join(fixture.bucketDir, REPO, "events", ids[0] ?? "")).json();122 expect(event).toEqual(written);123 });124125 test("a private repo answers exactly as an unknown one", async () => {126 inst.kepr.run("UPDATE repos SET public = 0");127 try {128 for (const path of [129 "format",130 "head",131 "events",132 `saves/${head}`,133 `objects/${"a".repeat(64)}`,134 `events/${"b".repeat(12)}`,135 ]) {136 const private_ = await get(path);137 const unknown = await inst.app.request(`/no-such-repo/koh/${path}`);138 const mine = `${private_.status} ${await private_.text()}`;139 expect(mine).toBe(`${unknown.status} ${await unknown.text()}`);140 expect(mine).toBe(`404 ${NOT_FOUND}`);141 expect(private_.headers.get("content-type")).toBe(unknown.headers.get("content-type"));142 expect(private_.headers.get("cache-control")).toBe(unknown.headers.get("cache-control"));143 }144 } finally {145 inst.kepr.run("UPDATE repos SET public = 1");146 }147 });148149 test("an ID or hash the mirror does not hold answers the same 404", async () => {150 for (const path of [151 `saves/${"0".repeat(12)}`,152 "saves/not-an-id",153 `objects/${"f".repeat(64)}`,154 ]) {155 const response = await get(path);156 expect(`${response.status} ${await response.text()}`).toBe(`404 ${NOT_FOUND}`);157 }158 });159160 test("a stripped path leaves the record and the object the mirror serves", async () => {161 const before = await record(oldest);162 const secret = before.files.find((entry: { path: string }) => entry.path === "secret.txt");163 expect(secret).toBeDefined();164 await fixture.strip("secret.txt");165 await resync();166 const after = await record(oldest);167 expect(after.files.some((entry: { path: string }) => entry.path === "secret.txt")).toBe(false);168 expect((await get(`objects/${secret.hash}`)).status).toBe(404);169 const events = await (await get("events")).text();170 const strip = events171 .trim()172 .split("\n")173 .filter((id) => id.length > 0);174 expect(strip.length).toBeGreaterThan(0);175 });176});