b7f3735b29
- 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.
199 lines
7.4 KiB
JavaScript
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");
|
|
});
|
|
});
|