diff --git a/src/__tests__/managed-profile-contract.test.ts b/src/__tests__/managed-profile-contract.test.ts index a925ecf84..eea6ec3d3 100644 --- a/src/__tests__/managed-profile-contract.test.ts +++ b/src/__tests__/managed-profile-contract.test.ts @@ -31,4 +31,25 @@ describe("managed-profile contract", () => { routes.some((entry) => entry.toLowerCase().includes("emancip")), ).toBe(false); }); + + it("uses the same record lock for every Guardian-reducing transition", async () => { + const lifecycle = await readFile( + path.join(root, "src/lib/managed-profiles/lifecycle.ts"), + "utf8", + ); + const grants = await readFile( + path.join(root, "src/lib/sharing/grants.ts"), + "utf8", + ); + const accountDeletion = await readFile( + path.join(root, "src/app/api/settings/account/route.ts"), + "utf8", + ); + + expect(lifecycle).toContain("pg_advisory_xact_lock"); + expect(lifecycle).toContain("clearManagedProfileMarker"); + expect(lifecycle).toContain("deleteManagedProfile"); + expect(grants).toContain("reduceManagedProfileGuardian"); + expect(accountDeletion).toContain("deleteGuardianAccountWithLifecycle"); + }); }); diff --git a/tests/integration/managed-profile-guardian-concurrency.test.ts b/tests/integration/managed-profile-guardian-concurrency.test.ts new file mode 100644 index 000000000..274f943a8 --- /dev/null +++ b/tests/integration/managed-profile-guardian-concurrency.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { createManagedProfile } from "@/lib/managed-profiles/create"; +import { + acceptGrant, + inviteGrant, + renounceGrant, + revokeGrant, +} from "@/lib/sharing/grants"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +let sequence = 0; + +async function makeAdult(label: string) { + const suffix = sequence++; + return getPrismaClient().user.create({ + data: { + username: `${label}-${suffix}`, + email: `${label}-${suffix}@example.test`, + }, + }); +} + +async function twoGuardians() { + const creator = await makeAdult("creator"); + const secondGuardian = await makeAdult("second-guardian"); + const { profile, creatorGrant } = await createManagedProfile({ + creatorId: creator.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const invitation = await inviteGrant({ + grantorId: profile.id, + granteeId: secondGuardian.id, + access: "MANAGE", + scope: null, + }); + const secondGrant = await acceptGrant({ + grantId: invitation.id, + granteeId: secondGuardian.id, + }); + return { creator, secondGuardian, profile, creatorGrant, secondGrant }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("managed profile Guardian concurrency (real Postgres)", () => { + it("allows only one concurrent reduction of the final two Guardians", async () => { + const { creatorGrant, secondGuardian, secondGrant, profile } = + await twoGuardians(); + + const results = await Promise.allSettled([ + revokeGrant({ grantId: creatorGrant.id, grantorId: profile.id }), + renounceGrant({ + grantId: secondGrant.id, + granteeId: secondGuardian.id, + }), + ]); + + expect( + results.filter((result) => result.status === "fulfilled"), + ).toHaveLength(1); + expect( + results.filter((result) => result.status === "rejected"), + ).toHaveLength(1); + expect( + await getPrismaClient().accountGrant.count({ + where: { + grantorId: profile.id, + access: "MANAGE", + acceptedAt: { not: null }, + revokedAt: null, + }, + }), + ).toBe(1); + }); +}); diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts index cd2f47ff1..f13d8670c 100644 --- a/tests/integration/managed-profile-lifecycle.test.ts +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -9,6 +9,11 @@ import { findActiveGrant, inviteGrant, } from "@/lib/sharing/grants"; +import { + LastManagedGuardianError, + clearManagedProfileMarker, + deleteManagedProfile, +} from "@/lib/managed-profiles/lifecycle"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -210,4 +215,70 @@ describe("managed profile lifecycle (real Postgres)", () => { ), ).not.toBeNull(); }); + + it("refuses to delete a Guardian account when it would leave a managed profile behind", async () => { + const guardian = await signInWithFreshMfa(); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const { DELETE } = await import("@/app/api/settings/account/route"); + + const response = await DELETE( + new NextRequest("http://localhost/api/settings/account", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), + }), + ); + expect(response.status).toBe(409); + expect( + await getPrismaClient().user.findUnique({ where: { id: guardian.id } }), + ).not.toBeNull(); + expect( + await getPrismaClient().user.findUnique({ where: { id: profile.id } }), + ).not.toBeNull(); + }); + + it("deletes a managed profile only through its fresh-MFA Guardian path", async () => { + const guardian = await signInWithFreshMfa(); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + + await deleteManagedProfile({ + profileId: profile.id, + guardianId: guardian.id, + }); + expect( + await getPrismaClient().user.findUnique({ where: { id: profile.id } }), + ).toBeNull(); + }); + + it("keeps marker clearing internal while stopping managed behavior", async () => { + const guardian = await signInWithFreshMfa(); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + + await clearManagedProfileMarker({ profileId: profile.id }); + const cleared = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: profile.id }, + }); + expect(cleared.managedProfileAt).toBeNull(); + await expect( + clearManagedProfileMarker({ profileId: "missing-profile" }), + ).rejects.toBeInstanceOf(LastManagedGuardianError); + }); });