// Covers the optional backup bucket (June, 2026-09-30): set, tested and removed in the console, // written to kepr.toml by Kepr alone, each backup copied there with the newest 14 kept, and a // failure shown and told once until an upload succeeds. import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; import { join } from "node:path"; import { attemptUpload, makeBackup } from "../src/server/backup.ts"; import { loadConfig, saveConfig } from "../src/server/config.ts"; import { setting } from "../src/server/db.ts"; import { Browser, type Instance, instance } from "./browser.ts"; import { makeRepos } from "./fixture.ts"; const objects = new Map(); let failing = false; // Just enough S3 for Bun's client: PUT, GET, DELETE and ListObjectsV2 on the bucket "offsite". const s3 = Bun.serve({ port: 0, async fetch(request) { const url = new URL(request.url); const [, bucket, ...rest] = url.pathname.split("/"); if (bucket !== "offsite" || failing) { return new Response("AccessDeniedno", { status: 403, }); } if (url.searchParams.get("list-type") === "2") { const prefix = url.searchParams.get("prefix") ?? ""; const contents = [...objects] .filter(([key]) => key.startsWith(prefix)) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, body]) => `${key}${body.length}`) .join(""); return new Response( `false${contents}`, ); } const key = rest.join("/"); if (request.method === "PUT") { objects.set(key, new Uint8Array(await request.arrayBuffer())); return new Response("", { headers: { etag: '"1"' } }); } if (request.method === "DELETE") { objects.delete(key); return new Response(null, { status: 204 }); } const body = objects.get(key); return body ? new Response(body) : new Response("NoSuchKey", { status: 404 }); }, }); afterAll(() => s3.stop(true)); const endpoint = `http://127.0.0.1:${s3.port}`; const form = (fields: Record = {}) => ({ endpoint, name: "offsite", region: "fr-par", access_key_id: "BACKUP-ID", secret_access_key: "BACKUP-SECRET", ...fields, }); // Each backup made here is a day after the last, so it is always the newest. let clock = Date.now(); const nextDay = () => { clock += 86_400_000; return clock; }; const stored = () => [...objects.keys()].filter((key) => key.startsWith("kepr-backup-")).sort(); describe("the backup bucket", () => { let inst: Instance; let operator: Browser; let configPath: string; const page = async () => (await operator.get("/admin?section=backups")).text(); beforeAll(async () => { const repos = await makeRepos(["app"]); inst = await instance(repos.bucketDir); configPath = join(inst.dataDir, "kepr.toml"); await saveConfig(configPath, inst.config); const { createApp } = await import("../src/server/app.ts"); inst.app = createApp(inst.config, inst.kepr, undefined, configPath); operator = new Browser(inst.app); await operator.signIn(); }); beforeEach(() => { failing = false; }); test("a bucket that refuses is not saved", async () => { failing = true; const response = await operator.post("/admin/backup-bucket", form({ action: "save" })); expect(response.status).toBe(400); expect(await response.text()).toContain("The backup bucket did not take a test file"); expect((await loadConfig(configPath)).backup).toBeUndefined(); }); test("saving writes kepr.toml, never shows the secret, and applies at once", async () => { const response = await operator.post("/admin/backup-bucket", form({ action: "save" })); expect(response.status).toBe(303); expect((await loadConfig(configPath)).backup).toEqual({ endpoint, name: "offsite", region: "fr-par", accessKeyId: "BACKUP-ID", secretAccessKey: "BACKUP-SECRET", }); expect(inst.config.backup?.name).toBe("offsite"); const text = await page(); expect(text).toContain('value="BACKUP-ID"'); expect(text).not.toContain("BACKUP-SECRET"); expect(objects.has("kepr-backup-test")).toBe(false); }); test("back up now copies the backup to the bucket and lists it there", async () => { expect((await operator.post("/admin/backup", {})).status).toBe(303); expect(stored()).toHaveLength(1); const text = await page(); expect(text).toContain("in the backup bucket"); expect(text).toContain(stored()[0]?.replace(/^kepr-backup-|\.tar\.gz$/g, "") ?? "missing"); }); test("the bucket keeps the newest 14", async () => { for (let n = 0; n < 20; n++) { makeBackup(inst.kepr, inst.dataDir, nextDay()); expect(await attemptUpload(inst.kepr, inst.dataDir, inst.config.backup)).toBeNull(); } expect(stored()).toHaveLength(14); }); test("a failed upload is shown and told once, and clears when one succeeds", async () => { failing = true; makeBackup(inst.kepr, inst.dataDir, nextDay()); const failure = await attemptUpload(inst.kepr, inst.dataDir, inst.config.backup); expect(failure).toContain("S3Error"); await attemptUpload(inst.kepr, inst.dataDir, inst.config.backup); const told = inst.kepr .query<{ n: number }, []>( "SELECT COUNT(*) AS n FROM notifications WHERE text LIKE 'Backup upload failed%'", ) .get(); expect(told?.n).toBe(1); expect(await page()).toContain("The last upload to the backup bucket failed"); failing = false; expect(await attemptUpload(inst.kepr, inst.dataDir, inst.config.backup)).toBeNull(); expect(setting(inst.kepr, "backup_upload_error")).toBeNull(); }); test("removing the bucket drops it from kepr.toml and stops uploads", async () => { const response = await operator.post("/admin/backup-bucket", { action: "remove" }); expect(response.status).toBe(303); expect((await loadConfig(configPath)).backup).toBeUndefined(); expect(inst.config.backup).toBeUndefined(); const before = stored().length; makeBackup(inst.kepr, inst.dataDir, nextDay()); expect(await attemptUpload(inst.kepr, inst.dataDir, inst.config.backup)).toBeNull(); expect(stored()).toHaveLength(before); }); });