12345import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";6import { join } from "node:path";7import { attemptUpload, makeBackup } from "../src/server/backup.ts";8import { loadConfig, saveConfig } from "../src/server/config.ts";9import { setting } from "../src/server/db.ts";10import { Browser, type Instance, instance } from "./browser.ts";11import { makeRepos } from "./fixture.ts";1213const objects = new Map<string, Uint8Array>();14let failing = false;1516const s3 = Bun.serve({17 port: 0,18 async fetch(request) {19 const url = new URL(request.url);20 const [, bucket, ...rest] = url.pathname.split("/");21 if (bucket !== "offsite" || failing) {22 return new Response("<Error><Code>AccessDenied</Code><Message>no</Message></Error>", {23 status: 403,24 });25 }26 if (url.searchParams.get("list-type") === "2") {27 const prefix = url.searchParams.get("prefix") ?? "";28 const contents = [...objects]29 .filter(([key]) => key.startsWith(prefix))30 .sort(([a], [b]) => a.localeCompare(b))31 .map(([key, body]) => `<Contents><Key>${key}</Key><Size>${body.length}</Size></Contents>`)32 .join("");33 return new Response(34 `<ListBucketResult><IsTruncated>false</IsTruncated>${contents}</ListBucketResult>`,35 );36 }37 const key = rest.join("/");38 if (request.method === "PUT") {39 objects.set(key, new Uint8Array(await request.arrayBuffer()));40 return new Response("", { headers: { etag: '"1"' } });41 }42 if (request.method === "DELETE") {43 objects.delete(key);44 return new Response(null, { status: 204 });45 }46 const body = objects.get(key);47 return body48 ? new Response(body)49 : new Response("<Error><Code>NoSuchKey</Code></Error>", { status: 404 });50 },51});52afterAll(() => s3.stop(true));5354const endpoint = `http://127.0.0.1:${s3.port}`;55const form = (fields: Record<string, string> = {}) => ({56 endpoint,57 name: "offsite",58 region: "fr-par",59 access_key_id: "BACKUP-ID",60 secret_access_key: "BACKUP-SECRET",61 ...fields,62});6364let clock = Date.now();65const nextDay = () => {66 clock += 86_400_000;67 return clock;68};69const stored = () => [...objects.keys()].filter((key) => key.startsWith("kepr-backup-")).sort();7071describe("the backup bucket", () => {72 let inst: Instance;73 let operator: Browser;74 let configPath: string;75 const page = async () => (await operator.get("/admin?section=backups")).text();7677 beforeAll(async () => {78 const repos = await makeRepos(["app"]);79 inst = await instance(repos.bucketDir);80 configPath = join(inst.dataDir, "kepr.toml");81 await saveConfig(configPath, inst.config);82 const { createApp } = await import("../src/server/app.ts");83 inst.app = createApp(inst.config, inst.kepr, undefined, configPath);84 operator = new Browser(inst.app);85 await operator.signIn();86 });87 beforeEach(() => {88 failing = false;89 });9091 test("a bucket that refuses is not saved", async () => {92 failing = true;93 const response = await operator.post("/admin/backup-bucket", form({ action: "save" }));94 expect(response.status).toBe(400);95 expect(await response.text()).toContain("The backup bucket did not take a test file");96 expect((await loadConfig(configPath)).backup).toBeUndefined();97 });9899 test("saving writes kepr.toml, never shows the secret, and applies at once", async () => {100 const response = await operator.post("/admin/backup-bucket", form({ action: "save" }));101 expect(response.status).toBe(303);102 expect((await loadConfig(configPath)).backup).toEqual({103 endpoint,104 name: "offsite",105 region: "fr-par",106 accessKeyId: "BACKUP-ID",107 secretAccessKey: "BACKUP-SECRET",108 });109 expect(inst.config.backup?.name).toBe("offsite");110 const text = await page();111 expect(text).toContain('value="BACKUP-ID"');112 expect(text).not.toContain("BACKUP-SECRET");113 expect(objects.has("kepr-backup-test")).toBe(false);114 });115116 test("back up now copies the backup to the bucket and lists it there", async () => {117 expect((await operator.post("/admin/backup", {})).status).toBe(303);118 expect(stored()).toHaveLength(1);119 const text = await page();120 expect(text).toContain("in the backup bucket");121 expect(text).toContain(stored()[0]?.replace(/^kepr-backup-|\.tar\.gz$/g, "") ?? "missing");122 });123124 test("the bucket keeps the newest 14", async () => {125 for (let n = 0; n < 20; n++) {126 makeBackup(inst.kepr, inst.dataDir, nextDay());127 expect(await attemptUpload(inst.kepr, inst.dataDir, inst.config.backup)).toBeNull();128 }129 expect(stored()).toHaveLength(14);130 });131132 test("a failed upload is shown and told once, and clears when one succeeds", async () => {133 failing = true;134 makeBackup(inst.kepr, inst.dataDir, nextDay());135 const failure = await attemptUpload(inst.kepr, inst.dataDir, inst.config.backup);136 expect(failure).toContain("S3Error");137 await attemptUpload(inst.kepr, inst.dataDir, inst.config.backup);138 const told = inst.kepr139 .query<{ n: number }, []>(140 "SELECT COUNT(*) AS n FROM notifications WHERE text LIKE 'Backup upload failed%'",141 )142 .get();143 expect(told?.n).toBe(1);144 expect(await page()).toContain("The last upload to the backup bucket failed");145 failing = false;146 expect(await attemptUpload(inst.kepr, inst.dataDir, inst.config.backup)).toBeNull();147 expect(setting(inst.kepr, "backup_upload_error")).toBeNull();148 });149150 test("removing the bucket drops it from kepr.toml and stops uploads", async () => {151 const response = await operator.post("/admin/backup-bucket", { action: "remove" });152 expect(response.status).toBe(303);153 expect((await loadConfig(configPath)).backup).toBeUndefined();154 expect(inst.config.backup).toBeUndefined();155 const before = stored().length;156 makeBackup(inst.kepr, inst.dataDir, nextDay());157 expect(await attemptUpload(inst.kepr, inst.dataDir, inst.config.backup)).toBeNull();158 expect(stored()).toHaveLength(before);159 });160});