1import { beforeAll, describe, expect, test } from "bun:test";2import { writeFile } from "node:fs/promises";3import { join } from "node:path";4import { openBucket } from "../src/bucket/reader.ts";5import { checkFormat, listRepos, readHead, readObject, readSave } from "../src/bucket/repo.ts";6import { type Fixture, makeKohBucket } from "./fixture.ts";78let fx: Fixture;9beforeAll(async () => {10 fx = await makeKohBucket();11});1213const bucket = () => openBucket({ kind: "folder", path: fx.bucketDir });1415describe("reading a koh3-produced folder remote", () => {16 test("lists the repo", async () => {17 expect(await listRepos(bucket())).toEqual([fx.repo]);18 });1920 test("reads format, head, the save record and one verified object end to end", async () => {21 const b = bucket();22 await checkFormat(b, fx.repo);23 const head = await readHead(b, fx.repo);24 expect(head).toBe(fx.saveIds[0] as string);2526 const save = await readSave(b, fx.repo, head as string);27 expect(save.message).toBe("say hello");28 expect(save.is_checkpoint).toBe(false);29 const checkpoint = await readSave(b, fx.repo, save.parent);30 expect(checkpoint.is_checkpoint).toBe(true);31 const first = await readSave(b, fx.repo, checkpoint.parent);32 expect([first.agent, first.session]).toEqual(["test-agent", "fixture"]);3334 const main = save.files.find((file) => file.path === "src/main.ts");35 const bytes = await readObject(b, fx.repo, main?.hash as string);36 expect(new TextDecoder().decode(bytes)).toBe("// Entry point.\nconsole.log('hello');\n");37 });3839 test("a missing key reads as null and a listing is prefix-scoped", async () => {40 const b = bucket();41 expect(await b.get(`${fx.repo}/nothing-here`)).toBeNull();42 const saves = await b.list(`${fx.repo}/saves/`);43 expect(saves.sort()).toEqual(fx.saveIds.map((id) => `${fx.repo}/saves/${id}`).sort());44 });45});4647test("the S3 reader pages through listings and reads a missing key as null", async () => {48 const pageSize = 2;49 const server = Bun.serve({50 port: 0,51 async fetch(req) {52 const url = new URL(req.url);53 const folder = bucket();54 if (url.searchParams.get("list-type") === "2") {55 const all = await folder.list(url.searchParams.get("prefix") ?? "");56 const start = Number(url.searchParams.get("continuation-token") ?? 0);57 const page = all.slice(start, start + pageSize);58 const more = start + pageSize < all.length;59 const contents = page.map((key) => `<Contents><Key>${key}</Key></Contents>`).join("");60 const next = more61 ? `<NextContinuationToken>${start + pageSize}</NextContinuationToken>`62 : "";63 return new Response(64 `<ListBucketResult><IsTruncated>${more}</IsTruncated>${contents}${next}</ListBucketResult>`,65 );66 }67 const bytes = await folder.get(url.pathname.replace("/kohbucket/", ""));68 if (bytes) return new Response(bytes);69 return new Response("<Error><Code>NoSuchKey</Code></Error>", { status: 404 });70 },71 });72 try {73 const s3 = openBucket({74 kind: "s3",75 endpoint: `http://localhost:${server.port}`,76 name: "kohbucket",77 region: "fr-par",78 accessKeyId: "read",79 secretAccessKey: "only",80 });81 expect(await s3.list(`${fx.repo}/`)).toEqual(await bucket().list(`${fx.repo}/`));82 expect(await readHead(s3, fx.repo)).toBe(fx.saveIds[0] as string);83 expect(await s3.get(`${fx.repo}/format`)).toBeNull();84 } finally {85 server.stop();86 }87});8889describe("the format rule", () => {90 test("accepts koh-remote 1 and refuses anything else", async () => {91 const { bucketDir, repo } = await makeKohBucket("formatted");92 const b = openBucket({ kind: "folder", path: bucketDir });93 await writeFile(join(bucketDir, repo, "format"), "koh-remote 1");94 await checkFormat(b, repo);95 await writeFile(join(bucketDir, repo, "format"), "koh-remote 2");96 await expect(checkFormat(b, repo)).rejects.toThrow('remote format "koh-remote 2"');97 });98});99100describe("object verification", () => {101 test("refuses an object whose content does not match its key", async () => {102 const b = bucket();103 const save = await readSave(b, fx.repo, fx.saveIds[0] as string);104 const [one, two] = save.files;105 const swapped = {106 ...b,107 get: (key: string) => b.get(key.replace(one?.hash ?? "", two?.hash ?? "")),108 };109 await expect(readObject(swapped, fx.repo, one?.hash as string)).rejects.toThrow(110 "does not match its hash",111 );112 });113});