// Covers §24's backup workspace: every kept backup listed and downloadable, back up now, the last // failure until a backup succeeds, and the written restore steps. import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test"; import { unlinkSync, writeFileSync } from "node:fs"; import { backups, makeBackup, startBackupLoop } from "../src/server/backup.ts"; import { setting } from "../src/server/db.ts"; import { Browser, type Instance, instance } from "./browser.ts"; import { makeRepos } from "./fixture.ts"; describe("the backup workspace (§24)", () => { let inst: Instance; let operator: Browser; let clock = Date.parse("2026-09-27T03:00:00Z"); const page = async () => (await operator.get("/admin?section=backups")).text(); beforeAll(async () => { setSystemTime(new Date(clock)); const repos = await makeRepos(["app"]); inst = await instance(repos.bucketDir); for (let day = 3; day > 0; day--) { makeBackup(inst.kepr, inst.dataDir, clock - day * 86_400_000); } operator = new Browser(inst.app); await operator.signIn(); }); afterAll(() => setSystemTime()); test("lists every kept backup, newest first, with its size and its own download", async () => { const text = await page(); const listed = [...text.matchAll(/href="\/admin\/backup\?name=(\d{8}T\d{6}Z)"/g)].map( (match) => match[1], ); expect(listed).toEqual(["20260926T030000Z", "20260925T030000Z", "20260924T030000Z"]); expect(text).toMatch(/2026-09-24 03:00:00 UTC<\/a>\s*[\d.]+ KB<\/span>/); const download = await operator.request("/admin/backup?name=20260925T030000Z"); expect(download.status).toBe(200); expect(download.headers.get("content-disposition")).toBe( 'attachment; filename="kepr-backup-20260925T030000Z.tar.gz"', ); expect((await operator.request("/admin/backup?name=20260101T000000Z")).status).toBe(404); expect((await operator.request("/admin/backup?name=../kepr.db")).status).toBe(404); }); test("says how to restore one by hand", async () => { const text = await page(); expect(text).toContain("Stop Kepr."); expect(text).toContain("kepr.db and assets/"); }); test("back up now makes one at once, even with today's done", async () => { clock += 3600_000; setSystemTime(new Date(clock)); const before = backups(inst.dataDir).length; const made = await operator.post("/admin/backup", {}); expect(made.status).toBe(303); expect(made.headers.get("location")).toBe("/admin?section=backups&saved=1"); expect(backups(inst.dataDir).length).toBe(before + 1); expect(backups(inst.dataDir).at(-1)).toBe("20260927T040000Z"); }); test("the last failure stays shown until a backup succeeds", async () => { const saved = `${inst.dataDir}/backups-kept`; Bun.spawnSync(["mv", `${inst.dataDir}/backups`, saved]); writeFileSync(`${inst.dataDir}/backups`, ""); startBackupLoop(inst.kepr, inst.config); expect(setting(inst.kepr, "backup_error")).toContain("ENOTDIR"); const failed = await operator.post("/admin/backup", {}); expect(failed.status).toBe(500); expect(await page()).toContain("The last backup failed:"); unlinkSync(`${inst.dataDir}/backups`); Bun.spawnSync(["mv", saved, `${inst.dataDir}/backups`]); expect((await operator.post("/admin/backup", {})).status).toBe(303); expect(setting(inst.kepr, "backup_error")).toBeNull(); expect(await page()).not.toContain("The last backup failed:"); }); });