feat(recycle-bin): soft delete + restore for master data
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were hard and unrecoverable. Now they soft-delete into a recycle bin. Schema (migration 0012): nullable deleted_at + deleted_by on users, roles, subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified against a copy of the live DB. Backend: each resource's DELETE route STAMPS instead of removing; every catalog list filters deleted_at IS NULL. New recycle-bin module + routes (GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a new recyclebin:read/update/delete permission. A 6-hourly + startup sweep auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 = forever). Invariants: soft-deleted users can't log in (login rejects deleted_at; no-lockout counts live admins only); a soft-deleted subscription doesn't open the barrier; plans are versioned so a delete stamps all versions of the plan_id (bin shows one item); username/role-name UNIQUE spans deleted rows so reuse returns a clear 409 pointing at the bin; restore doesn't auto-cascade a dangling role (guard resolves missing role to empty perms). The signed append-only ledger is OUT of scope (no delete path). Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge + purge confirm; api client + i18n (sq + en parity). Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4 integration: delete -> can't-login -> restore -> login, purge, gating, 409 reuse). server 103/103; build+lint+test 19/19. Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
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 soft delete + recycle bin: an admin DELETE soft-deletes (the user
|
||||
// leaves the list, can't log in), the bin lists it, restore brings it back, and a deleted
|
||||
// user can log in again. Drives the REAL app over a fresh in-memory DB via app.inject.
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
/** Log in an admin and return the auth headers for mutations. */
|
||||
async function asAdmin() {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
const { cookie, csrf } = await login(app, username, password);
|
||||
return { cookie, csrf };
|
||||
}
|
||||
|
||||
describe("soft delete via the resource DELETE route", () => {
|
||||
it("DELETE /api/users/:id soft-deletes: user leaves the list and can't log in, but is restorable", async () => {
|
||||
const { cookie, csrf } = await asAdmin();
|
||||
// Create a victim user to delete.
|
||||
await seedUser(db, { username: "victim", password: "victim-pass-123", roleId: "admin" });
|
||||
const victim = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
|
||||
.users.find((u: { username: string; id: string }) => u.username === "victim");
|
||||
expect(victim).toBeDefined();
|
||||
|
||||
// Delete (soft).
|
||||
const del = await app.inject({
|
||||
method: "DELETE", url: `/api/users/${victim.id}`,
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
});
|
||||
expect(del.statusCode).toBeLessThan(300);
|
||||
|
||||
// Gone from the live list.
|
||||
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json();
|
||||
expect(list.users.some((u: { username: string }) => u.username === "victim")).toBe(false);
|
||||
|
||||
// Can't log in.
|
||||
const relogin = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } });
|
||||
expect(relogin.statusCode).toBe(401);
|
||||
|
||||
// Shows in the recycle bin.
|
||||
const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json();
|
||||
expect(bin.items.some((i: { kind: string; label: string }) => i.kind === "user" && i.label === "victim")).toBe(true);
|
||||
|
||||
// Restore → reappears + can log in.
|
||||
const restore = await app.inject({
|
||||
method: "POST", url: `/api/recycle-bin/user/${victim.id}/restore`,
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
});
|
||||
expect(restore.statusCode).toBeLessThan(300);
|
||||
const relogin2 = await app.inject({ method: "POST", url: "/api/auth/login", payload: { username: "victim", password: "victim-pass-123" } });
|
||||
expect(relogin2.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("purge permanently removes a soft-deleted user", async () => {
|
||||
const { cookie, csrf } = await asAdmin();
|
||||
await seedUser(db, { username: "gone", password: "gone-pass-1234", roleId: "admin" });
|
||||
const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
|
||||
.users.find((u: { username: string }) => u.username === "gone").id;
|
||||
|
||||
await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } });
|
||||
const purge = await app.inject({
|
||||
method: "DELETE", url: `/api/recycle-bin/user/${id}`,
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
});
|
||||
expect(purge.statusCode).toBe(204);
|
||||
|
||||
const bin = (await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } })).json();
|
||||
expect(bin.items.some((i: { label: string }) => i.label === "gone")).toBe(false);
|
||||
});
|
||||
|
||||
it("the recycle bin is gated — a user without recyclebin:read is 403", async () => {
|
||||
const { username, password } = await seedUser(db, {
|
||||
username: "plain", roleId: "plain", permissions: ["user:read"],
|
||||
});
|
||||
const { cookie } = await login(app, username, password);
|
||||
const res = await app.inject({ method: "GET", url: "/api/recycle-bin", headers: { cookie } });
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("recreating a user with a soft-deleted user's username gives a clear 409", async () => {
|
||||
const { cookie, csrf } = await asAdmin();
|
||||
await seedUser(db, { username: "dup", password: "dup-pass-12345", roleId: "admin" });
|
||||
const id = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie } })).json()
|
||||
.users.find((u: { username: string }) => u.username === "dup").id;
|
||||
await app.inject({ method: "DELETE", url: `/api/users/${id}`, headers: { cookie, "x-csrf-token": csrf } });
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST", url: "/api/users",
|
||||
headers: { cookie, "x-csrf-token": csrf },
|
||||
payload: { username: "dup", password: "new-pass-12345", roleId: "admin" },
|
||||
});
|
||||
expect(create.statusCode).toBe(409);
|
||||
expect(create.json().error).toMatch(/recycle bin/i);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user