7680d9a0ed
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
115 lines
5.1 KiB
TypeScript
115 lines
5.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 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);
|
|
});
|
|
});
|