feat(backup): admin UI with admin-chosen target directory
The backup destination is now chosen by the on-site admin in the UI (Setup -> Backup), not a server env var. An env-pinned target defeats the purpose: the admin can't point backups at a freshly-plugged USB or a NAS mount without editing .env and restarting. The encryption key stays a server secret. Target storage: - New site_config.backup_target_dir (migration 0016, nullable; null = not configured). BackupService reads it fresh each run, so a UI change takes effect with no restart. Only BACKUP_KEY stays env -- a key must never live in the DB it backs up. Routes: - PUT /api/backup/config -- set/clear the target (backup:update; upserts id=1). - POST /api/backup/test -- probe a candidate path server-side (exists / is a directory / writable) so the admin gets feedback before relying on it. - status() now exposes targetDir + keyPresent, so the UI distinguishes 'no target set' from 'BACKUP_KEY missing'. UI (apps/web/src/BackupSettings.tsx): - A Setup -> Backup tab (gated backup:read): an editable target-path field with a Test-target probe (localized ok/missing/not-a-dir/not-writable), Save, the status panel (config state, last-run size/pruned/error, a distinct amber missing-key warning), a Back up now button, and the restore-is-out-of-band note. Full i18n (sq + en); nav.backup. - API client: fetchBackupStatus / setBackupTarget / testBackupTarget / runBackup. Also includes a small in-progress copy trim to the setup-intro i18n strings. Verified live with Playwright: typed a path -> Test reported writable -> Save persisted it -> status reflected it and showed the key-missing warning. Whole monorepo build/lint/test green. Wiki backup-recovery + open-question #5 updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -49,6 +49,7 @@ describe("GET /api/backup/status", () => {
|
||||
const body = res.json();
|
||||
expect(body).toMatchObject({
|
||||
configured: false,
|
||||
targetDir: null,
|
||||
running: false,
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
@@ -56,6 +57,71 @@ describe("GET /api/backup/status", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/backup/config — admin-chosen target", () => {
|
||||
it("403 for a user lacking backup:update", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "viewer", roleId: "viewer", permissions: ["backup:read"],
|
||||
});
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { targetDir: "/tmp/x" },
|
||||
});
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("persists the target dir and reflects it in status", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
const put = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { targetDir: " /mnt/backup " }, // trimmed server-side
|
||||
});
|
||||
expect(put.statusCode).toBe(200);
|
||||
expect(put.json()).toMatchObject({ targetDir: "/mnt/backup" });
|
||||
|
||||
const status = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
||||
expect(status.json().targetDir).toBe("/mnt/backup");
|
||||
});
|
||||
|
||||
it("clears the target dir when given empty/null", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "/mnt/backup" },
|
||||
});
|
||||
const clear = await app.inject({
|
||||
method: "PUT", url: "/api/backup/config",
|
||||
headers: { cookie, "x-csrf-token": csrf }, payload: { targetDir: "" },
|
||||
});
|
||||
expect(clear.json().targetDir).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/backup/test — path probe", () => {
|
||||
it("reports ok for a writable directory and a reason for a missing one", async () => {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
|
||||
const good = await app.inject({
|
||||
method: "POST", url: "/api/backup/test",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { targetDir: process.cwd() }, // an existing, writable dir
|
||||
});
|
||||
expect(good.json()).toMatchObject({ ok: true });
|
||||
|
||||
const bad = await app.inject({
|
||||
method: "POST", url: "/api/backup/test",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { targetDir: "/no/such/path/here-xyz" },
|
||||
});
|
||||
expect(bad.json()).toMatchObject({ ok: false, reason: "missing" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/backup/run", () => {
|
||||
it("403 for a user lacking backup:create", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
|
||||
Reference in New Issue
Block a user