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 { 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}7576async function line(repo: string, home: string): Promise<unknown> {77 return JSON.parse((await koh3(repo, home, ["view", "--json"])).out).saves.map(78 (save: { id: string; message: string }) => [save.id, save.message],79 );80}8182describe.serial("stealing a public repo from Kepr over HTTP", () => {83 test(84 "a clone comes over the wire, stays read-only, and follows its source",85 async () => {86 const root = await mkdtemp(join(tmpdir(), "kepr-steal-"));87 const bucketServer = await startBucket();88 const endpoint = bucketServer.endpoint;89 const keys = { KOH_ACCESS_KEY: ACCESS, KOH_SECRET_KEY: SECRET };90 try {91 92 const ownerHome = join(root, "owner-home");93 const ownerDir = join(root, "owner");94 const repoDir = join(ownerDir, REPO);95 const keptDir = join(ownerDir, PRIVATE_REPO);96 await mkdir(ownerHome, { recursive: true });97 await mkdir(repoDir, { recursive: true });98 await mkdir(keptDir, { recursive: true });99 expect((await koh3(repoDir, ownerHome, ["init"], keys)).code).toBe(0);100 expect(101 (await koh3(repoDir, ownerHome, ["remote", "s3", endpoint, BUCKET, "fr-par"], keys)).code,102 ).toBe(0);103 await writeFile(join(repoDir, "README.md"), "# shipped\n\nA repo to steal.\n");104 await writeFile(join(repoDir, "src.v"), "fn main() {}\n");105 expect((await koh3(repoDir, ownerHome, ["save", "first save"])).code).toBe(0);106 await writeFile(join(repoDir, "README.md"), "# shipped\n\nA repo to steal, edited.\n");107 expect((await koh3(repoDir, ownerHome, ["save", "second save"])).code).toBe(0);108 109 expect((await koh3(keptDir, ownerHome, ["init"], keys)).code).toBe(0);110 expect(111 (await koh3(keptDir, ownerHome, ["remote", "s3", endpoint, BUCKET, "fr-par"], keys)).code,112 ).toBe(0);113 await writeFile(join(keptDir, "secret.txt"), "kept in the bucket\n");114 expect((await koh3(keptDir, ownerHome, ["save", "a private repo"])).code).toBe(0);115116 117 const dataDir = await mkdtemp(join(tmpdir(), "kepr-steal-kepr-"));118 const kepr = openKeprDb(dataDir);119 const bucket = {120 kind: "s3" as const,121 endpoint,122 name: BUCKET,123 region: "fr-par",124 accessKeyId: ACCESS,125 secretAccessKey: SECRET,126 };127 128 const sync = () =>129 syncAll({130 bucket: openBucket(bucket),131 kepr,132 mirrorDir: join(dataDir, "mirror"),133 tarballDir: join(dataDir, "tarballs"),134 });135 await sync();136 kepr.query("UPDATE repos SET public = 1 WHERE name = ?").run(REPO);137 let app: Hono;138 const server = Bun.serve({139 port: 0,140 fetch: (request) => app.fetch(request),141 });142 app = createApp(143 {144 domain: `127.0.0.1:${server.port}`,145 dataDir,146 adminPasswordHash: await Bun.password.hash(PASSWORD, "argon2id"),147 bucket: { ...bucket, write: { accessKeyId: ACCESS, secretAccessKey: SECRET } },148 },149 kepr,150 );151 const host = `http://127.0.0.1:${server.port}`;152153 154 const thiefHome = join(root, "thief-home");155 const thiefDir = join(root, "thief");156 await mkdir(thiefHome, { recursive: true });157 await mkdir(thiefDir, { recursive: true });158 const stolen = await koh3(thiefDir, thiefHome, ["steal", `${host}/${REPO}`]);159 expect(stolen.code, stolen.err + stolen.out).toBe(0);160 const clone = join(thiefDir, REPO);161 expect(await readFile(join(clone, "README.md"), "utf8")).toContain("edited");162 expect((await koh3(clone, thiefHome, ["doctor"])).code).toBe(0);163 164 expect(await line(clone, thiefHome)).toEqual(await line(repoDir, ownerHome));165 166 expect((await koh3(clone, thiefHome, ["remote"])).out).toContain(`${host}/${REPO}`);167 168 const refused = await koh3(clone, thiefHome, ["offer"]);169 expect(refused.code).toBe(1);170 expect(refused.err + refused.out).toContain("which is read-only");171172 173 await writeFile(join(repoDir, "README.md"), "# shipped\n\nA third save.\n");174 expect((await koh3(repoDir, ownerHome, ["save", "third save"])).code).toBe(0);175 await sync();176 expect((await koh3(clone, thiefHome, ["steal"])).code).toBe(0);177 expect(await readFile(join(clone, "README.md"), "utf8")).toContain("A third save");178179 180 181 182 await stripPath(repoDir, ownerHome, "src.v");183 await sync();184 expect((await koh3(clone, thiefHome, ["steal"])).code).toBe(0);185 expect((await koh3(clone, thiefHome, ["doctor"])).code).toBe(0);186 expect((await koh3(clone, thiefHome, ["status"])).out).toContain("src.v");187 const fresh = join(root, "thief-again");188 await mkdir(fresh, { recursive: true });189 expect((await koh3(fresh, thiefHome, ["steal", `${host}/${REPO}`])).code).toBe(0);190 expect(await Bun.file(join(fresh, REPO, "src.v")).exists()).toBe(false);191192 193 194 195 const origin = `https://127.0.0.1:${server.port}`;196 const browser = new Browser(app);197 const signInPage = await browser.get("/sign-in");198 const form = /name="form" value="([^"]+)"/.exec(await signInPage.text())?.[1] ?? "";199 const signed = await browser.request("/sign-in", {200 method: "POST",201 headers: { origin },202 body: new URLSearchParams({ form, password: PASSWORD }),203 });204 expect(signed.status).toBe(303);205 const machine = "thief-machine";206 expect((await browser.get(`/connect?machine=${machine}`)).status).toBe(200);207 const shown = await browser.post("/connect", { machine }, origin);208 const code = /class="mono code">([^<]+)</.exec(await shown.text())?.[1]?.trim() ?? "";209 expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/);210 const signedIn = await koh3(thiefDir, thiefHome, ["remote", host, "--code", code]);211 expect(signedIn.code, signedIn.err + signedIn.out).toBe(0);212213 214 215 await writeFile(join(clone, "local.txt"), "work in progress\n");216 const local = await koh3(clone, thiefHome, ["save", "local only"]);217 expect(local.code, local.err + local.out).toBe(0);218 expect(local.out + local.err).not.toContain("offered");219 expect((await koh3(clone, thiefHome, ["remote"])).out).toContain(`${host}/${REPO}`);220221 222 223 224 expect((await koh3(clone, thiefHome, ["revert", "--yes"])).code).toBe(0);225 const upgraded = await koh3(clone, thiefHome, ["offer"]);226 expect(upgraded.code, upgraded.err + upgraded.out).toBe(0);227 expect(upgraded.out + upgraded.err).toContain("now syncs with your bucket");228 await writeFile(join(clone, "README.md"), "# shipped\n\nThe thief's edit.\n");229 expect((await koh3(clone, thiefHome, ["save", "from the thief"])).code).toBe(0);230 231 232 expect((await koh3(repoDir, ownerHome, ["revert", "--yes"])).code).toBe(0);233 expect((await koh3(repoDir, ownerHome, ["steal"])).code).toBe(0);234 expect(await readFile(join(repoDir, "README.md"), "utf8")).toContain("The thief's edit");235236 237 const second = join(root, "thief-two");238 await mkdir(second, { recursive: true });239 const kept = await koh3(second, thiefHome, ["steal", `${host}/${PRIVATE_REPO}`]);240 expect(kept.code, kept.err + kept.out).toBe(0);241 expect(await readFile(join(second, PRIVATE_REPO, "secret.txt"), "utf8")).toContain("kept");242 243 expect((await koh3(second, thiefHome, ["remote"])).out).toContain(`s3 ${endpoint}`);244 } finally {245 bucketServer.stop();246 }247 },248 RUN,249 );250});