84f00db48b
Retention (keep-last / keep-daily-days) is operational policy the on-site admin should tune, not a server env var requiring a redeploy -- same reasoning that moved the target directory to the UI. - Migration 0017: site_config.backup_keep_last + backup_keep_daily_days (nullable; null = code default 7 / 30 per field). - BackupService reads retention fresh each run; status() exposes keepLast + keepDailyDays. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads gone). - PUT /api/backup/config accepts keepLast / keepDailyDays (non-negative int, or null to reset to default; 400 on negative). - UI: two retention fields on the Backup config card; one Save covers target + retention. i18n sq + en. BACKUP_KEY wired into Komodo: - komodo/resources.toml: BACKUP_KEY=[[park_buzi_backup_key]] (per-booth secret, alongside JWT / signing keys). - komodo/.env.komodo.example: documents it as the ONLY backup env var -- escrow it offsite alongside EVENT_SIGNING_KEY (recovery needs both); target + retention are admin-chosen in the UI / DB, not env. Server .env.example trimmed to just BACKUP_KEY. Also carries the small in-progress setup-intro i18n copy trim. Tests: 218 server tests green, incl. retention persist / reset-to-default / reject- negative and the updated status shape. Migration applies cleanly (needed a statement-breakpoint between the two ALTERs). Wiki backup-recovery updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
192 lines
7.4 KiB
TypeScript
192 lines
7.4 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,
|
|
keepLast: 7, // code defaults surfaced when unset
|
|
keepDailyDays: 30,
|
|
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();
|
|
});
|
|
|
|
it("persists retention and resets to defaults on null", async () => {
|
|
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
|
const { cookie, csrf } = await login(app, username, password);
|
|
|
|
const set = await app.inject({
|
|
method: "PUT", url: "/api/backup/config",
|
|
headers: { cookie, "x-csrf-token": csrf },
|
|
payload: { keepLast: 3, keepDailyDays: 14 },
|
|
});
|
|
expect(set.json()).toMatchObject({ keepLast: 3, keepDailyDays: 14 });
|
|
|
|
// null resets to the code default.
|
|
const reset = await app.inject({
|
|
method: "PUT", url: "/api/backup/config",
|
|
headers: { cookie, "x-csrf-token": csrf },
|
|
payload: { keepLast: null, keepDailyDays: null },
|
|
});
|
|
expect(reset.json()).toMatchObject({ keepLast: 7, keepDailyDays: 30 });
|
|
});
|
|
|
|
it("rejects a negative retention value (400)", 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: "PUT", url: "/api/backup/config",
|
|
headers: { cookie, "x-csrf-token": csrf },
|
|
payload: { keepLast: -1 },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
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" });
|
|
});
|
|
});
|