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 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", sort_order: "3" });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 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 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")).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 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});