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
190 lines
7.3 KiB
TypeScript
190 lines
7.3 KiB
TypeScript
import { beforeEach, describe, expect, it } from "vitest";
|
|
import { randomUUID } from "node:crypto";
|
|
import {
|
|
eq,
|
|
isNull,
|
|
roles,
|
|
rolePermissions,
|
|
subscriptionCredentials,
|
|
subscriptionPlans,
|
|
subscriptions,
|
|
tariffs,
|
|
users,
|
|
type Db,
|
|
} from "@parking/db";
|
|
import { createTestDb } from "@parking/db/testing";
|
|
import {
|
|
listRecycleBin,
|
|
purge,
|
|
restore,
|
|
restoreBlockedReason,
|
|
softDelete,
|
|
sweepExpired,
|
|
} from "./recycle-bin.js";
|
|
|
|
// Soft delete / recycle bin. Pins: a delete STAMPS (keeps the row), the bin lists
|
|
// soft-deleted items across kinds, restore brings them back, purge does the real
|
|
// DELETE (+ children), a restore that would collide with a live row is blocked, and the
|
|
// retention sweep purges only items past the window.
|
|
|
|
let db: Db;
|
|
beforeEach(() => {
|
|
({ db } = createTestDb());
|
|
});
|
|
|
|
function seedUser(username: string): string {
|
|
const id = randomUUID();
|
|
db.insert(roles).values({ id: "admin", name: "admin", builtin: 1 }).onConflictDoNothing().run();
|
|
db.insert(users).values({ id, username, passwordHash: "x", roleId: "admin" }).run();
|
|
return id;
|
|
}
|
|
function seedRole(name: string): string {
|
|
const id = randomUUID();
|
|
db.insert(roles).values({ id, name, builtin: 0 }).run();
|
|
db.insert(rolePermissions).values({ roleId: id, permission: "site:read" }).run();
|
|
return id;
|
|
}
|
|
function seedSubscription(holder: string): string {
|
|
const id = randomUUID();
|
|
db.insert(subscriptions).values({ id, holderName: holder, period: "month" }).run();
|
|
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: "qr", value: `qr-${id}` }).run();
|
|
return id;
|
|
}
|
|
function seedPlan(planId: string, versions = 2): void {
|
|
for (let i = 0; i < versions; i++) {
|
|
db.insert(subscriptionPlans).values({
|
|
id: randomUUID(),
|
|
planId,
|
|
name: planId,
|
|
period: "month",
|
|
pricePerPeriodMinor: 100000,
|
|
currency: "ALL",
|
|
effectiveFrom: `2026-0${i + 1}-01T00:00:00.000Z`,
|
|
}).run();
|
|
}
|
|
}
|
|
|
|
describe("softDelete + restore + purge", () => {
|
|
it("stamps the row instead of removing it, and hides it from a live query", () => {
|
|
const id = seedUser("alice");
|
|
expect(softDelete(db, "user", id, "admin-1")).toBe(true);
|
|
|
|
const row = db.select().from(users).where(eq(users.id, id)).get();
|
|
expect(row).toBeDefined(); // still there
|
|
expect(row?.deletedAt).toBeTruthy();
|
|
expect(row?.deletedBy).toBe("admin-1");
|
|
// A live-only query no longer sees it.
|
|
expect(db.select().from(users).where(isNull(users.deletedAt)).all()).toHaveLength(0);
|
|
});
|
|
|
|
it("soft-deleting an already-deleted row is a no-op (returns false)", () => {
|
|
const id = seedUser("bob");
|
|
expect(softDelete(db, "user", id, "a")).toBe(true);
|
|
expect(softDelete(db, "user", id, "a")).toBe(false);
|
|
});
|
|
|
|
it("restore clears the stamps and brings the row back to the live set", () => {
|
|
const id = seedRole("valet");
|
|
softDelete(db, "role", id, "a");
|
|
expect(restore(db, "role", id)).toBe(true);
|
|
const row = db.select().from(roles).where(eq(roles.id, id)).get();
|
|
expect(row?.deletedAt).toBeNull();
|
|
expect(db.select().from(roles).where(isNull(roles.deletedAt)).all().map((r) => r.id)).toContain(id);
|
|
});
|
|
|
|
it("purge removes a soft-deleted row + its children; refuses a LIVE row", () => {
|
|
const id = seedSubscription("carlos");
|
|
// Cannot purge while live (purge only touches soft-deleted rows).
|
|
expect(purge(db, "subscription", id)).toBe(false);
|
|
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeDefined();
|
|
|
|
softDelete(db, "subscription", id, "a");
|
|
expect(purge(db, "subscription", id)).toBe(true);
|
|
expect(db.select().from(subscriptions).where(eq(subscriptions.id, id)).get()).toBeUndefined();
|
|
// Children gone too.
|
|
expect(db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("versioned plans", () => {
|
|
it("soft-deletes / restores / purges ALL versions of a planId together", () => {
|
|
seedPlan("hotel-daily", 3);
|
|
expect(softDelete(db, "plan", "hotel-daily", "a")).toBe(true);
|
|
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(0);
|
|
|
|
// The bin lists the plan as ONE item, not three.
|
|
const planItems = listRecycleBin(db).filter((i) => i.kind === "plan");
|
|
expect(planItems).toHaveLength(1);
|
|
expect(planItems[0]?.id).toBe("hotel-daily");
|
|
|
|
expect(restore(db, "plan", "hotel-daily")).toBe(true);
|
|
expect(db.select().from(subscriptionPlans).where(isNull(subscriptionPlans.deletedAt)).all()).toHaveLength(3);
|
|
|
|
softDelete(db, "plan", "hotel-daily", "a");
|
|
expect(purge(db, "plan", "hotel-daily")).toBe(true);
|
|
expect(db.select().from(subscriptionPlans).all()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("listRecycleBin", () => {
|
|
it("collects soft-deleted items across every kind, newest-deleted first", () => {
|
|
const u = seedUser("dora");
|
|
const r = seedRole("guard");
|
|
const t = randomUUID();
|
|
db.insert(tariffs).values({ id: t, scope: "site", name: "Site" }).run();
|
|
|
|
softDelete(db, "user", u, "a");
|
|
softDelete(db, "role", r, "a");
|
|
softDelete(db, "tariff", t, "a");
|
|
|
|
const items = listRecycleBin(db);
|
|
expect(items.map((i) => i.kind).sort()).toEqual(["role", "tariff", "user"]);
|
|
// Each carries a human label + the deletedAt stamp.
|
|
expect(items.find((i) => i.kind === "user")?.label).toBe("dora");
|
|
expect(items.every((i) => i.deletedAt)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("restoreBlockedReason", () => {
|
|
// NB: the DB `username`/`name` UNIQUE spans live AND soft-deleted rows, so a live
|
|
// duplicate can't even be INSERTed while the deleted one exists (the create route
|
|
// returns a clear 409 instead — see routes/users.ts). restoreBlockedReason is a
|
|
// belt-and-suspenders guard at restore time; verify it returns null in the normal
|
|
// case (nothing colliding) so a clean restore is never wrongly blocked.
|
|
it("does not block a normal restore (no live collision)", () => {
|
|
const u = seedUser("eve");
|
|
softDelete(db, "user", u, "a");
|
|
expect(restoreBlockedReason(db, "user", u)).toBeNull();
|
|
|
|
const r = seedRole("cleaner");
|
|
softDelete(db, "role", r, "a");
|
|
expect(restoreBlockedReason(db, "role", r)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("sweepExpired (retention)", () => {
|
|
it("purges items deleted longer than the window ago, keeps recent ones", () => {
|
|
const old = seedUser("old");
|
|
const fresh = seedUser("fresh");
|
|
softDelete(db, "user", old, "a");
|
|
softDelete(db, "user", fresh, "a");
|
|
// Backdate `old`'s deletion to 40 days ago.
|
|
const longAgo = new Date(Date.now() - 40 * 86_400_000).toISOString();
|
|
db.update(users).set({ deletedAt: longAgo }).where(eq(users.id, old)).run();
|
|
|
|
const purged = sweepExpired(db, 30);
|
|
expect(purged.user).toBe(1);
|
|
expect(db.select().from(users).where(eq(users.id, old)).get()).toBeUndefined();
|
|
expect(db.select().from(users).where(eq(users.id, fresh)).get()).toBeDefined();
|
|
});
|
|
|
|
it("days <= 0 disables the sweep (keep forever)", () => {
|
|
const id = seedUser("keeper");
|
|
softDelete(db, "user", id, "a");
|
|
db.update(users).set({ deletedAt: new Date(Date.now() - 999 * 86_400_000).toISOString() }).where(eq(users.id, id)).run();
|
|
const purged = sweepExpired(db, 0);
|
|
expect(purged.user).toBe(0);
|
|
expect(db.select().from(users).where(eq(users.id, id)).get()).toBeDefined();
|
|
});
|
|
});
|