import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test"; import { mkdtemp, readdir, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileIssue } from "../src/issues/store.ts"; import { backupIfDue, backups, makeBackup } from "../src/server/backup.ts"; import { openKeprDb } from "../src/server/db.ts"; import { Browser, type Instance, instance } from "./browser.ts"; import { makeRepos, writeRelease } from "./fixture.ts"; const PNG = Uint8Array.from( atob( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", ), (ch) => ch.charCodeAt(0), ); describe("backups", () => { test("one a day, named by time, and only the last 14 kept", async () => { const dataDir = await mkdtemp(join(tmpdir(), "kepr-backups-")); const kepr = openKeprDb(dataDir); const start = Date.parse("2026-09-01T03:00:00Z"); expect(backupIfDue(kepr, dataDir, start)).toBe("20260901T030000Z"); expect(backupIfDue(kepr, dataDir, start + 23 * 3600 * 1000)).toBeNull(); expect(backupIfDue(kepr, dataDir, start + 24 * 3600 * 1000)).toBe("20260902T030000Z"); for (let day = 2; day < 17; day++) makeBackup(kepr, dataDir, start + day * 86400 * 1000); const kept = backups(dataDir); expect(kept.length).toBe(14); expect(kept[0]).toBe("20260904T030000Z"); expect(kept.at(-1)).toBe("20260917T030000Z"); // No half-made backup is left behind. expect((await readdir(join(dataDir, "backups"))).sort()).toEqual(kept); }); }); describe("a backup restored onto a fresh instance works", () => { let clock = Date.parse("2026-09-26T12:00:00Z"); let old: Instance; let fresh: Instance; let operator: Browser; let bucketDir: string; let signature = ""; let extracted: string[] = []; let modes: number[] = []; beforeAll(async () => { setSystemTime(new Date(clock)); const repos = await makeRepos(["app", "hush"]); bucketDir = repos.bucketDir; await writeRelease(bucketDir, "app", { version: "1.0.0", save: repos.saves.app as string, notes: "record notes", files: [{ platform: "linux-x86_64", name: "app", bytes: new Uint8Array([1]) }], }); old = await instance(bucketDir); operator = new Browser(old.app); await operator.signIn(); await operator.post("/admin/repos/app", { public: "1", category: "tools" }); const site = new FormData(); for (const [key, value] of Object.entries({ site_name: "restored", handle: "ada", bio: "Hi.", })) { site.set(key, value); } site.set("signature", new File([PNG], "sig.png", { type: "image/png" })); await operator.post("/admin/site", site); signature = ( old.kepr.query("SELECT value FROM settings WHERE key = 'signature'").get() as { value: string; } ).value; fileIssue(old.kepr, { repo: "app", title: "Keep me", body: "b", type: "bug", version: "", reporterName: "Ada", contactEmail: "ada@private.example", ipHash: "x", filedAt: 1, }); await operator.post("/admin/issues/app/1", { action: "reply", body: "Answered." }); await operator.post("/admin/releases/app/1.0.0", { notes: "edited notes" }); await old.app.request("/app/releases/1.0.0/linux-x86_64"); clock += 60_000; setSystemTime(new Date(clock)); makeBackup(old.kepr, old.dataDir); // "download backup", unpacked by tar into a fresh data directory, as an operator would. await operator.get("/admin"); const download = await operator.request("/admin/backup"); expect(download.status).toBe(200); expect(download.headers.get("content-disposition")).toBe( 'attachment; filename="kepr-backup-20260926T120100Z.tar.gz"', ); const work = await mkdtemp(join(tmpdir(), "kepr-restore-")); await writeFile(join(work, "backup.tar.gz"), new Uint8Array(await download.arrayBuffer())); const tar = Bun.spawnSync(["tar", "-xzf", "backup.tar.gz"], { cwd: work }); expect(tar.exitCode).toBe(0); const restored = join(work, "kepr-backup-20260926T120100Z"); modes = [ (await stat(join(restored, "kepr.db"))).mode & 0o777, (await stat(join(restored, "assets", signature))).mode & 0o777, ]; extracted = (await readdir(restored, { recursive: true })).sort(); // Mirrors are not in the backup; the fresh instance's first sync rebuilds them. fresh = await instance(bucketDir, restored); }); afterAll(() => setSystemTime()); const text = async (path: string) => (await fresh.app.request(path)).text(); test("the backup holds kepr.db and assets, and no sessions", async () => { expect(modes).toEqual([0o600, 0o600]); expect(extracted).toEqual(["assets", `assets/${signature}`, "kepr.db"]); expect(fresh.kepr.query("SELECT COUNT(*) AS n FROM sessions").get()).toEqual({ n: 0 }); expect(await (await operator.get("/admin?section=backups")).text()).toContain( 'Last backup: 2026-09-26 12:01:00 UTC. download backup', ); const stale = new Browser(fresh.app); stale.cookies = new Map(operator.cookies); expect((await stale.get("/admin")).status).toBe(401); expect((await new Browser(fresh.app).signIn()).status).toBe(303); }); test("settings, visibility and the signature survive", async () => { expect(await text("/")).toContain("