Files
parking_solution/apps/server/src/routes/backup.routes.test.ts
T
julian 84f00db48b
Build desktop / desktop (push) Successful in 4m17s
Build & push images / images (push) Failing after 39s
CI / check (push) Successful in 39s
feat(backup): admin-tunable retention + BACKUP_KEY as a Komodo secret
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
2026-06-29 12:52:18 +02:00

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" });
});
});