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