sync.test.ts

13.0 KB · raw

1import { Database } from "bun:sqlite";2import { beforeAll, describe, expect, test } from "bun:test";3import { cp, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";4import { tmpdir } from "node:os";5import { join } from "node:path";6import { deflateSync } from "node:zlib";7import { openBucket } from "../src/bucket/reader.ts";8import { syncAll } from "../src/bucket/sync.ts";9import {10	fileAt,11	filesAt,12	flagReason,13	headId,14	history,15	objectBytes,16	repoStats,17	saveByPrefix,18	timeline,19} from "../src/mirror/queries.ts";20import { mirrorState, openMirror } from "../src/mirror/store.ts";21import { openKeprDb } from "../src/server/db.ts";22import { Browser, instance } from "./browser.ts";23import { type Fixture, makeKohBucket } from "./fixture.ts";2425const text = (bytes: Uint8Array | null) => new TextDecoder().decode(bytes ?? new Uint8Array());2627async function syncTarget(fx: Fixture) {28	const dataDir = await mkdtemp(join(tmpdir(), "kepr-data-"));29	const target = {30		bucket: openBucket({ kind: "folder", path: fx.bucketDir }),31		kepr: openKeprDb(dataDir),32		mirrorDir: join(dataDir, "mirror"),33		tarballDir: join(dataDir, "tarballs"),34	};35	return { ...target, mirror: () => openMirror(target.mirrorDir, fx.repo) };36}3738describe("a first sync of a koh3 bucket", () => {39	let fx: Fixture;40	let t: Awaited<ReturnType<typeof syncTarget>>;41	beforeAll(async () => {42		fx = await makeKohBucket();43		t = await syncTarget(fx);44		await syncAll(t);45	});46	const [head, checkpoint, second, first] = [0, 1, 2, 3].map((i) => () => fx.saveIds[i] as string);4748	test("registers the repo private, with a clean last sync", () => {49		const row = t.kepr.query("SELECT public, last_sync_at, last_sync_error FROM repos").get() as {50			public: number;51			last_sync_at: number;52			last_sync_error: string | null;53		};54		expect(row.public).toBe(0);55		expect(row.last_sync_error).toBeNull();56		expect(row.last_sync_at).toBeGreaterThan(0);57	});5859	test("history is newest first and leaves out the checkpoint", () => {60		expect(headId(t.mirror())).toBe(head());61		expect(history(t.mirror(), 10, 0).map((s) => s.id)).toEqual([head(), second(), first()]);62		expect(history(t.mirror(), 1, 1).map((s) => s.message)).toEqual(["add readme and entry point"]);63		expect(timeline(t.mirror()).map((s) => s.id)).toEqual([first(), second(), head()]);64		expect(saveByPrefix(t.mirror(), checkpoint())?.is_checkpoint).toBe(true);65	});6667	test("finds a save by a unique prefix of six or more characters", () => {68		const save = saveByPrefix(t.mirror(), second().slice(0, 6));69		expect([save?.id, save?.agent, save?.session]).toEqual([second(), "test-agent", "fixture"]);70		expect(saveByPrefix(t.mirror(), second().slice(0, 5))).toBeNull();71		expect(saveByPrefix(t.mirror(), "zzzzzz")).toBeNull();72	});7374	test("reads files and their bytes at any save", () => {75		const paths = filesAt(t.mirror(), head()).map((f) => f.path);76		expect(paths).toContain("src/lib/deep/util.ts");77		const now = fileAt(t.mirror(), head(), "src/main.ts");78		const then = fileAt(t.mirror(), second(), "src/main.ts");79		expect(text(objectBytes(t.mirror(), now?.hash as string))).toContain("hello");80		expect(text(objectBytes(t.mirror(), then?.hash as string))).toContain("'hi'");81		expect(fileAt(t.mirror(), first(), "src/main.ts")).toBeNull();82		expect(fileAt(t.mirror(), head(), "docs/logo.png")?.size).toBe(70);83	});8485	test("counts saves without checkpoints, and lines and language at head", () => {86		const stats = repoStats(t.mirror());87		expect(stats.saves).toBe(3);88		expect(stats.language).toBe("TypeScript");89		// Source lines only: util.ts 2 + main.ts 2. The README, guide, secret and .kohignore are not90		// code, and vendor/big.c's 100 lines of C are someone else's.91		expect(stats.lines).toBe(4);92		expect(stats.updated).toBe(saveByPrefix(t.mirror(), head())?.created_at ?? -1);93	});9495	test("stores the flag event", () => {96		const flag = t.mirror().query("SELECT kind, save_id, reason FROM events").get();97		expect(flag).toEqual({ kind: "flag", save_id: first(), reason: "holds a secret" });98	});99100	test("a save's flag state is its newest flag or clear event, by time and then ID", async () => {101		expect(flagReason(t.mirror(), first())).toBe("holds a secret");102		expect(flagReason(t.mirror(), head())).toBeNull();103		const mirror = openMirror(await mkdtemp(join(tmpdir(), "kepr-flags-")), "flags");104		const event = mirror.query(105			"INSERT INTO events (id, kind, save_id, reason, created_at, machine) VALUES (?, ?, 'aaaaaaaaaaaa', ?, ?, 'm')",106		);107		event.run("000000000001", "flag", "first", 10);108		expect(flagReason(mirror, "aaaaaaaaaaaa")).toBe("first");109		event.run("000000000002", "clear", null, 20);110		expect(flagReason(mirror, "aaaaaaaaaaaa")).toBeNull();111		// Same second: the larger ID is the newer.112		event.run("000000000003", "flag", "again", 20);113		expect(flagReason(mirror, "aaaaaaaaaaaa")).toBe("again");114		event.run("000000000000", "clear", null, 20);115		expect(flagReason(mirror, "aaaaaaaaaaaa")).toBe("again");116	});117118	test("a strip reaches the mirror and drops the object", async () => {119		const secret = fileAt(t.mirror(), head(), "secret.txt")?.hash as string;120		expect(objectBytes(t.mirror(), secret)).not.toBeNull();121		await fx.strip("secret.txt");122		await syncAll(t);123		for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).toBeNull();124		expect(objectBytes(t.mirror(), secret)).toBeNull();125		expect(fileAt(t.mirror(), head(), "src/main.ts")).not.toBeNull();126	});127});128129test("a new save syncs incrementally and a bad format is recorded as the last sync error", async () => {130	const fx = await makeKohBucket("growing");131	const t = await syncTarget(fx);132	await syncAll(t);133	await writeFile(join(fx.repoDir, "new.txt"), "new\n");134	Bun.spawnSync(["koh3", "save", "add new"], {135		cwd: fx.repoDir,136		env: { PATH: process.env.PATH, HOME: join(fx.repoDir, "../../home"), NO_COLOR: "1" },137	});138	await syncAll(t);139	expect(history(t.mirror(), 1, 0)[0]?.message).toBe("add new");140141	await writeFile(join(fx.bucketDir, fx.repo, "format"), "koh-remote 2");142	await syncAll(t);143	const row = t.kepr.query("SELECT last_sync_error FROM repos").get() as {144		last_sync_error: string;145	};146	expect(row.last_sync_error).toContain('remote format "koh-remote 2"');147});148149test("a whole-history strip reaches every save, those arriving after it too, whatever their time (§10.2)", async () => {150	const fx = await makeKohBucket("late");151	const view = await mkdtemp(join(tmpdir(), "kepr-view-"));152	const viewRepo = join(view, fx.repo);153	await cp(join(fx.bucketDir, fx.repo), viewRepo, { recursive: true });154	// A strip older than every save here: a strip bans its path from all of history, so no save155	// keeps it, however much later it was made or arrives.156	await mkdir(join(viewRepo, "events"), { recursive: true });157	await writeFile(158		join(viewRepo, "events", "0000000000bb"),159		JSON.stringify({160			id: "0000000000bb",161			kind: "strip",162			save_id: "",163			path: "secret.txt",164			reason: "a secret got saved",165			created_at: 1,166			machine: "elsewhere",167		}),168	);169	const oldest = fx.saveIds.at(-1) as string;170	const newest = fx.saveIds[0] as string;171	// First sync: the strip and the oldest save only.172	await writeFile(join(viewRepo, "head"), oldest);173	const t = await syncTarget({ ...fx, bucketDir: view });174	await syncAll(t);175	expect(fileAt(t.mirror(), oldest, "secret.txt")).toBeNull();176	// Then the rest arrive, each made long after the strip.177	await writeFile(join(viewRepo, "head"), newest);178	await syncAll(t);179	expect(headId(t.mirror())).toBe(newest);180	for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).toBeNull();181	expect(fileAt(t.mirror(), newest, "src/main.ts")).not.toBeNull();182});183184describe("the mirror's schema version", () => {185	test("a version 2 mirror reopens and any other version is refused as disposable", async () => {186		const dir = await mkdtemp(join(tmpdir(), "kepr-mirror-"));187		const created = openMirror(dir, "one");188		created.run("PRAGMA wal_checkpoint(TRUNCATE)");189		await Bun.write(join(dir, "copy.db"), Bun.file(join(dir, "one.db")));190		expect(openMirror(dir, "copy").query("PRAGMA user_version").get()).toEqual({191			user_version: 2,192		});193194		for (const version of [1, 3]) {195			const other = new Database(join(dir, `v${version}.db`));196			other.run(`PRAGMA user_version = ${version}`);197			other.close();198			expect(() => openMirror(dir, `v${version}`)).toThrow("disposable");199		}200	});201});202203test("a strip that arrives older than the saves already mirrored still strips them (§10.2, R6)", async () => {204	const fx = await makeKohBucket("older-strip");205	const t = await syncTarget(fx);206	await syncAll(t);207	// Every save in the mirror holds secret.txt …208	for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).not.toBeNull();209210	// … and now a strip arrives whose own time is older than all of them, which is what a211	// strip made on another machine and offered late looks like. Every save is reached, whatever212	// its time.213	const events = join(fx.bucketDir, fx.repo, "events");214	await mkdir(events, { recursive: true });215	await writeFile(216		join(events, "0000000000aa"),217		JSON.stringify({218			id: "0000000000aa",219			kind: "strip",220			save_id: "",221			path: "secret.txt",222			reason: "a secret got saved",223			created_at: 1,224			machine: "elsewhere",225		}),226	);227	await syncAll(t);228229	for (const id of fx.saveIds) expect(fileAt(t.mirror(), id, "secret.txt")).toBeNull();230	expect(fileAt(t.mirror(), fx.saveIds[0] as string, "src/main.ts")).not.toBeNull();231});232233test("a save with an unsafe path lands without it, keeps syncing, and the admin status names it (A3)", async () => {234	const fx = await makeKohBucket("quarantine");235	const head = fx.saveIds[0] as string;236	const key = join(fx.bucketDir, fx.repo, "saves", head);237	const record = JSON.parse(await readFile(key, "utf8"));238	record.files.push({ ...record.files[0], path: "Icon\r" });239	await writeFile(key, JSON.stringify(record));240	const inst = await instance(fx.bucketDir);241	const mirror = openMirror(join(inst.dataDir, "mirror"), fx.repo);242	expect(inst.kepr.query("SELECT last_sync_error FROM repos").get()).toEqual({243		last_sync_error: null,244	});245	expect(filesAt(mirror, head).map((file) => file.path)).toContain("README.md");246	expect(filesAt(mirror, head).map((file) => file.path)).not.toContain("Icon\r");247248	await writeFile(join(fx.repoDir, "later.txt"), "later\n");249	const env = { PATH: process.env.PATH, HOME: join(fx.repoDir, "../../home"), NO_COLOR: "1" };250	Bun.spawnSync(["koh3", "save", "a later save"], { cwd: fx.repoDir, env });251	Bun.spawnSync(["koh3", "offer"], { cwd: fx.repoDir, env });252	await syncAll({253		bucket: openBucket({ kind: "folder", path: fx.bucketDir }),254		kepr: inst.kepr,255		mirrorDir: join(inst.dataDir, "mirror"),256		tarballDir: join(inst.dataDir, "tarballs"),257	});258	expect(history(mirror, 1, 0)[0]?.message).toBe("a later save");259260	const browser = new Browser(inst.app);261	await browser.signIn();262	const page = await (await browser.get("/admin/repos")).text();263	expect(page).toContain("1 path quarantined: Icon\\x0d");264});265266test("a strip event with an illegal path is ignored, as Koh ignores it (§5.5)", async () => {267	const fx = await makeKohBucket("illegal-strip");268	for (const [id, path] of [269		["aaaaaaaaaaaa", "src//"],270		["bbbbbbbbbbbb", ""],271		["cccccccccccc", "src\ud800"],272	]) {273		const event = { id, kind: "strip", save_id: "", path, reason: "", created_at: 1, machine: "x" };274		await writeFile(join(fx.bucketDir, fx.repo, "events", id as string), JSON.stringify(event));275	}276	const t = await syncTarget(fx);277	await syncAll(t);278	expect(t.kepr.query("SELECT last_sync_error FROM repos").get()).toEqual({279		last_sync_error: null,280	});281	expect(filesAt(t.mirror(), fx.saveIds[0] as string).map((file) => file.path)).toContain(282		"src/main.ts",283	);284});285286test("an object that inflates past 512 MiB is quarantined without stopping the sync (A3)", async () => {287	const fx = await makeKohBucket("oversized");288	const head = fx.saveIds[0] as string;289	const key = join(fx.bucketDir, fx.repo, "saves", head);290	const record = JSON.parse(await readFile(key, "utf8"));291	const hash = "ab".repeat(32);292	record.files.push({ path: "huge.bin", hash, executable: false });293	await writeFile(key, JSON.stringify(record));294	await writeFile(295		join(fx.bucketDir, fx.repo, "objects", hash),296		deflateSync(new Uint8Array(512 * 1024 * 1024 + 1)),297	);298	const t = await syncTarget(fx);299	await syncAll(t);300	expect(t.kepr.query("SELECT last_sync_error FROM repos").get()).toEqual({301		last_sync_error: null,302	});303	expect(filesAt(t.mirror(), head).map((file) => file.path)).not.toContain("huge.bin");304	expect(JSON.parse(mirrorState(t.mirror(), "quarantined") ?? "[]")).toEqual(["huge.bin"]);305});306307test("the line count skips a file over 8 MiB (A3)", async () => {308	const fx = await makeKohBucket("large-text");309	const t = await syncTarget(fx);310	await syncAll(t);311	const before = repoStats(t.mirror()).lines;312	await writeFile(join(fx.repoDir, "big.txt"), "x\n".repeat(4.5 * 1024 * 1024));313	const env = { PATH: process.env.PATH, HOME: join(fx.repoDir, "../../home"), NO_COLOR: "1" };314	Bun.spawnSync(["koh3", "save", "add a big text file"], { cwd: fx.repoDir, env });315	Bun.spawnSync(["koh3", "offer"], { cwd: fx.repoDir, env });316	await syncAll(t);317	expect(history(t.mirror(), 1, 0)[0]?.message).toBe("add a big text file");318	expect(repoStats(t.mirror()).lines).toBe(before);319});