1234567891011import { describe, expect, test } from "bun:test";12import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";13import { tmpdir } from "node:os";14import { join } from "node:path";15import type { Hono } from "hono";16import { openBucket } from "../src/bucket/reader.ts";17import { syncAll } from "../src/bucket/sync.ts";18import { createApp } from "../src/server/app.ts";19import { openKeprDb } from "../src/server/db.ts";20import { Browser, PASSWORD } from "./browser.ts";21import { platform, stripPath } from "./fixture.ts";2223const REPO = "shipped";24const PRIVATE_REPO = "kept";25const BUCKET = "kohbucket";26const ACCESS = "fake-access";27const SECRET = "fake-secret";2829const RUN = 60_000;30313233343536async function startBucket(): Promise<{ endpoint: string; stop: () => void }> {37 const proc = Bun.spawn(["bun", "run", join(import.meta.dir, "fake-s3.ts")], {38 stdout: "pipe",39 stderr: "inherit",40 });41 const reader = proc.stdout.getReader();42 const first = await reader.read();43 const endpoint =44 new TextDecoder()45 .decode(first.value ?? new Uint8Array())46 .trim()47 .split("\n")[0] ?? "";48 reader.releaseLock();49 expect(endpoint).toStartWith("http://127.0.0.1:");50 return { endpoint, stop: () => proc.kill() };51}5253type Ran = { code: number; out: string; err: string };545556async function koh3(57 cwd: string,58 home: string,59 args: string[],60 env: Record<string, string> = {},61): Promise<Ran> {62 const proc = Bun.spawn(["koh3", ...args], {63 cwd,64 env: { PATH: process.env.PATH, HOME: home, NO_COLOR: "1", ...env },65 stdout: "pipe",66 stderr: "pipe",67 });68 const [out, err, code] = await Promise.all([69 new Response(proc.stdout).text(),70 new Response(proc.stderr).text(),71 proc.exited,72 ]);73 return { code, out, err };74}757677async function line(repo: string, home: string): Promise<unknown> {78 return JSON.parse((await koh3(repo, home, ["view", "--json"])).out).saves;79}8081describe.serial("stealing a public repo from Kepr over HTTP", () => {82 test(83 "a stolen copy comes over the wire, stays read-only, and follows its source",84 async () => {85 const root = await mkdtemp(join(tmpdir(), "kepr-steal-"));86 const bucketServer = await startBucket();87 const endpoint = bucketServer.endpoint;88 const keys = { KOH_ACCESS_KEY: ACCESS, KOH_SECRET_KEY: SECRET };89 try {90 91 const ownerHome = join(root, "owner-home");92 const ownerDir = join(root, "owner");93 const repoDir = join(ownerDir, REPO);94 const keptDir = join(ownerDir, PRIVATE_REPO);95 await mkdir(ownerHome, { recursive: true });96 await mkdir(repoDir, { recursive: true });97 await mkdir(keptDir, { recursive: true });98 expect((await koh3(repoDir, ownerHome, ["init"], keys)).code).toBe(0);99 expect(100 (await koh3(repoDir, ownerHome, ["remote", "s3", endpoint, BUCKET, "fr-par"], keys)).code,101 ).toBe(0);102 await writeFile(join(repoDir, "README.md"), "# shipped\n\nA repo to steal.\n");103 await writeFile(join(repoDir, "src.v"), "fn main() {}\n");104 expect((await koh3(repoDir, ownerHome, ["save", "first save"])).code).toBe(0);105 await writeFile(join(repoDir, "README.md"), "# shipped\n\nA repo to steal, edited.\n");106 expect((await koh3(repoDir, ownerHome, ["save", "second save"])).code).toBe(0);107 108 expect((await koh3(keptDir, ownerHome, ["init"], keys)).code).toBe(0);109 expect(110 (await koh3(keptDir, ownerHome, ["remote", "s3", endpoint, BUCKET, "fr-par"], keys)).code,111 ).toBe(0);112 await writeFile(join(keptDir, "secret.txt"), "kept in the bucket\n");113 expect((await koh3(keptDir, ownerHome, ["save", "a private repo"])).code).toBe(0);114115 116 const dataDir = await mkdtemp(join(tmpdir(), "kepr-steal-kepr-"));117 const kepr = openKeprDb(dataDir);118 const bucket = {119 kind: "s3" as const,120 endpoint,121 name: BUCKET,122 region: "fr-par",123 accessKeyId: ACCESS,124 secretAccessKey: SECRET,125 };126 127 const sync = () =>128 syncAll({129 bucket: openBucket(bucket),130 kepr,131 mirrorDir: join(dataDir, "mirror"),132 });133 await sync();134 kepr.query("UPDATE repos SET public = 1 WHERE name = ?").run(REPO);135 let app: Hono;136 const server = Bun.serve({137 port: 0,138 fetch: (request) => app.fetch(request),139 });140 app = createApp(141 {142 domain: `127.0.0.1:${server.port}`,143 dataDir,144 adminPasswordHash: await Bun.password.hash(PASSWORD, "argon2id"),145 bucket: { ...bucket, write: { accessKeyId: ACCESS, secretAccessKey: SECRET } },146 },147 kepr,148 );149 const host = `http://127.0.0.1:${server.port}`;150151 152 const thiefHome = join(root, "thief-home");153 const thiefDir = join(root, "thief");154 await mkdir(thiefHome, { recursive: true });155 await mkdir(thiefDir, { recursive: true });156 const stolen = await koh3(thiefDir, thiefHome, ["steal", `${host}/${REPO}`]);157 expect(stolen.code, stolen.err + stolen.out).toBe(0);158 const copy = join(thiefDir, REPO);159 expect(await readFile(join(copy, "README.md"), "utf8")).toContain("edited");160 expect((await koh3(copy, thiefHome, ["doctor"])).code).toBe(0);161 162 expect(await line(copy, thiefHome)).toEqual(await line(repoDir, ownerHome));163 164 expect((await koh3(copy, thiefHome, ["remote"])).out).toContain(`${host}/${REPO}`);165 166 const refused = await koh3(copy, thiefHome, ["offer"]);167 expect(refused.code).toBe(1);168 expect(refused.err + refused.out).toContain("which is read-only");169170 171 await writeFile(join(repoDir, "README.md"), "# shipped\n\nA third save.\n");172 expect((await koh3(repoDir, ownerHome, ["save", "third save"])).code).toBe(0);173 await sync();174 expect((await koh3(copy, thiefHome, ["steal"])).code).toBe(0);175 expect(await readFile(join(copy, "README.md"), "utf8")).toContain("A third save");176177 178 179 180 181 await stripPath(repoDir, ownerHome, "src.v");182 183 expect(await readFile(join(repoDir, ".kohignore"), "utf8")).toContain("/src.v\n");184 expect((await koh3(repoDir, ownerHome, ["save", "ignore the stripped src.v"])).code).toBe(185 0,186 );187 await sync();188 expect((await koh3(copy, thiefHome, ["steal"])).code).toBe(0);189 expect((await koh3(copy, thiefHome, ["doctor"])).code).toBe(0);190 expect(await Bun.file(join(copy, "src.v")).exists()).toBe(true);191 192 const status = (await koh3(copy, thiefHome, ["status"])).out;193 expect(status).not.toContain("+ src.v");194 expect(status).toContain("no changes");195 const fresh = join(root, "thief-again");196 await mkdir(fresh, { recursive: true });197 expect((await koh3(fresh, thiefHome, ["steal", `${host}/${REPO}`])).code).toBe(0);198 expect(await Bun.file(join(fresh, REPO, "src.v")).exists()).toBe(false);199 200 const third = join(root, "thief-three");201 await mkdir(third, { recursive: true });202 expect((await koh3(third, thiefHome, ["steal", `${host}/${REPO}`])).code).toBe(0);203 204 const fourth = join(root, "thief-four");205 await mkdir(fourth, { recursive: true });206 expect((await koh3(fourth, thiefHome, ["steal", `${host}/${REPO}`])).code).toBe(0);207208 209 210 211 const origin = `https://127.0.0.1:${server.port}`;212 const browser = new Browser(app);213 const signInPage = await browser.get("/sign-in");214 const form = /name="form" value="([^"]+)"/.exec(await signInPage.text())?.[1] ?? "";215 const signed = await browser.request("/sign-in", {216 method: "POST",217 headers: { origin },218 body: new URLSearchParams({ form, password: PASSWORD }),219 });220 expect(signed.status).toBe(303);221 const machine = "thief-machine";222 expect((await browser.get(`/connect?machine=${machine}`)).status).toBe(200);223 const shown = await browser.post("/connect", { machine }, origin);224 const code = /class="mono code">([^<]+)</.exec(await shown.text())?.[1]?.trim() ?? "";225 expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/);226 const signedIn = await koh3(thiefDir, thiefHome, ["remote", host, "--code", code]);227 expect(signedIn.code, signedIn.err + signedIn.out).toBe(0);228229 230 231 await writeFile(join(copy, "local.txt"), "work in progress\n");232 const local = await koh3(copy, thiefHome, ["save", "local only"]);233 expect(local.code, local.err + local.out).toBe(0);234 expect(local.out + local.err).not.toContain("offered");235 expect((await koh3(copy, thiefHome, ["remote"])).out).toContain(`${host}/${REPO}`);236237 238 239 const upgraded = await koh3(copy, thiefHome, ["offer"]);240 expect(upgraded.code, upgraded.err + upgraded.out).toBe(0);241 expect(upgraded.out + upgraded.err).toContain("now syncs with your bucket");242 243 244 expect((await koh3(repoDir, ownerHome, ["revert", "--yes"])).code).toBe(0);245 expect((await koh3(repoDir, ownerHome, ["steal"])).code).toBe(0);246 expect(await readFile(join(repoDir, "local.txt"), "utf8")).toContain("work in progress");247 expect(JSON.parse((await koh3(repoDir, ownerHome, ["view", "--json"])).out).saves).toEqual(248 JSON.parse((await koh3(copy, thiefHome, ["view", "--json"])).out).saves,249 );250 await writeFile(join(copy, "README.md"), "# shipped\n\nThe thief's edit.\n");251 expect((await koh3(copy, thiefHome, ["save", "from the thief"])).code).toBe(0);252 expect((await koh3(repoDir, ownerHome, ["steal"])).code).toBe(0);253 expect(await readFile(join(repoDir, "README.md"), "utf8")).toContain("The thief's edit");254255 256 257 258 const other = join(fresh, REPO);259 await writeFile(join(repoDir, "README.md"), "# shipped\n\nThe owner's edit.\n");260 expect((await koh3(repoDir, ownerHome, ["save", "owner edits the readme"])).code).toBe(0);261 await writeFile(join(other, "README.md"), "# shipped\n\nThe other copy's edit.\n");262 expect((await koh3(other, thiefHome, ["save", "other copy edits"])).code).toBe(0);263 const clashed = await koh3(other, thiefHome, ["offer"]);264 expect(clashed.code).toBe(1);265 expect(clashed.err).toContain(266 'README.md changed on both machines — run "koh steal" to choose, then offer',267 );268 expect(await readFile(join(other, "README.md"), "utf8")).toContain("other copy's edit");269 expect((await koh3(other, thiefHome, ["remote"])).out).toContain(`${host}/${REPO}`);270 271 const chosen = await koh3(other, thiefHome, ["steal", "--theirs"]);272 expect(chosen.code, chosen.err + chosen.out).toBe(0);273 expect(chosen.out + chosen.err).toContain("now syncs with your bucket");274 expect(chosen.out + chosen.err).toContain("your README.md is in");275 expect(await readFile(join(other, "README.md"), "utf8")).toContain("The owner's edit");276277 278 const second = join(root, "thief-two");279 await mkdir(second, { recursive: true });280 const kept = await koh3(second, thiefHome, ["steal", `${host}/${PRIVATE_REPO}`]);281 expect(kept.code, kept.err + kept.out).toBe(0);282 expect(await readFile(join(second, PRIVATE_REPO, "secret.txt"), "utf8")).toContain("kept");283 284 expect((await koh3(second, thiefHome, ["remote"])).out).toContain(`s3 ${endpoint}`);285286 287 expect((await koh3(repoDir, ownerHome, ["steal"])).code).toBe(0);288 await writeFile(join(repoDir, "risky.txt"), "risky\n");289 290 await writeFile(join(repoDir, "VERSION"), "1.0.0\n");291 await writeFile(292 join(repoDir, "koh.build"),293 [294 `command = mkdir -p out && printf built > out/app-1.0.0-${platform()}`,295 "output = out/app-{version}-{platform}",296 `platforms = ${platform()}`,297 "",298 ].join("\n"),299 );300 const risky = JSON.parse(301 (await koh3(repoDir, ownerHome, ["save", "risky", "--json"])).out,302 ).id;303 const flag = await koh3(repoDir, ownerHome, ["flag", risky, "--reason", "broke the build"]);304 expect(flag.code, flag.err + flag.out).toBe(0);305 expect((await koh3(repoDir, ownerHome, ["offer"])).code).toBe(0);306 const flaggedOffer = await koh3(join(third, REPO), thiefHome, ["offer", "--json"]);307 expect(flaggedOffer.code, flaggedOffer.err + flaggedOffer.out).toBe(0);308 expect(JSON.parse(flaggedOffer.out).flagged).toEqual([risky]);309 const flaggedBuilds = await koh3(join(fourth, REPO), thiefHome, [310 "offer",311 "--builds",312 "--json",313 ]);314 expect(flaggedBuilds.code, flaggedBuilds.err + flaggedBuilds.out).toBe(0);315 expect(JSON.parse(flaggedBuilds.out).flagged).toEqual([risky]);316 } finally {317 bucketServer.stop();318 }319 },320 RUN,321 );322});