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 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 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 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 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});