backupbucket.test.ts

6.1 KB · raw

1// Covers the optional backup bucket (June, 2026-09-30): set, tested and removed in the console,2// written to kepr.toml by Kepr alone, each backup copied there with the newest 14 kept, and a3// failure shown and told once until an upload succeeds.45import { 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;15// Just enough S3 for Bun's client: PUT, GET, DELETE and ListObjectsV2 on the bucket "offsite".16const 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});63// Each backup made here is a day after the last, so it is always the newest.64let 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});