/** * @file Tests server/lib/db-backup.js and its Settings routes — a backup is a * complete, openable copy of the live database under a server-made name, no * `.partial` is left behind, rotation deletes only AgentHub's own * `agenthub-YYYYMMDD-HHMMSS.db` files (never a look-alike, a symlink or anything * else in the folder), the once-a-day schedule decision, and the folder check. * The settings file and the folders are temp paths. * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const fs = require("node:fs"); const path = require("node:path"); const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-db-backup-")); process.env.DASHBOARD_DB_PATH = path.join(ROOT, "dashboard.db"); process.env.AGENTHUB_SETTINGS_FILE = path.join(ROOT, "settings.env"); process.env.DASHBOARD_LIVENESS_PROBE = "0"; for (const k of ["AGENTHUB_BACKUP_DIR", "AGENTHUB_BACKUP_HOUR", "AGENTHUB_BACKUP_KEEP"]) { delete process.env[k]; } const { createApp, startServer } = require("../index"); const { db, Database } = require("../db"); const backup = require("../lib/db-backup"); const folder = (name) => { const dir = path.join(ROOT, name); fs.mkdirSync(dir, { recursive: true }); return dir; }; let server; let BASE; before(async () => { server = await startServer(createApp(), 0); BASE = `http://127.0.0.1:${server.address().port}`; }); after(() => { server?.close(); fs.rmSync(ROOT, { recursive: true, force: true }); }); async function call(method, urlPath, body) { const res = await fetch(BASE + urlPath, { method, headers: body ? { "content-type": "application/json" } : {}, body: body ? JSON.stringify(body) : undefined, }); return { status: res.status, body: await res.json() }; } describe("validateBackupDir", () => { it("accepts an existing writable folder and resolves it", () => { const dir = folder("ok"); assert.equal(backup.validateBackupDir(`${dir}/./`), dir); }); for (const [label, input] of [ ["a relative path", "backups"], ["a missing folder", path.join(ROOT, "nope")], ["a control character", `${ROOT}/a\nb`], ]) { it(`refuses ${label}`, () => { assert.throws(() => backup.validateBackupDir(input), { code: "INVALID_PATH" }); }); } it("refuses a file", () => { const file = path.join(ROOT, "a-file"); fs.writeFileSync(file, "x"); assert.throws(() => backup.validateBackupDir(file), { code: "INVALID_PATH" }); }); }); describe("runBackup", () => { it("writes a complete copy under a server-made name and leaves no .partial", async () => { const dir = folder("run"); process.env.AGENTHUB_BACKUP_DIR = dir; db.prepare( "INSERT INTO sessions (id, name, status, started_at) VALUES ('backup-marker', 'm', 'completed', '2026-01-01T00:00:00Z')" ).run(); const out = await backup.runBackup(); assert.match(out.file, /^agenthub-\d{8}-\d{6}\.db$/); assert.deepEqual(fs.readdirSync(dir), [out.file]); const copy = new Database(path.join(dir, out.file), { readonly: true }); try { assert.equal(copy.pragma("quick_check", { simple: true }), "ok"); assert.equal( copy.prepare("SELECT name FROM sessions WHERE id = 'backup-marker'").get().name, "m" ); } finally { copy.close(); } assert.equal(backup.lastResult().ok, true); }); it("refuses with no folder set", async () => { delete process.env.AGENTHUB_BACKUP_DIR; await assert.rejects(backup.runBackup(), { code: "ENOBACKUPDIR" }); }); }); describe("rotate", () => { it("keeps the newest N of its own files and touches nothing else", () => { const dir = folder("rotate"); const own = [ "agenthub-20261001-020000.db", "agenthub-20261002-020000.db", "agenthub-20261003-020000.db", "agenthub-20261004-020000.db", ]; const others = ["notes.txt", "agenthub-latest.db", "agenthub-20261001-020000.db.bak", "my.db"]; for (const f of [...own, ...others]) fs.writeFileSync(path.join(dir, f), "x"); const outside = path.join(ROOT, "outside.db"); fs.writeFileSync(outside, "keep me"); fs.symlinkSync(outside, path.join(dir, "agenthub-20250101-000000.db")); assert.deepEqual(backup.rotate(dir, 2).sort(), own.slice(0, 2)); assert.deepEqual( fs.readdirSync(dir).sort(), [...own.slice(2), ...others, "agenthub-20250101-000000.db"].sort() ); assert.equal(fs.readFileSync(outside, "utf8"), "keep me"); }); }); describe("dueNow", () => { const cfg = { dir: "/backups", hour: 2, keep: 7 }; const at = (h) => new Date(2026, 9, 8, h, 30); const today = [{ name: "agenthub-20261008-020000.db" }]; const yesterday = [{ name: "agenthub-20261007-020000.db" }]; it("waits for the hour", () => assert.equal(backup.dueNow(at(1), cfg, [], null), false)); it("runs once the hour passed with no backup today", () => assert.equal(backup.dueNow(at(9), cfg, yesterday, null), true)); it("skips a day that already has one", () => assert.equal(backup.dueNow(at(9), cfg, today, null), false)); it("tries once a day, not every minute after a failure", () => assert.equal(backup.dueNow(at(9), cfg, [], "20261008"), false)); it("does nothing with no folder", () => assert.equal(backup.dueNow(at(9), { ...cfg, dir: "" }, [], null), false)); }); describe("Settings routes", () => { it("GET answers the defaults with no folder", async () => { delete process.env.AGENTHUB_BACKUP_DIR; const { status, body } = await call("GET", "/api/settings/backup"); assert.equal(status, 200); assert.equal(body.dir, ""); assert.equal(body.hour, 2); assert.equal(body.keep, 7); assert.deepEqual(body.files, []); }); it("PUT validates every field", async () => { for (const bad of [ { dir: "relative" }, { hour: 24 }, { hour: 1.5 }, { keep: 0 }, { keep: "7" }, ]) { const { status } = await call("PUT", "/api/settings/backup", bad); assert.equal(status, 400, JSON.stringify(bad)); } }); it("PUT persists to the settings file; POST /run starts a backup", async () => { const dir = folder("route"); const put = await call("PUT", "/api/settings/backup", { dir, hour: 3, keep: 5 }); assert.equal(put.status, 200); assert.equal(put.body.dir, dir); const saved = fs.readFileSync(process.env.AGENTHUB_SETTINGS_FILE, "utf8"); assert.match(saved, new RegExp(`^AGENTHUB_BACKUP_DIR=${dir}$`, "m")); assert.match(saved, /^AGENTHUB_BACKUP_HOUR=3$/m); assert.match(saved, /^AGENTHUB_BACKUP_KEEP=5$/m); const run = await call("POST", "/api/settings/backup/run"); assert.equal(run.status, 202); for (let i = 0; i < 100 && (await call("GET", "/api/settings/backup")).body.running; i++) { await new Promise((r) => setTimeout(r, 50)); } const { body } = await call("GET", "/api/settings/backup"); assert.equal(body.last.ok, true); assert.equal(body.files.length, 1); assert.match(body.files[0].name, /^agenthub-\d{8}-\d{6}\.db$/); }); it("PUT with an empty folder turns backups off", async () => { const { status, body } = await call("PUT", "/api/settings/backup", { dir: "" }); assert.equal(status, 200); assert.equal(body.dir, ""); const run = await call("POST", "/api/settings/backup/run"); assert.equal(run.status, 400); assert.equal(run.body.error.code, "ENOBACKUPDIR"); }); });