backup.test.ts

6.2 KB · raw

1import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";2import { mkdtemp, readdir, 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[] = [];4445	beforeAll(async () => {46		setSystemTime(new Date(clock));47		const repos = await makeRepos(["app", "hush"]);48		bucketDir = repos.bucketDir;49		await writeRelease(bucketDir, "app", {50			version: "1.0.0",51			save: repos.saves.app as string,52			notes: "record notes",53			files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }],54		});55		old = await instance(bucketDir);56		operator = new Browser(old.app);57		await operator.signIn();58		await operator.post("/admin/repos/app", { public: "1", category: "tools", sort_order: "3" });59		const site = new FormData();60		for (const [key, value] of Object.entries({61			site_name: "restored",62			handle: "ada",63			bio: "Hi.",64		})) {65			site.set(key, value);66		}67		site.set("signature", new File([PNG], "sig.png", { type: "image/png" }));68		await operator.post("/admin/site", site);69		signature = (70			old.kepr.query("SELECT value FROM settings WHERE key = 'signature'").get() as {71				value: string;72			}73		).value;74		fileIssue(old.kepr, {75			repo: "app",76			title: "Keep me",77			body: "b",78			type: "bug",79			version: "",80			reporterName: "Ada",81			contactEmail: "[email protected]",82			ipHash: "x",83			filedAt: 1,84		});85		await operator.post("/admin/issues/app/1", { action: "reply", body: "Answered." });86		await operator.post("/admin/releases/app/1.0.0", { notes: "edited notes" });87		await old.app.request("/app/releases/1.0.0/linux-x86_64");88		clock += 60_000;89		setSystemTime(new Date(clock));90		makeBackup(old.kepr, old.dataDir);9192		// "download backup", unpacked by tar into a fresh data directory, as an operator would.93		await operator.get("/admin");94		const download = await operator.request("/admin/backup");95		expect(download.status).toBe(200);96		expect(download.headers.get("content-disposition")).toBe(97			'attachment; filename="kepr-backup-20260926T120100Z.tar.gz"',98		);99		const work = await mkdtemp(join(tmpdir(), "kepr-restore-"));100		await writeFile(join(work, "backup.tar.gz"), new Uint8Array(await download.arrayBuffer()));101		const tar = Bun.spawnSync(["tar", "-xzf", "backup.tar.gz"], { cwd: work });102		expect(tar.exitCode).toBe(0);103		const restored = join(work, "kepr-backup-20260926T120100Z");104		extracted = (await readdir(restored, { recursive: true })).sort();105		// Mirrors are not in the backup; the fresh instance's first sync rebuilds them.106		fresh = await instance(bucketDir, restored);107	});108	afterAll(() => setSystemTime());109110	const text = async (path: string) => (await fresh.app.request(path)).text();111112	test("the backup holds kepr.db and assets, and no sessions", async () => {113		expect(extracted).toEqual(["assets", `assets/${signature}`, "kepr.db"]);114		expect(fresh.kepr.query("SELECT COUNT(*) AS n FROM sessions").get()).toEqual({ n: 0 });115		expect(await (await operator.get("/admin")).text()).toContain(116			'Last backup: 2026-09-26 12:01:00 UTC. <a href="/admin/backup">download backup</a>',117		);118		const stale = new Browser(fresh.app);119		stale.cookies = new Map(operator.cookies);120		expect((await stale.get("/admin")).status).toBe(401);121		expect((await new Browser(fresh.app).signIn()).status).toBe(303);122	});123124	test("settings, visibility and the signature survive", async () => {125		expect(await text("/")).toContain("<title>restored</title>");126		expect((await fresh.app.request("/app")).status).toBe(200);127		expect((await fresh.app.request("/hush")).status).toBe(404);128		expect(await text("/browse")).toContain("tools");129		const profile = await text("/@ada");130		expect(profile).toContain(`src="/static/assets/${signature}"`);131		const served = await fresh.app.request(`/static/assets/${signature}`);132		expect(new Uint8Array(await served.arrayBuffer())).toEqual(PNG);133	});134135	test("issues with their thread and contact, release notes and counts survive", async () => {136		const thread = await text("/app/issues/1");137		expect(thread).toContain("Keep me");138		expect(thread).toContain("Answered.");139		const admin = new Browser(fresh.app);140		await admin.signIn();141		expect(await (await admin.get("/admin/issues/app/1")).text()).toContain("[email protected]");142		expect(JSON.parse(await text("/api/app/releases/latest")).notes).toBe("edited notes");143		const stats = [...(await text("/@ada")).matchAll(/profile-stat-number">([^<]*)</g)].map(144			(m) => m[1],145		);146		expect(stats).toEqual(["1", "1", "1", "1"]);147		// New activity keeps counting and numbering from where the backup left off.148		await fresh.app.request("/app/releases/1.0.0/linux-x86_64");149		expect(await text("/@ada")).toContain('profile-stat-number">2<');150	});151});