backup.test.ts

6.4 KB · raw

1import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";2import { mkdtemp, readdir, stat, writeFile } from "node:fs/promises";3import { tmpdir } from "node:os";4import { join } from "node:path";5import { fileIssue } from "../src/issues/store.ts";6import { backupIfDue, backups, makeBackup } from "../src/server/backup.ts";7import { openKeprDb } from "../src/server/db.ts";8import { Browser, type Instance, instance } from "./browser.ts";9import { makeRepos, writeRelease } from "./fixture.ts";1011const PNG = Uint8Array.from(12	atob(13		"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",14	),15	(ch) => ch.charCodeAt(0),16);1718describe("backups", () => {19	test("one a day, named by time, and only the last 14 kept", async () => {20		const dataDir = await mkdtemp(join(tmpdir(), "kepr-backups-"));21		const kepr = openKeprDb(dataDir);22		const start = Date.parse("2026-09-01T03:00:00Z");23		expect(backupIfDue(kepr, dataDir, start)).toBe("20260901T030000Z");24		expect(backupIfDue(kepr, dataDir, start + 23 * 3600 * 1000)).toBeNull();25		expect(backupIfDue(kepr, dataDir, start + 24 * 3600 * 1000)).toBe("20260902T030000Z");26		for (let day = 2; day < 17; day++) makeBackup(kepr, dataDir, start + day * 86400 * 1000);27		const kept = backups(dataDir);28		expect(kept.length).toBe(14);29		expect(kept[0]).toBe("20260904T030000Z");30		expect(kept.at(-1)).toBe("20260917T030000Z");31		// No half-made backup is left behind.32		expect((await readdir(join(dataDir, "backups"))).sort()).toEqual(kept);33	});34});3536describe("a backup restored onto a fresh instance works", () => {37	let clock = Date.parse("2026-09-26T12:00:00Z");38	let old: Instance;39	let fresh: Instance;40	let operator: Browser;41	let bucketDir: string;42	let signature = "";43	let extracted: string[] = [];44	let modes: number[] = [];4546	beforeAll(async () => {47		setSystemTime(new Date(clock));48		const repos = await makeRepos(["app", "hush"]);49		bucketDir = repos.bucketDir;50		await writeRelease(bucketDir, "app", {51			version: "1.0.0",52			save: repos.saves.app as string,53			notes: "record notes",54			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],55		});56		old = await instance(bucketDir);57		operator = new Browser(old.app);58		await operator.signIn();59		await operator.post("/admin/repos/app", { public: "1", category: "tools" });60		const site = new FormData();61		for (const [key, value] of Object.entries({62			site_name: "restored",63			handle: "ada",64			bio: "Hi.",65		})) {66			site.set(key, value);67		}68		site.set("signature", new File([PNG], "sig.png", { type: "image/png" }));69		await operator.post("/admin/site", site);70		signature = (71			old.kepr.query("SELECT value FROM settings WHERE key = 'signature'").get() as {72				value: string;73			}74		).value;75		fileIssue(old.kepr, {76			repo: "app",77			title: "Keep me",78			body: "b",79			type: "bug",80			version: "",81			reporterName: "Ada",82			contactEmail: "[email protected]",83			ipHash: "x",84			filedAt: 1,85		});86		await operator.post("/admin/issues/app/1", { action: "reply", body: "Answered." });87		await operator.post("/admin/releases/app/1.0.0", { notes: "edited notes" });88		await old.app.request("/app/releases/1.0.0/linux-x86_64");89		clock += 60_000;90		setSystemTime(new Date(clock));91		makeBackup(old.kepr, old.dataDir);9293		// "download backup", unpacked by tar into a fresh data directory, as an operator would.94		await operator.get("/admin");95		const download = await operator.request("/admin/backup");96		expect(download.status).toBe(200);97		expect(download.headers.get("content-disposition")).toBe(98			'attachment; filename="kepr-backup-20260926T120100Z.tar.gz"',99		);100		const work = await mkdtemp(join(tmpdir(), "kepr-restore-"));101		await writeFile(join(work, "backup.tar.gz"), new Uint8Array(await download.arrayBuffer()));102		const tar = Bun.spawnSync(["tar", "-xzf", "backup.tar.gz"], { cwd: work });103		expect(tar.exitCode).toBe(0);104		const restored = join(work, "kepr-backup-20260926T120100Z");105		modes = [106			(await stat(join(restored, "kepr.db"))).mode & 0o777,107			(await stat(join(restored, "assets", signature))).mode & 0o777,108		];109		extracted = (await readdir(restored, { recursive: true })).sort();110		// Mirrors are not in the backup; the fresh instance's first sync rebuilds them.111		fresh = await instance(bucketDir, restored);112	});113	afterAll(() => setSystemTime());114115	const text = async (path: string) => (await fresh.app.request(path)).text();116117	test("the backup holds kepr.db and assets, and no sessions", async () => {118		expect(modes).toEqual([0o600, 0o600]);119		expect(extracted).toEqual(["assets", `assets/${signature}`, "kepr.db"]);120		expect(fresh.kepr.query("SELECT COUNT(*) AS n FROM sessions").get()).toEqual({ n: 0 });121		expect(await (await operator.get("/admin?section=backups")).text()).toContain(122			'Last backup: 2026-09-26 12:01:00 UTC. <a href="/admin/backup">download backup</a>',123		);124		const stale = new Browser(fresh.app);125		stale.cookies = new Map(operator.cookies);126		expect((await stale.get("/admin")).status).toBe(401);127		expect((await new Browser(fresh.app).signIn()).status).toBe(303);128	});129130	test("settings, visibility and the signature survive", async () => {131		expect(await text("/")).toContain("<title>restored</title>");132		expect((await fresh.app.request("/app")).status).toBe(200);133		expect((await fresh.app.request("/hush")).status).toBe(404);134		expect(await text("/browse")).toContain("tools");135		const profile = await text("/@ada");136		expect(profile).toContain(`src="/static/assets/${signature}"`);137		const served = await fresh.app.request(`/static/assets/${signature}`);138		expect(new Uint8Array(await served.arrayBuffer())).toEqual(PNG);139	});140141	test("issues with their thread and contact, release notes and counts survive", async () => {142		const thread = await text("/app/issues/1");143		expect(thread).toContain("Keep me");144		expect(thread).toContain("Answered.");145		const admin = new Browser(fresh.app);146		await admin.signIn();147		expect(await (await admin.get("/admin/issues/app/1")).text()).toContain("[email protected]");148		expect(JSON.parse(await text("/api/app/releases/latest")).notes).toBe("edited notes");149		const stats = [...(await text("/@ada")).matchAll(/profile-stat-number">([^<]*)</g)].map(150			(m) => m[1],151		);152		expect(stats).toEqual(["1", "1", "1", "1"]);153		// New activity keeps counting and numbering from where the backup left off.154		await fresh.app.request("/app/releases/1.0.0/linux-x86_64");155		expect(await text("/@ada")).toContain('profile-stat-number">2<');156	});157});