mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(managed-profiles): pending deletion closes handovers and the Guardian count
A handover link stayed claimable after its profile was deleted: the claim gave the profile credentials that every sign-in then refused, and the purge erased the account someone had just taken over. Locking an account for deletion now revokes the open links for that profile and the links that account minted, and creating, previewing and claiming a link refuse a profile pending deletion, the claim under the record lock. The last-Guardian rule counted Guardians pending deletion and profiles pending deletion. A Guardian who had deleted their only profile could not delete their own account; two Guardians sharing a profile could both leave, and the second one's erasure was then refused on every run. The rule now counts only Guardians whose account is not pending deletion, ignores profiles pending deletion, and is decided under the record lock for account deletion, data wipe, revoking and renouncing a Guardian. An admin's undo of a managed profile with no such Guardian left is refused (409 account.deletion.no_guardian).
This commit is contained in:
1 parent
b37e4f5df7
commit
be78517500
7 files changed
+344
-34
No files matched your search
@@ -43,6 +43,13 @@ export const POST = apiHandler(
|
||||
ipAddress: getClientIp(request),
|
||||
});
|
||||
if (outcome === "not_found") return apiError("User not found", 404);
|
||||
if (outcome === "no_guardian") {
|
||||
return apiError(
|
||||
"This managed profile has no Guardian left to give it back to. Its Guardians deleted their accounts or left it during the window.",
|
||||
409,
|
||||
{ errorCode: "account.deletion.no_guardian" },
|
||||
);
|
||||
}
|
||||
if (outcome === "not_pending") {
|
||||
return apiError("This account is not pending deletion", 409, {
|
||||
errorCode: "account.deletion.not_pending",
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { credentialRevocations } from "@/lib/auth/credential-revocations";
|
||||
import { remainingGuardianWhere } from "@/lib/managed-profiles/guardian-where";
|
||||
import {
|
||||
requestOffhostPurge,
|
||||
type OffhostPurgeReason,
|
||||
@@ -57,6 +58,18 @@ export async function lockAccountForDeletion(
|
||||
for (const revoke of credentialRevocations(tx, input.userId)) {
|
||||
await revoke;
|
||||
}
|
||||
// A handover link is a credential for the profile it hands over: claimed
|
||||
// during the window, it would give someone an account that is locked at
|
||||
// once and erased when the window ends. The links this account minted for
|
||||
// a profile it guards go too: nothing acts on its behalf from now on.
|
||||
await tx.managedProfileHandover.updateMany({
|
||||
where: {
|
||||
OR: [{ profileId: input.userId }, { createdById: input.userId }],
|
||||
usedAt: null,
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: input.requestedAt },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,11 +126,17 @@ export async function lockPendingAccount(
|
||||
};
|
||||
}
|
||||
|
||||
type CancelDeletionOutcome = "cancelled" | "not_found" | "not_pending";
|
||||
type CancelDeletionOutcome =
|
||||
"cancelled" | "not_found" | "not_pending" | "no_guardian";
|
||||
|
||||
/**
|
||||
* Undo a pending deletion. Audit-logged twice: once for the admin who undid
|
||||
* it, once on the account itself so its own activity shows what happened.
|
||||
*
|
||||
* A managed profile comes back only to a Guardian: one whose own account is
|
||||
* not pending deletion. Its Guardians may have left during the window (the
|
||||
* last-Guardian rule no longer counts a profile pending deletion), and a
|
||||
* profile nobody can reach is not something to give back.
|
||||
*/
|
||||
export async function cancelAccountDeletion(input: {
|
||||
userId: string;
|
||||
@@ -125,9 +144,25 @@ export async function cancelAccountDeletion(input: {
|
||||
ipAddress?: string | null;
|
||||
}): Promise<CancelDeletionOutcome> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// The record lock first (the key `withManagedProfileLock` takes), then
|
||||
// the row, in the order the purge takes them, so a Guardian leaving and
|
||||
// this undo are ordered too.
|
||||
await tx.$queryRaw`
|
||||
SELECT 1 AS locked
|
||||
FROM pg_advisory_xact_lock(hashtextextended(${`managed-profile:${input.userId}`}, 0))
|
||||
`;
|
||||
const state = await lockPendingAccount(tx, input.userId);
|
||||
if (!state) return "not_found";
|
||||
if (!state.pending) return "not_pending";
|
||||
if (state.managedProfile) {
|
||||
const guardians = await tx.accountGrant.count({
|
||||
where: {
|
||||
grantorId: input.userId,
|
||||
...remainingGuardianWhere(new Date()),
|
||||
},
|
||||
});
|
||||
if (guardians === 0) return "no_guardian";
|
||||
}
|
||||
await tx.user.update({
|
||||
where: { id: input.userId },
|
||||
data: {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Which `AccountGrant` rows are a managed profile's Guardians. Apart from
|
||||
* `lifecycle.ts` so the account-deletion undo, which `lifecycle.ts` itself
|
||||
* imports, can read the same rule.
|
||||
*/
|
||||
import { ACTIVE_ACCOUNT } from "@/lib/account-deletion/active-account";
|
||||
|
||||
export const activeGuardianWhere = (now: Date) => ({
|
||||
access: "MANAGE" as const,
|
||||
acceptedAt: { not: null },
|
||||
revokedAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
});
|
||||
|
||||
/**
|
||||
* The Guardians the "last Guardian" rule counts: active grants whose holder
|
||||
* is not pending deletion. A Guardian pending deletion cannot sign in, acts
|
||||
* for nobody, and is erased when the window ends, so counting them let a
|
||||
* second Guardian leave on the strength of the first, and left the profile
|
||||
* with nobody once both windows ended.
|
||||
*/
|
||||
export const remainingGuardianWhere = (now: Date) => ({
|
||||
...activeGuardianWhere(now),
|
||||
grantee: ACTIVE_ACCOUNT,
|
||||
});
|
||||
@@ -248,7 +248,11 @@ export async function createHandover(input: {
|
||||
const now = input.now ?? new Date();
|
||||
return prisma.$transaction((tx) =>
|
||||
withManagedProfileLock(tx, input.profileId, async (profile) => {
|
||||
if (!profile?.managedProfileAt) throw new HandoverError("not_found");
|
||||
// A profile pending deletion has left every roster: there is nothing
|
||||
// to hand over.
|
||||
if (!profile?.managedProfileAt || profile.deletionRequestedAt) {
|
||||
throw new HandoverError("not_found");
|
||||
}
|
||||
|
||||
const guardians = await tx.accountGrant.findMany({
|
||||
where: { grantorId: profile.id, ...activeGuardianWhere(now) },
|
||||
@@ -334,9 +338,9 @@ export async function readHandoverStatus(input: {
|
||||
const now = input.now ?? new Date();
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: input.profileId },
|
||||
select: { managedProfileAt: true },
|
||||
select: { managedProfileAt: true, deletionRequestedAt: true },
|
||||
});
|
||||
if (!profile?.managedProfileAt) return null;
|
||||
if (!profile?.managedProfileAt || profile.deletionRequestedAt) return null;
|
||||
if (
|
||||
!(await isActiveGuardian(prisma, input.profileId, input.guardianId, now))
|
||||
) {
|
||||
@@ -446,7 +450,8 @@ export interface HandoverPreview {
|
||||
/**
|
||||
* The open link behind a stored row, or null when it can no longer be
|
||||
* claimed: used, withdrawn, expired, a state this code cannot read, a profile
|
||||
* no longer managed, or a creator who lost access. The one definition of
|
||||
* no longer managed or pending deletion, or a creator who lost access. The
|
||||
* one definition of
|
||||
* "usable" the preview, the single-sign-on start and the callback share.
|
||||
*/
|
||||
async function usableHandover(
|
||||
@@ -463,9 +468,13 @@ async function usableHandover(
|
||||
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: { id: row.profileId },
|
||||
select: { displayName: true, managedProfileAt: true },
|
||||
select: {
|
||||
displayName: true,
|
||||
managedProfileAt: true,
|
||||
deletionRequestedAt: true,
|
||||
},
|
||||
});
|
||||
if (!profile?.managedProfileAt) return null;
|
||||
if (!profile?.managedProfileAt || profile.deletionRequestedAt) return null;
|
||||
if (!(await isActiveGuardian(prisma, row.profileId, row.createdById, now))) {
|
||||
return null;
|
||||
}
|
||||
@@ -589,7 +598,13 @@ export async function claimManagedProfile(input: {
|
||||
try {
|
||||
return await prisma.$transaction((tx) =>
|
||||
withManagedProfileLock(tx, found.profileId, async (profile) => {
|
||||
if (!profile?.managedProfileAt) throw new HandoverError("invalid");
|
||||
// A profile pending deletion is not claimable: the new owner would be
|
||||
// locked out at once and erased when the window ends. Its deletion
|
||||
// revokes the open link too; this holds for a claim that waited on
|
||||
// the lock while the deletion committed.
|
||||
if (!profile?.managedProfileAt || profile.deletionRequestedAt) {
|
||||
throw new HandoverError("invalid");
|
||||
}
|
||||
|
||||
// Re-read under the lock: a new link, a withdrawal or a second claim
|
||||
// that committed while this one waited is seen here.
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
eraseAccount,
|
||||
lockAccountForDeletion,
|
||||
} from "@/lib/account-deletion/lifecycle";
|
||||
import {
|
||||
activeGuardianWhere,
|
||||
remainingGuardianWhere,
|
||||
} from "@/lib/managed-profiles/guardian-where";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { releaseUnbackedClientApps } from "@/lib/integrations/oauth-kit/connect-target";
|
||||
@@ -19,12 +23,26 @@ import {
|
||||
|
||||
type Transaction = Prisma.TransactionClient;
|
||||
|
||||
export const activeGuardianWhere = (now: Date) => ({
|
||||
access: "MANAGE" as const,
|
||||
acceptedAt: { not: null },
|
||||
revokedAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
});
|
||||
export { activeGuardianWhere, remainingGuardianWhere };
|
||||
|
||||
/**
|
||||
* Whether `profileId` keeps a Guardian who is not pending deletion once
|
||||
* `leavingGuardianId` (if any) is gone. Call it under the record lock.
|
||||
*/
|
||||
async function keepsAGuardian(
|
||||
tx: Transaction,
|
||||
profileId: string,
|
||||
leavingGuardianId: string | null,
|
||||
): Promise<boolean> {
|
||||
const remaining = await tx.accountGrant.count({
|
||||
where: {
|
||||
grantorId: profileId,
|
||||
...(leavingGuardianId ? { granteeId: { not: leavingGuardianId } } : {}),
|
||||
...remainingGuardianWhere(new Date()),
|
||||
},
|
||||
});
|
||||
return remaining > 0;
|
||||
}
|
||||
|
||||
export type ManagedProfileLifecycleErrorCode =
|
||||
"not_found" | "not_managed" | "not_guardian" | "managed_grantee";
|
||||
@@ -79,16 +97,17 @@ export async function withManagedProfileLock<T>(
|
||||
export async function reduceManagedProfileGuardian<T>(
|
||||
tx: Transaction,
|
||||
profileId: string,
|
||||
leavingGuardianId: string,
|
||||
reduction: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return withManagedProfileLock(tx, profileId, async (profile) => {
|
||||
if (!profile?.managedProfileAt) return reduction();
|
||||
|
||||
const activeGuardians = await tx.accountGrant.count({
|
||||
where: { grantorId: profile.id, ...activeGuardianWhere(new Date()) },
|
||||
});
|
||||
if (activeGuardians <= 1) throw new LastManagedGuardianError();
|
||||
|
||||
// A profile pending deletion has left every roster; nobody is stranded.
|
||||
if (!profile?.managedProfileAt || profile.deletionRequestedAt) {
|
||||
return reduction();
|
||||
}
|
||||
if (!(await keepsAGuardian(tx, profile.id, leavingGuardianId))) {
|
||||
throw new LastManagedGuardianError();
|
||||
}
|
||||
return reduction();
|
||||
});
|
||||
}
|
||||
@@ -134,11 +153,15 @@ export async function deleteGuardianAccountWithLifecycle<T>(
|
||||
): Promise<T> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
// A profile already pending deletion is not stranded by this account
|
||||
// leaving: it has left every roster and goes at the end of its own
|
||||
// window. Counting it refused a Guardian who had just deleted their only
|
||||
// profile, with nothing left they could do about it.
|
||||
const grants = await tx.accountGrant.findMany({
|
||||
where: {
|
||||
granteeId: guardianId,
|
||||
...activeGuardianWhere(now),
|
||||
grantor: { managedProfileAt: { not: null } },
|
||||
grantor: { managedProfileAt: { not: null }, deletionRequestedAt: null },
|
||||
},
|
||||
select: { grantorId: true },
|
||||
distinct: ["grantorId"],
|
||||
@@ -147,7 +170,7 @@ export async function deleteGuardianAccountWithLifecycle<T>(
|
||||
|
||||
for (const { grantorId } of grants) {
|
||||
await withManagedProfileLock(tx, grantorId, async (profile) => {
|
||||
if (!profile?.managedProfileAt) return;
|
||||
if (!profile?.managedProfileAt || profile.deletionRequestedAt) return;
|
||||
|
||||
const stillActive = await tx.accountGrant.findFirst({
|
||||
where: {
|
||||
@@ -159,10 +182,12 @@ export async function deleteGuardianAccountWithLifecycle<T>(
|
||||
});
|
||||
if (!stillActive) return;
|
||||
|
||||
const activeGuardians = await tx.accountGrant.count({
|
||||
where: { grantorId, ...activeGuardianWhere(new Date()) },
|
||||
});
|
||||
if (activeGuardians <= 1) throw new LastManagedGuardianError();
|
||||
// Another Guardian who is not pending deletion must stay. Counted
|
||||
// under the record lock, so two Guardians deleting at once cannot
|
||||
// each count the other.
|
||||
if (!(await keepsAGuardian(tx, grantorId, guardianId))) {
|
||||
throw new LastManagedGuardianError();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -215,18 +240,21 @@ export async function protectManagedProfilesDuringDataWipe(
|
||||
const now = new Date();
|
||||
for (const { grantorId } of affectedProfiles) {
|
||||
await withManagedProfileLock(tx, grantorId, async (profile) => {
|
||||
if (!profile?.managedProfileAt) return;
|
||||
// A profile pending deletion is stranded by nobody (see the deletion
|
||||
// above); its grants still go with the wipe, as the caller settles.
|
||||
if (!profile?.managedProfileAt || profile.deletionRequestedAt) return;
|
||||
|
||||
// Guardians pending deletion count on neither side.
|
||||
const [activeGuardians, guardiansRemovedByWipe] = await Promise.all([
|
||||
tx.accountGrant.count({
|
||||
where: { grantorId: profile.id, ...activeGuardianWhere(now) },
|
||||
where: { grantorId: profile.id, ...remainingGuardianWhere(now) },
|
||||
}),
|
||||
tx.accountGrant.count({
|
||||
where: {
|
||||
AND: [
|
||||
{ grantorId: profile.id },
|
||||
{ OR: [{ grantorId: userId }, { granteeId: userId }] },
|
||||
activeGuardianWhere(now),
|
||||
remainingGuardianWhere(now),
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
LastManagedGuardianError,
|
||||
ManagedProfileLifecycleError,
|
||||
reduceManagedProfileGuardian,
|
||||
remainingGuardianWhere,
|
||||
withManagedProfileLock,
|
||||
} from "@/lib/managed-profiles/lifecycle";
|
||||
import {
|
||||
@@ -410,10 +411,15 @@ export async function revokeManagedProfileGuardian(input: {
|
||||
target.acceptedAt !== null &&
|
||||
(target.expiresAt === null || target.expiresAt > now);
|
||||
if (targetIsActive) {
|
||||
const activeGuardians = await tx.accountGrant.count({
|
||||
where: { grantorId: profile.id, ...activeGuardianWhere(now) },
|
||||
// Another Guardian who is not pending deletion must stay.
|
||||
const remaining = await tx.accountGrant.count({
|
||||
where: {
|
||||
grantorId: profile.id,
|
||||
granteeId: { not: target.granteeId },
|
||||
...remainingGuardianWhere(now),
|
||||
},
|
||||
});
|
||||
if (activeGuardians <= 1) throw new LastManagedGuardianError();
|
||||
if (remaining === 0) throw new LastManagedGuardianError();
|
||||
}
|
||||
|
||||
await tx.accountGrant.update({
|
||||
@@ -763,7 +769,12 @@ async function endGrant(
|
||||
select: { id: true },
|
||||
});
|
||||
if (!live) return end();
|
||||
return reduceManagedProfileGuardian(db, current.grantorId, end);
|
||||
return reduceManagedProfileGuardian(
|
||||
db,
|
||||
current.grantorId,
|
||||
current.granteeId,
|
||||
end,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -516,6 +516,195 @@ describe("managed-profile deletion with a grace window", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** A managed profile with the given Guardians, each grant accepted. */
|
||||
async function profileGuardedBy(username: string, guardianIds: string[]) {
|
||||
const prisma = getPrismaClient();
|
||||
const profile = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
displayName: "Kid",
|
||||
managedProfileAt: new Date(),
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
for (const granteeId of guardianIds) {
|
||||
await prisma.accountGrant.create({
|
||||
data: {
|
||||
grantorId: profile.id,
|
||||
granteeId,
|
||||
access: "MANAGE",
|
||||
acceptedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
describe("a handover link and a profile pending deletion", () => {
|
||||
it("revokes the open link with the deletion and refuses every way to claim the profile", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const guardian = await seedAccount("guardian");
|
||||
const profile = await profileGuardedBy("managed-teen", [guardian.id]);
|
||||
const { createHandover, previewHandover, claimManagedProfile } =
|
||||
await import("@/lib/managed-profiles/handover");
|
||||
const { deleteManagedProfile } =
|
||||
await import("@/lib/managed-profiles/lifecycle");
|
||||
const { token } = await createHandover({
|
||||
profileId: profile.id,
|
||||
guardianId: guardian.id,
|
||||
expiresInDays: 7,
|
||||
proposals: [],
|
||||
});
|
||||
expect(await previewHandover(token)).not.toBeNull();
|
||||
|
||||
await deleteManagedProfile({
|
||||
profileId: profile.id,
|
||||
guardianId: guardian.id,
|
||||
});
|
||||
const link = await prisma.managedProfileHandover.findFirstOrThrow({
|
||||
where: { profileId: profile.id },
|
||||
});
|
||||
expect(link.revokedAt).not.toBeNull();
|
||||
expect(await previewHandover(token)).toBeNull();
|
||||
|
||||
// A link the deletion did not reach (one that was reopened by hand here)
|
||||
// is still refused: the claim decides on the profile under its lock.
|
||||
await prisma.managedProfileHandover.update({
|
||||
where: { id: link.id },
|
||||
data: { revokedAt: null },
|
||||
});
|
||||
expect(await previewHandover(token)).toBeNull();
|
||||
await expect(
|
||||
claimManagedProfile({
|
||||
link: { rawToken: token },
|
||||
username: "teen-now-adult",
|
||||
email: "teen@example.test",
|
||||
passwordHash: "argon2-hash-placeholder",
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "HandoverError" });
|
||||
const row = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: profile.id },
|
||||
});
|
||||
expect(row.managedProfileAt).not.toBeNull();
|
||||
expect(row.passwordHash).toBeNull();
|
||||
});
|
||||
|
||||
it("revokes the links a Guardian minted when the Guardian deletes their account", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const leaving = await seedAccount("guardian-leaving");
|
||||
const staying = await seedAccount("guardian-staying");
|
||||
const profile = await profileGuardedBy("managed-kid", [
|
||||
leaving.id,
|
||||
staying.id,
|
||||
]);
|
||||
const { createHandover, previewHandover } =
|
||||
await import("@/lib/managed-profiles/handover");
|
||||
const { token } = await createHandover({
|
||||
profileId: profile.id,
|
||||
guardianId: leaving.id,
|
||||
expiresInDays: 7,
|
||||
proposals: [],
|
||||
});
|
||||
await signIn(leaving.id);
|
||||
expect((await deleteOwnAccount()).status).toBe(200);
|
||||
expect(await previewHandover(token)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the last-Guardian rule and accounts pending deletion", () => {
|
||||
it("lets a Guardian whose only profile is pending deletion delete their own account", async () => {
|
||||
const guardian = await seedAccount("guardian");
|
||||
const profile = await profileGuardedBy("managed-kid", [guardian.id]);
|
||||
const { deleteManagedProfile } =
|
||||
await import("@/lib/managed-profiles/lifecycle");
|
||||
await deleteManagedProfile({
|
||||
profileId: profile.id,
|
||||
guardianId: guardian.id,
|
||||
});
|
||||
await signIn(guardian.id);
|
||||
expect((await deleteOwnAccount()).status).toBe(200);
|
||||
});
|
||||
|
||||
it("does not let the second of two Guardians leave on the strength of the first, pending deletion", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const first = await seedAccount("guardian-first");
|
||||
const second = await seedAccount("guardian-second");
|
||||
const profile = await profileGuardedBy("managed-kid", [
|
||||
first.id,
|
||||
second.id,
|
||||
]);
|
||||
await signIn(first.id);
|
||||
expect((await deleteOwnAccount()).status).toBe(200);
|
||||
await signIn(second.id);
|
||||
const refused = await deleteOwnAccount();
|
||||
expect(refused.status).toBe(409);
|
||||
expect(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: second.id } }))
|
||||
.deletionRequestedAt,
|
||||
).toBeNull();
|
||||
|
||||
// Nor through a data wipe, which ends the second Guardian's grant.
|
||||
const { protectManagedProfilesDuringDataWipe } =
|
||||
await import("@/lib/managed-profiles/lifecycle");
|
||||
await expect(
|
||||
prisma.$transaction((tx) =>
|
||||
protectManagedProfilesDuringDataWipe(tx, second.id),
|
||||
),
|
||||
).rejects.toMatchObject({ name: "LastManagedGuardianError" });
|
||||
expect(
|
||||
await prisma.accountGrant.count({ where: { grantorId: profile.id } }),
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
it("does not give back a managed profile whose Guardians left during the window", async () => {
|
||||
const guardian = await seedAccount("guardian");
|
||||
const profile = await profileGuardedBy("managed-kid", [guardian.id]);
|
||||
const { deleteManagedProfile } =
|
||||
await import("@/lib/managed-profiles/lifecycle");
|
||||
await deleteManagedProfile({
|
||||
profileId: profile.id,
|
||||
guardianId: guardian.id,
|
||||
});
|
||||
await signIn(guardian.id);
|
||||
expect((await deleteOwnAccount()).status).toBe(200);
|
||||
|
||||
const admin = await seedAccount("admin", "ADMIN");
|
||||
const { cancelAccountDeletion } =
|
||||
await import("@/lib/account-deletion/lifecycle");
|
||||
expect(
|
||||
await cancelAccountDeletion({ userId: profile.id, adminId: admin.id }),
|
||||
).toBe("no_guardian");
|
||||
// The Guardian's own deletion undone first: the profile can come back.
|
||||
expect(
|
||||
await cancelAccountDeletion({ userId: guardian.id, adminId: admin.id }),
|
||||
).toBe("cancelled");
|
||||
expect(
|
||||
await cancelAccountDeletion({ userId: profile.id, adminId: admin.id }),
|
||||
).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the last admin and admins pending deletion", () => {
|
||||
it("does not let the last usable admin step down while another is pending deletion", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const first = await seedAccount("admin-one", "ADMIN");
|
||||
const second = await seedAccount("admin-two", "ADMIN");
|
||||
await signIn(first.id);
|
||||
expect((await deleteOwnAccount()).status).toBe(200);
|
||||
|
||||
await signIn(second.id);
|
||||
const { PUT } = await import("@/app/api/admin/users/[id]/route");
|
||||
const res = await PUT(
|
||||
jsonRequest(`/api/admin/users/${second.id}`, "PUT", { role: "USER" }),
|
||||
{ params: Promise.resolve({ id: second.id }) },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: second.id } })).role,
|
||||
).toBe("ADMIN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete all data with a grace window", () => {
|
||||
it("keeps a copy of the record that an admin can restore", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
Reference in new issue
Block a user