kohsteal.test.ts

15.5 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 { platform, 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}7576/** The whole line, every field of every save: what a stolen copy must reproduce exactly. */77async 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				// The owner: a small repo in a bucket, offered on every save.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				// A second repo, never made public on Kepr: only a signed-in machine may have it.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				// Kepr, reading that bucket: the shipped repo is public, the kept one is not.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				/** Kepr syncs on its own loop in production; here each new save is synced by hand. */127				const sync = () =>128					syncAll({129						bucket: openBucket(bucket),130						kepr,131						mirrorDir: join(dataDir, "mirror"),132						tarballDir: join(dataDir, "tarballs"),133					});134				await sync();135				kepr.query("UPDATE repos SET public = 1 WHERE name = ?").run(REPO);136				let app: Hono;137				const server = Bun.serve({138					port: 0,139					fetch: (request) => app.fetch(request),140				});141				app = createApp(142					{143						domain: `127.0.0.1:${server.port}`,144						dataDir,145						adminPasswordHash: await Bun.password.hash(PASSWORD, "argon2id"),146						bucket: { ...bucket, write: { accessKeyId: ACCESS, secretAccessKey: SECRET } },147					},148					kepr,149				);150				const host = `http://127.0.0.1:${server.port}`;151152				// The second machine: nothing but a HOME and the address it was told.153				const thiefHome = join(root, "thief-home");154				const thiefDir = join(root, "thief");155				await mkdir(thiefHome, { recursive: true });156				await mkdir(thiefDir, { recursive: true });157				const stolen = await koh3(thiefDir, thiefHome, ["steal", `${host}/${REPO}`]);158				expect(stolen.code, stolen.err + stolen.out).toBe(0);159				const copy = join(thiefDir, REPO);160				expect(await readFile(join(copy, "README.md"), "utf8")).toContain("edited");161				expect((await koh3(copy, thiefHome, ["doctor"])).code).toBe(0);162				// Save for save, the copy is the line the owner published.163				expect(await line(copy, thiefHome)).toEqual(await line(repoDir, ownerHome));164				// The source is remembered, and shown above the machine's remote.165				expect((await koh3(copy, thiefHome, ["remote"])).out).toContain(`${host}/${REPO}`);166				// And it cannot be offered: it has no bucket of its own.167				const refused = await koh3(copy, thiefHome, ["offer"]);168				expect(refused.code).toBe(1);169				expect(refused.err + refused.out).toContain("which is read-only");170171				// A later public save arrives on a bare `koh steal`.172				await writeFile(join(repoDir, "README.md"), "# shipped\n\nA third save.\n");173				expect((await koh3(repoDir, ownerHome, ["save", "third save"])).code).toBe(0);174				await sync();175				expect((await koh3(copy, thiefHome, ["steal"])).code).toBe(0);176				expect(await readFile(join(copy, "README.md"), "utf8")).toContain("A third save");177178				// A strip on the source side reaches the mirror, and is honoured: history no longer179				// holds the path, so a fresh steal never sees the file at all. On the machine that180				// already had it the file stays on disk — a strip is not a delete — but it is banned:181				// not unsaved work, and never saved again.182				await stripPath(repoDir, ownerHome, "src.v");183				// The strip wrote `/src.v` into `.kohignore`; the next save carries the rule everywhere.184				expect(await readFile(join(repoDir, ".kohignore"), "utf8")).toContain("/src.v\n");185				expect((await koh3(repoDir, ownerHome, ["save", "ignore the stripped src.v"])).code).toBe(186					0,187				);188				await sync();189				expect((await koh3(copy, thiefHome, ["steal"])).code).toBe(0);190				expect((await koh3(copy, thiefHome, ["doctor"])).code).toBe(0);191				expect(await Bun.file(join(copy, "src.v")).exists()).toBe(true);192				// Not listed as a change; the status line may still quote a save message naming it.193				const status = (await koh3(copy, thiefHome, ["status"])).out;194				expect(status).not.toContain("+ src.v");195				expect(status).toContain("no changes");196				const fresh = join(root, "thief-again");197				await mkdir(fresh, { recursive: true });198				expect((await koh3(fresh, thiefHome, ["steal", `${host}/${REPO}`])).code).toBe(0);199				expect(await Bun.file(join(fresh, REPO, "src.v")).exists()).toBe(false);200				// A third copy stays stolen to the end, for `offer --json` to upgrade.201				const third = join(root, "thief-three");202				await mkdir(third, { recursive: true });203				expect((await koh3(third, thiefHome, ["steal", `${host}/${REPO}`])).code).toBe(0);204				// And a fourth, for `offer --builds --json` to upgrade.205				const fourth = join(root, "thief-four");206				await mkdir(fourth, { recursive: true });207				expect((await koh3(fourth, thiefHome, ["steal", `${host}/${REPO}`])).code).toBe(0);208209				// Signing in: a code from the connect page, read the way `koh` reads it.210				// This Kepr's domain is the loopback address it is served on, so the same-origin211				// checks want that origin, not the Browser's default.212				const origin = `https://127.0.0.1:${server.port}`;213				const browser = new Browser(app);214				const signInPage = await browser.get("/sign-in");215				const form = /name="form" value="([^"]+)"/.exec(await signInPage.text())?.[1] ?? "";216				const signed = await browser.request("/sign-in", {217					method: "POST",218					headers: { origin },219					body: new URLSearchParams({ form, password: PASSWORD }),220				});221				expect(signed.status).toBe(303);222				const machine = "thief-machine";223				expect((await browser.get(`/connect?machine=${machine}`)).status).toBe(200);224				const shown = await browser.post("/connect", { machine }, origin);225				const code = /class="mono code">([^<]+)</.exec(await shown.text())?.[1]?.trim() ?? "";226				expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/);227				const signedIn = await koh3(thiefDir, thiefHome, ["remote", host, "--code", code]);228				expect(signedIn.code, signedIn.err + signedIn.out).toBe(0);229230				// A save made before the upgrade is local work and nothing else: it must not write to231				// the bucket, and it must not quietly drop the source on its own.232				await writeFile(join(copy, "local.txt"), "work in progress\n");233				const local = await koh3(copy, thiefHome, ["save", "local only"]);234				expect(local.code, local.err + local.out).toBe(0);235				expect(local.out + local.err).not.toContain("offered");236				expect((await koh3(copy, thiefHome, ["remote"])).out).toContain(`${host}/${REPO}`);237238				// The next offer drops the source: the repo is this machine's own work now; offer is239				// the path the addendum names for the owner case.240				const upgraded = await koh3(copy, thiefHome, ["offer"]);241				expect(upgraded.code, upgraded.err + upgraded.out).toBe(0);242				expect(upgraded.out + upgraded.err).toContain("now syncs with your bucket");243				// The owner can read what that offer published, before anything else is saved: the244				// local save the copy was carrying went up with it.245				expect((await koh3(repoDir, ownerHome, ["revert", "--yes"])).code).toBe(0);246				expect((await koh3(repoDir, ownerHome, ["steal"])).code).toBe(0);247				expect(await readFile(join(repoDir, "local.txt"), "utf8")).toContain("work in progress");248				expect(JSON.parse((await koh3(repoDir, ownerHome, ["view", "--json"])).out).saves).toEqual(249					JSON.parse((await koh3(copy, thiefHome, ["view", "--json"])).out).saves,250				);251				await writeFile(join(copy, "README.md"), "# shipped\n\nThe thief's edit.\n");252				expect((await koh3(copy, thiefHome, ["save", "from the thief"])).code).toBe(0);253				expect((await koh3(repoDir, ownerHome, ["steal"])).code).toBe(0);254				expect(await readFile(join(repoDir, "README.md"), "utf8")).toContain("The thief's edit");255256				// The other copy still rides its source. When both sides changed the same file,257				// offer without a terminal refuses as steal would, and never picks a side itself:258				// the file is named, steal is where to choose, and the source stays.259				const other = join(fresh, REPO);260				await writeFile(join(repoDir, "README.md"), "# shipped\n\nThe owner's edit.\n");261				expect((await koh3(repoDir, ownerHome, ["save", "owner edits the readme"])).code).toBe(0);262				await writeFile(join(other, "README.md"), "# shipped\n\nThe other copy's edit.\n");263				expect((await koh3(other, thiefHome, ["save", "other copy edits"])).code).toBe(0);264				const clashed = await koh3(other, thiefHome, ["offer"]);265				expect(clashed.code).toBe(1);266				expect(clashed.err).toContain(267					'README.md changed on both machines — run "koh steal" to choose, then offer',268				);269				expect(await readFile(join(other, "README.md"), "utf8")).toContain("other copy's edit");270				expect((await koh3(other, thiefHome, ["remote"])).out).toContain(`${host}/${REPO}`);271				// Choosing there upgrades the copy, and says where the version it gave up lives.272				const chosen = await koh3(other, thiefHome, ["steal", "--theirs"]);273				expect(chosen.code, chosen.err + chosen.out).toBe(0);274				expect(chosen.out + chosen.err).toContain("now syncs with your bucket");275				expect(chosen.out + chosen.err).toContain("your README.md is in");276				expect(await readFile(join(other, "README.md"), "utf8")).toContain("The owner's edit");277278				// A repo the public side never serves is stolen from the bucket once signed in.279				const second = join(root, "thief-two");280				await mkdir(second, { recursive: true });281				const kept = await koh3(second, thiefHome, ["steal", `${host}/${PRIVATE_REPO}`]);282				expect(kept.code, kept.err + kept.out).toBe(0);283				expect(await readFile(join(second, PRIVATE_REPO, "secret.txt"), "utf8")).toContain("kept");284				// And it is a normal repo: the machine's own remote is untouched by any of this.285				expect((await koh3(second, thiefHome, ["remote"])).out).toContain(`s3 ${endpoint}`);286287				// Saves that come in flagged during offer's upgrade are named in its JSON, as steal's are.288				expect((await koh3(repoDir, ownerHome, ["steal"])).code).toBe(0);289				await writeFile(join(repoDir, "risky.txt"), "risky\n");290				// The same save brings a build file, so the fourth copy can publish a release.291				await writeFile(join(repoDir, "VERSION"), "1.0.0\n");292				await writeFile(293					join(repoDir, "koh.build"),294					[295						`command = mkdir -p out && printf built > out/app-1.0.0-${platform()}`,296						"output = out/app-{version}-{platform}",297						`platforms = ${platform()}`,298						"",299					].join("\n"),300				);301				const risky = JSON.parse(302					(await koh3(repoDir, ownerHome, ["save", "risky", "--json"])).out,303				).id;304				const flag = await koh3(repoDir, ownerHome, ["flag", risky, "--reason", "broke the build"]);305				expect(flag.code, flag.err + flag.out).toBe(0);306				expect((await koh3(repoDir, ownerHome, ["offer"])).code).toBe(0);307				const flaggedOffer = await koh3(join(third, REPO), thiefHome, ["offer", "--json"]);308				expect(flaggedOffer.code, flaggedOffer.err + flaggedOffer.out).toBe(0);309				expect(JSON.parse(flaggedOffer.out).flagged).toEqual([risky]);310				const flaggedBuilds = await koh3(join(fourth, REPO), thiefHome, [311					"offer",312					"--builds",313					"--json",314				]);315				expect(flaggedBuilds.code, flaggedBuilds.err + flaggedBuilds.out).toBe(0);316				expect(JSON.parse(flaggedBuilds.out).flagged).toEqual([risky]);317			} finally {318				bucketServer.stop();319			}320		},321		RUN,322	);323});