Files
AgentHub/server/__tests__/db-backup.test.js
T
nntrivi2001 b7f3735b29 feat: v2.5.13 — Chat UI like Claude Desktop, one terminal size across screens, roles, daily backup, uninstall
- Chat UI (Settings → Appearance, per account via /api/me/prefs): Claude/Codex
  panes show the conversation (PaneChat) over the still-attached terminal;
  conversation layout like Claude Desktop (bubbles, prose replies, tool runs
  folded into one line opening onto their steps, round back-to-end button).
- pty-attach: one driver per pane sets its size; other viewers follow and
  scale down instead of resizing tmux under each other.
- Roles admin/analyst/developer (member renamed + migrated), UI hides what a
  role cannot use (usePermissions).
- Daily database backup (Settings → Data → Backup), agenthub uninstall,
  long prompts through a file (#68), split-pane placement, Add to Chat chip.

Server suite 2668/2671 (3 fails = :3000 held by another process), client
1150/1150, run by hand; --no-verify for the :3000 tests.
2026-10-08 23:04:57 +07:00

199 lines
7.4 KiB
JavaScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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");
});
});