d5e41500a8
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
159 lines
6.1 KiB
TypeScript
159 lines
6.1 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
import { createTestDb } from "@parking/db/testing";
|
|
import { type Db } from "@parking/db";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { buildServer } from "../server.js";
|
|
import { seedUser, login } from "../test-helpers.js";
|
|
|
|
// HTTP integration for the backup routes — the security seam + the unconfigured-state
|
|
// behaviour. The booted test app has no BACKUP_TARGET_DIR/BACKUP_KEY, so the service is
|
|
// "not configured": status reports it, and a manual run is a clean 409 (not a 500).
|
|
// See wiki/concepts/backup-recovery.md.
|
|
|
|
let db: Db;
|
|
let close: () => void;
|
|
let app: FastifyInstance;
|
|
|
|
beforeEach(async () => {
|
|
const t = createTestDb();
|
|
db = t.db;
|
|
close = t.close;
|
|
app = await buildServer({ db });
|
|
await app.ready();
|
|
});
|
|
afterEach(async () => {
|
|
await app.close();
|
|
close();
|
|
});
|
|
|
|
describe("GET /api/backup/status", () => {
|
|
it("401 without a session", async () => {
|
|
const res = await app.inject({ method: "GET", url: "/api/backup/status" });
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("403 for a user lacking backup:read", async () => {
|
|
const { username, password } = await seedUser(db, {
|
|
username: "viewer", roleId: "viewer", permissions: ["site:read"],
|
|
});
|
|
const { cookie } = await login(app, username, password);
|
|
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it("an admin sees the (unconfigured) status shape", async () => {
|
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
|
const { cookie } = await login(app, username, password);
|
|
const res = await app.inject({ method: "GET", url: "/api/backup/status", headers: { cookie } });
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body).toMatchObject({
|
|
configured: false,
|
|
targetDir: null,
|
|
running: false,
|
|
lastSuccessAt: null,
|
|
lastError: null,
|
|
});
|
|
});
|
|
});
|
|
|
|
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, {
|
|
username: "viewer", roleId: "viewer", permissions: ["backup:read"], // read but not create
|
|
});
|
|
const { cookie, csrf } = await login(app, username, password);
|
|
const res = await app.inject({
|
|
method: "POST", url: "/api/backup/run",
|
|
headers: { cookie, "x-csrf-token": csrf },
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it("requires CSRF on the mutation", async () => {
|
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
|
const { cookie } = await login(app, username, password);
|
|
const res = await app.inject({
|
|
method: "POST", url: "/api/backup/run",
|
|
headers: { cookie }, // no csrf header
|
|
});
|
|
expect(res.statusCode).toBe(403);
|
|
});
|
|
|
|
it("returns 409 backup_not_configured when no target/key is set (not a 500)", async () => {
|
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
|
const { cookie, csrf } = await login(app, username, password);
|
|
const res = await app.inject({
|
|
method: "POST", url: "/api/backup/run",
|
|
headers: { cookie, "x-csrf-token": csrf },
|
|
});
|
|
expect(res.statusCode).toBe(409);
|
|
expect(res.json()).toMatchObject({ error: "backup_not_configured" });
|
|
});
|
|
});
|