kohsteal.test.ts

10.9 KB · raw

1/**2 * The joint public-steal contract (addendum A2, item 8d): one machine publishes a repo to a bucket,3 * Kepr serves it read-only over HTTP in Koh's own layout, a second `koh3` steals it — and later,4 * once that machine has signed in, offers its work back to the bucket.5 *6 * There is no S3 on a test host, so the bucket is a fake one served here; koh3 is run as the real7 * installed binary (`koh3` on PATH, the same one this suite is pinned to). The account of what the8 * fake bucket accepts is in its own class: signatures are ignored rather than verified, which is9 * the one way this test is easier than the real thing.10 */11import { 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";28/** The command deadline: two koh3 runs talk to a local server, so this is generous. */29const RUN = 60_000;3031/**32 * The fake bucket runs as its own process: the koh3 calls below are synchronous (`koh3` blocks the33 * event loop), so an in-process server would never answer them. `tests/fake-s3.ts` prints its34 * endpoint on stdout.35 */36async 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 };5455/** The real koh3, with a throwaway HOME so no test ever touches the operator's config. */56async 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				// The owner: a small repo in a bucket, offered on every save.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				// A second repo, never made public on Kepr: only a signed-in machine may have it.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				// Kepr, reading that bucket: the shipped repo is public, the kept one is not.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				/** Kepr syncs on its own loop in production; here each new save is synced by hand. */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				// The second machine: nothing but a HOME and the address it was told.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				// Save for save, the clone is the line the owner published.164				expect(await line(clone, thiefHome)).toEqual(await line(repoDir, ownerHome));165				// The source is remembered, and shown above the machine's remote.166				expect((await koh3(clone, thiefHome, ["remote"])).out).toContain(`${host}/${REPO}`);167				// And it cannot be offered: it has no bucket of its own.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				// A later public save arrives on a bare `koh steal`.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				// A strip on the source side reaches the mirror, and is honoured: history no longer180				// holds the path, so a fresh clone never sees the file at all. On the machine that181				// already had it, the file stays as work it never saved — a strip is not a delete.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				// Signing in: a code from the connect page, read the way `koh` reads it.193				// This Kepr's domain is the loopback address it is served on, so the same-origin194				// checks want that origin, not the Browser's default.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				// The next steal drops the source: the repo is this machine's own work now. The file214				// the strip left behind is unsaved work, cleared here as `revert` would.215				expect((await koh3(clone, thiefHome, ["revert", "--yes"])).code).toBe(0);216				const upgraded = await koh3(clone, thiefHome, ["steal"]);217				expect(upgraded.err, upgraded.err + upgraded.out).toBe("");218				expect(upgraded.code).toBe(0);219				expect(upgraded.out + upgraded.err).toContain("now syncs with your bucket");220				await writeFile(join(clone, "README.md"), "# shipped\n\nThe thief's edit.\n");221				expect((await koh3(clone, thiefHome, ["save", "from the thief"])).code).toBe(0);222				// The owner steals it back from the bucket; the stripped file is unsaved work there223				// too, so it goes first.224				expect((await koh3(repoDir, ownerHome, ["revert", "--yes"])).code).toBe(0);225				expect((await koh3(repoDir, ownerHome, ["steal"])).code).toBe(0);226				expect(await readFile(join(repoDir, "README.md"), "utf8")).toContain("The thief's edit");227228				// A repo the public side never serves clones from the bucket once signed in.229				const second = join(root, "thief-two");230				await mkdir(second, { recursive: true });231				const kept = await koh3(second, thiefHome, ["steal", `${host}/${PRIVATE_REPO}`]);232				expect(kept.code, kept.err + kept.out).toBe(0);233				expect(await readFile(join(second, PRIVATE_REPO, "secret.txt"), "utf8")).toContain("kept");234				// And it is a normal repo: the machine's own remote is untouched by any of this.235				expect((await koh3(second, thiefHome, ["remote"])).out).toContain(`s3 ${endpoint}`);236			} finally {237				bucketServer.stop();238			}239		},240		RUN,241	);242});