feat(account): tell admins about a refused erasure, and what undo leaves revoked

When the grace window ends for an account that has become the last
active guardian of a managed profile, the purge job refuses to erase it
and the account stays locked. Until now only the worker error log said
so. Every admin now gets one localized notification naming the account
and the ways out (another guardian, deleting the profile, or undoing
the deletion). An audit row on the account records that it was reported,
so the hourly retries stay quiet; a deletion undone and asked for again
is reported afresh.

The undo confirmation in the admin users list now says that sign-in
works again but API tokens, share links and trusted devices stay revoked
and have to be set up again (for a managed profile: its share links and
tokens).
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 14:28:15 +01:00
1 parent 694befd635
commit d902125371
11 files changed
+165 -29

No files matched your search

@@ -46,6 +46,15 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
const adminNotices = vi.hoisted(() => [] as Array<Record<string, unknown>>);
vi.mock("@/lib/notifications/dispatch-localised", () => ({
dispatchLocalisedNotification: vi.fn(
async (opts: Record<string, unknown>) => {
adminNotices.push(opts);
},
),
}));
const { hashPassword } = await import("@/lib/auth/password");
const PASSWORD = "Correct horse battery staple!42";
@@ -53,6 +62,7 @@ const DAY_MS = 24 * 60 * 60 * 1000;
beforeEach(async () => {
const prisma = getPrismaClient();
adminNotices.length = 0;
await truncateAllTables(prisma);
await prisma.rateLimit.deleteMany();
cookieJar.clear();
@@ -372,6 +382,65 @@ describe("account deletion with a grace window", () => {
});
});
describe("an erasure the Guardian invariant refuses", () => {
it("stays locked and tells every admin once", async () => {
const prisma = getPrismaClient();
const admin = await seedAccount("admin", "ADMIN");
const leaving = await seedAccount("guardian-leaving");
const staying = await seedAccount("guardian-staying");
const profile = await prisma.user.create({
data: {
username: "managed-kid",
managedProfileAt: new Date(),
timezone: "Europe/Berlin",
},
});
for (const guardian of [leaving, staying]) {
await prisma.accountGrant.create({
data: {
grantorId: profile.id,
granteeId: guardian.id,
access: "MANAGE",
acceptedAt: new Date(),
},
});
}
// Allowed while another guardian holds the profile.
await signIn(leaving.id);
expect((await deleteOwnAccount()).status).toBe(200);
// The other guardian leaves during the window.
await prisma.accountGrant.updateMany({
where: { granteeId: staying.id },
data: { revokedAt: new Date(), revokedBy: "GRANTEE" },
});
await prisma.user.update({
where: { id: leaving.id },
data: { deletionPurgeAt: new Date(Date.now() - 60_000) },
});
const { processDueAccountDeletions } =
await import("@/lib/jobs/account-deletion-purge");
expect(await processDueAccountDeletions(new Date())).toMatchObject({
purged: 0,
refused: 1,
});
// The next hourly run is refused again, and says nothing new.
expect(await processDueAccountDeletions(new Date())).toMatchObject({
refused: 1,
});
const row = await prisma.user.findUnique({ where: { id: leaving.id } });
expect(row?.deletionRequestedAt).not.toBeNull();
expect(adminNotices).toHaveLength(1);
expect(adminNotices[0]).toMatchObject({
userId: admin.id,
titleKey: "notifications.admin.accountErasureRefusedTitle",
params: { username: "guardian-leaving" },
});
});
});
describe("managed-profile deletion with a grace window", () => {
it("locks the profile away from its Guardian until an admin undoes it", async () => {
const prisma = getPrismaClient();