diff --git a/prisma/migrations/0297_managed_profile/migration.sql b/prisma/migrations/0297_managed_profile/migration.sql index d7d309e46..83b0a252e 100644 --- a/prisma/migrations/0297_managed_profile/migration.sql +++ b/prisma/migrations/0297_managed_profile/migration.sql @@ -16,11 +16,10 @@ -- -- A timestamp rather than a boolean, deliberately. It says since when, which -- the activity trail needs to be readable across the transition, and clearing --- it is the emancipation act: the day the person takes the record over, --- credentials attach (`email` and `password_hash` are already nullable, so --- nothing here has to change for that) and this goes back to NULL. The grants --- survive untouched — the handover changes who holds the revoke button, not --- which rows exist. +-- it leaves a future-compatible NULL state. This migration deliberately adds +-- no transition route or credential attachment flow. If a later handover is +-- designed, `email` and `password_hash` are already nullable and the grants +-- can survive untouched while the product defines who holds the revoke button. -- -- No index. The column is read by primary key, on a row the request already -- loaded, and the one enumeration that scans for marked rows (the reminder diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 651897559..457e3a151 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -62,16 +62,16 @@ model User { /// former and stay shut for the latter, and the gate is this column rather /// than the grant, so no argument about a grant level can reach them. /// - /// A timestamp rather than a boolean, for a reason the schema has to keep - /// possible: it says since when, and clearing it is the emancipation act — - /// the day the person takes their own record over, credentials attach and - /// this goes back to NULL, with the grants and the whole activity trail - /// spanning the transition untouched. + /// A timestamp rather than a boolean: it says since when, and preserves a + /// future-compatible null state without adding a transition surface here. + /// This release does not expose a way to clear the marker or attach + /// credentials; the grants and activity trail remain intact if a later, + /// separately designed handover ever needs that state. /// /// Consumers land in this release: the guardian resolver in - /// `src/lib/api-handler.ts` reads it on every guardian request, the - /// notification dispatcher fans reminders out to the guardians' channels on - /// it, and the creation flow is what writes it. + /// `src/lib/api-handler.ts` reads it on every guardian request, and the + /// creation flow is what writes it. A later notification plan may add + /// delivery fanout after it defines consent and preference semantics. managedProfileAt DateTime? @map("managed_profile_at") // Profile @@ -6036,7 +6036,10 @@ model AccountGrant { revokedBy AccountGrantRevoker? @map("revoked_by") /// Optional lapse date. Checked live on every resolution, so an expired - /// grant stops conferring access without anything having to sweep it. + /// grant stops conferring access without anything having to sweep it. A + /// managed-profile invitation may carry an expiry while awaiting consent; + /// accepting its MANAGE grant clears this field because an active Guardian + /// ends only through an explicit lifecycle transition. expiresAt DateTime? @map("expires_at") /// Refreshed fire-and-forget when the grant is exercised, the same posture /// as `ApiToken.lastUsedAt`. The owner's panel reads it to answer "is this diff --git a/src/__tests__/managed-profile-contract.test.ts b/src/__tests__/managed-profile-contract.test.ts new file mode 100644 index 000000000..aff12b1ea --- /dev/null +++ b/src/__tests__/managed-profile-contract.test.ts @@ -0,0 +1,75 @@ +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const root = process.cwd(); + +describe("managed-profile contract", () => { + it("requires a cookie-backed fresh MFA proof for creation", async () => { + const source = await readFile( + path.join(root, "src/app/api/managed-profiles/route.ts"), + "utf8", + ); + + expect(source).toContain("requireFreshMfa"); + expect(source).not.toContain("requireFreshMfaIfEnrolled"); + }); + + it("requires a cookie-backed fresh MFA proof for profile deletion", async () => { + const source = await readFile( + path.join(root, "src/app/api/managed-profiles/[id]/route.ts"), + "utf8", + ); + + expect(source).toContain("requireFreshMfa"); + expect(source).not.toContain("requireFreshMfaIfEnrolled"); + }); + + it("does not publish an emancipation API surface", async () => { + const source = await readFile( + path.join(root, "prisma/schema.prisma"), + "utf8", + ); + + expect(source.toLowerCase()).not.toContain("emancipation"); + + const routes = await readdir( + path.join(root, "src/app/api/managed-profiles"), + { recursive: true }, + ); + expect( + routes.some((entry) => entry.toLowerCase().includes("emancip")), + ).toBe(false); + }); + + it("does not claim notification fanout before its consent plan exists", async () => { + const source = await readFile( + path.join(root, "prisma/schema.prisma"), + "utf8", + ); + + expect(source).not.toContain("notification dispatcher fans reminders"); + expect(source).toContain("later notification plan"); + }); + + 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/src/app/api/account/grants/[id]/renounce/route.ts b/src/app/api/account/grants/[id]/renounce/route.ts index 5ab130a1d..2e5556a07 100644 --- a/src/app/api/account/grants/[id]/renounce/route.ts +++ b/src/app/api/account/grants/[id]/renounce/route.ts @@ -35,6 +35,7 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view"; +import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle"; import { GrantError, renounceGrantAndClearSwitch } from "@/lib/sharing/grants"; type RouteParams = { params: Promise<{ id: string }> }; @@ -76,6 +77,11 @@ export const POST = apiHandler( }); } catch (err) { if (err instanceof GrantError) return renounceErrorResponse(err); + if (err instanceof LastManagedGuardianError) { + return apiError("Add another Guardian before ending this access", 409, { + errorCode: "managed_profile.guardian.required", + }); + } throw err; } }, diff --git a/src/app/api/account/grants/[id]/route.ts b/src/app/api/account/grants/[id]/route.ts index 0fceca8b0..733098b39 100644 --- a/src/app/api/account/grants/[id]/route.ts +++ b/src/app/api/account/grants/[id]/route.ts @@ -34,6 +34,7 @@ import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view"; +import { LastManagedGuardianError } from "@/lib/managed-profiles/lifecycle"; import { GrantError, revokeGrantAndClearSwitch } from "@/lib/sharing/grants"; type RouteParams = { params: Promise<{ id: string }> }; @@ -75,6 +76,11 @@ export const DELETE = apiHandler( }); } catch (err) { if (err instanceof GrantError) return revokeErrorResponse(err); + if (err instanceof LastManagedGuardianError) { + return apiError("Add another Guardian before ending this access", 409, { + errorCode: "managed_profile.guardian.required", + }); + } throw err; } }, diff --git a/src/app/api/managed-profiles/[id]/guardians/[grantId]/route.ts b/src/app/api/managed-profiles/[id]/guardians/[grantId]/route.ts new file mode 100644 index 000000000..6b33c31d0 --- /dev/null +++ b/src/app/api/managed-profiles/[id]/guardians/[grantId]/route.ts @@ -0,0 +1,49 @@ +import { NextRequest } from "next/server"; + +import { + apiHandler, + MFA_STEP_UP_MAX_AGE_SECONDS, + requireFreshMfa, +} from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { + LastManagedGuardianError, + ManagedProfileLifecycleError, +} from "@/lib/managed-profiles/lifecycle"; +import { revokeManagedProfileGuardian } from "@/lib/sharing/grants"; +import { annotate } from "@/lib/logging/context"; + +type RouteParams = { params: Promise<{ id: string; grantId: string }> }; + +/** End another Guardian's access from a freshly verified browser session. */ +export const DELETE = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + const { id, grantId } = await params; + + try { + const grant = await revokeManagedProfileGuardian({ + profileId: id, + guardianId: user.id, + grantId, + }); + annotate({ + action: { name: "managed_profile.guardian.revoked" }, + meta: { profile_id: id, grant_id: grant.id }, + }); + return apiSuccess(grant); + } catch (error) { + if (error instanceof LastManagedGuardianError) { + return apiError("Add another Guardian before ending this access", 409, { + errorCode: "managed_profile.guardian.required", + }); + } + if (error instanceof ManagedProfileLifecycleError) { + return apiError("Managed profile or Guardian not found", 404, { + errorCode: "managed_profile.not_found", + }); + } + throw error; + } + }, +); diff --git a/src/app/api/managed-profiles/[id]/guardians/route.ts b/src/app/api/managed-profiles/[id]/guardians/route.ts new file mode 100644 index 000000000..80b8a13de --- /dev/null +++ b/src/app/api/managed-profiles/[id]/guardians/route.ts @@ -0,0 +1,102 @@ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { + apiHandler, + MFA_STEP_UP_MAX_AGE_SECONDS, + requireFreshMfa, +} from "@/lib/api-handler"; +import { + apiError, + apiSuccess, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { ManagedProfileLifecycleError } from "@/lib/managed-profiles/lifecycle"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { GrantError, inviteManagedProfileGuardian } from "@/lib/sharing/grants"; + +type RouteParams = { params: Promise<{ id: string }> }; + +const INVITE_LIMIT = 10; +const INVITE_WINDOW_MS = 60 * 60 * 1000; + +const guardianInviteSchema = z + .object({ + identifier: z.string().trim().min(1).max(255), + expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + }) + .strict(); + +/** Invite another Guardian from a cookie-backed, freshly verified session. */ +export const POST = apiHandler( + async (request: NextRequest, { params }: RouteParams) => { + const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + const rateLimit = await checkRateLimit( + `managed-profile:guardian-invite:${user.id}`, + INVITE_LIMIT, + INVITE_WINDOW_MS, + ); + if (!rateLimit.allowed) { + return apiError("Too many invitations, try again later", 429); + } + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 8 * 1024, + }); + if (jsonError) return jsonError; + const parsed = guardianInviteSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + const { id } = await params; + + const invitee = await prisma.user.findFirst({ + where: { + OR: [ + { username: { equals: parsed.data.identifier, mode: "insensitive" } }, + { email: { equals: parsed.data.identifier, mode: "insensitive" } }, + ], + }, + select: { id: true }, + }); + if (!invitee) return apiError("No account with that name or e-mail", 404); + + try { + const grant = await inviteManagedProfileGuardian({ + profileId: id, + guardianId: user.id, + granteeId: invitee.id, + expiresAt: parsed.data.expiresAt + ? new Date(parsed.data.expiresAt) + : null, + }); + annotate({ + action: { name: "managed_profile.guardian.invited" }, + meta: { profile_id: id, grant_id: grant.id }, + }); + return apiSuccess(grant, 201); + } catch (error) { + if (error instanceof GrantError && error.code === "self_grant") { + return apiError("An account cannot be shared with itself", 422, { + errorCode: "managed_profile.guardian.self", + }); + } + if (error instanceof GrantError) { + return apiError("That account already has access", 409, { + errorCode: "managed_profile.guardian.duplicate", + }); + } + if (error instanceof ManagedProfileLifecycleError) { + if (error.code === "managed_grantee") { + return apiError("A managed profile cannot be a Guardian", 422, { + errorCode: "managed_profile.guardian.managed_invitee", + }); + } + return apiError("Managed profile not found", 404, { + errorCode: "managed_profile.not_found", + }); + } + throw error; + } + }, +); diff --git a/src/app/api/managed-profiles/[id]/route.ts b/src/app/api/managed-profiles/[id]/route.ts new file mode 100644 index 000000000..e952f5f80 --- /dev/null +++ b/src/app/api/managed-profiles/[id]/route.ts @@ -0,0 +1,40 @@ +import { NextRequest } from "next/server"; + +import { + apiHandler, + MFA_STEP_UP_MAX_AGE_SECONDS, + requireFreshMfa, +} from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { + deleteManagedProfile, + ManagedProfileLifecycleError, +} from "@/lib/managed-profiles/lifecycle"; +import { annotate } from "@/lib/logging/context"; + +type RouteParams = { params: Promise<{ id: string }> }; + +/** Delete a managed record only after its cookie-backed Guardian proves MFA. */ +export const DELETE = apiHandler( + async (_request: NextRequest, { params }: RouteParams) => { + const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + const { id } = await params; + + try { + await deleteManagedProfile({ profileId: id, guardianId: user.id }); + } catch (error) { + if (error instanceof ManagedProfileLifecycleError) { + return apiError("Managed profile not found", 404, { + errorCode: "managed_profile.not_found", + }); + } + throw error; + } + + annotate({ + action: { name: "managed_profile.delete" }, + meta: { profile_id: id }, + }); + return apiSuccess({ deleted: true }); + }, +); diff --git a/src/app/api/managed-profiles/route.ts b/src/app/api/managed-profiles/route.ts new file mode 100644 index 000000000..964a6e0bf --- /dev/null +++ b/src/app/api/managed-profiles/route.ts @@ -0,0 +1,73 @@ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { + apiHandler, + MFA_STEP_UP_MAX_AGE_SECONDS, + requireFreshMfa, +} from "@/lib/api-handler"; +import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; +import { createManagedProfile } from "@/lib/managed-profiles/create"; +import { annotate } from "@/lib/logging/context"; +import { isValidTimezone } from "@/lib/tz/format"; + +const managedProfileSchema = z + .object({ + displayName: z.string().trim().min(1).max(80), + dateOfBirth: z.iso.date().nullable().optional(), + locale: z.enum(["de", "en", "es", "fr", "it", "pl"]), + timezone: z + .string() + .min(1) + .max(64) + .refine(isValidTimezone, "Invalid IANA timezone"), + }) + .strict(); + +/** + * Create a record a Guardian administers. This deliberately uses the + * cookie-only fresh-MFA gate: a Bearer token cannot mint a credential-less + * person and persistent management relationship. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireFreshMfa(MFA_STEP_UP_MAX_AGE_SECONDS); + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 64 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = managedProfileSchema.safeParse(body); + if (!parsed.success) return returnAllZodIssues(parsed.error, 422); + + const { profile, creatorGrant } = await createManagedProfile({ + creatorId: user.id, + displayName: parsed.data.displayName, + dateOfBirth: parsed.data.dateOfBirth + ? new Date(`${parsed.data.dateOfBirth}T00:00:00.000Z`) + : null, + locale: parsed.data.locale, + timezone: parsed.data.timezone, + }); + + await auditLog("managed_profile.created", { + userId: user.id, + details: { profileId: profile.id, grantId: creatorGrant.id }, + }).catch(() => {}); + annotate({ + action: { name: "managed_profile.create" }, + meta: { profile_id: profile.id }, + }); + + return apiSuccess( + { + id: profile.id, + displayName: profile.displayName, + dateOfBirth: profile.dateOfBirth, + locale: profile.locale, + timezone: profile.timezone, + recordKind: "managed" as const, + }, + 201, + ); +}); diff --git a/src/app/api/settings/account/route.ts b/src/app/api/settings/account/route.ts index 1b6125a76..d976062ab 100644 --- a/src/app/api/settings/account/route.ts +++ b/src/app/api/settings/account/route.ts @@ -1,7 +1,6 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; -import { destroyAllSessions } from "@/lib/auth/session"; import { NextRequest } from "next/server"; import { apiHandler, @@ -9,6 +8,10 @@ import { MFA_STEP_UP_MAX_AGE_SECONDS, } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { + deleteGuardianAccountWithLifecycle, + LastManagedGuardianError, +} from "@/lib/managed-profiles/lifecycle"; export const dynamic = "force-dynamic"; @@ -54,27 +57,41 @@ export const DELETE = apiHandler(async (request: NextRequest) => { const userId = user.id; const username = user.username; - // Log BEFORE deletion. The audit row's userId is SetNull post-cascade - // (per schema), but we then immediately purge it below for GDPR Art. 17 - // erasure completeness — see comment further down. - await auditLog("user.account.delete", { - userId, - ipAddress: getClientIp(request), - details: { username }, - }); + try { + await deleteGuardianAccountWithLifecycle(userId, async (tx) => { + // The invariant has now passed, so this success event cannot survive a + // refusal. It is purged with the rest of the account's audit history. + await auditLog("user.account.delete", { + userId, + ipAddress: getClientIp(request), + details: { username }, + client: tx, + }); - // Destroy all sessions first - await destroyAllSessions(userId); + // Destroy all sessions before the user row goes, inside the same record + // lifecycle transaction so a refusal leaves every account state intact. + await tx.session.deleteMany({ where: { userId } }); - // Audit V3 NEW-V3-2 / GDPR Art. 17 fix: AuditLog rows have - // `onDelete: SetNull` in the schema, which keeps PII (IP addresses, login - // city geo) attached to the record after the user is deleted. We explicitly - // purge them inside the same logical operation so account deletion is - // genuinely complete erasure. - await prisma.auditLog.deleteMany({ where: { userId } }); + // Audit V3 NEW-V3-2 / GDPR Art. 17 fix: AuditLog rows have + // `onDelete: SetNull` in the schema, which keeps PII (IP addresses, login + // city geo) attached to the record after the user is deleted. We explicitly + // purge them inside the same logical operation so account deletion is + // genuinely complete erasure. + await tx.auditLog.deleteMany({ where: { userId } }); - // Delete user — all other related data is removed via onDelete: Cascade - await prisma.user.delete({ where: { id: userId } }); + // Delete user — all other related data is removed via onDelete: Cascade. + await tx.user.delete({ where: { id: userId } }); + }); + } catch (error) { + if (error instanceof LastManagedGuardianError) { + return apiError( + "Add another Guardian or delete the managed profile first", + 409, + { errorCode: "managed_profile.guardian.required" }, + ); + } + throw error; + } annotate({ action: { name: "settings.account.delete" }, diff --git a/src/lib/managed-profiles/create.ts b/src/lib/managed-profiles/create.ts new file mode 100644 index 000000000..431e36373 --- /dev/null +++ b/src/lib/managed-profiles/create.ts @@ -0,0 +1,88 @@ +import { randomBytes } from "node:crypto"; + +import { Prisma } from "@/generated/prisma/client"; +import type { AccountGrant, User } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; + +const USERNAME_ATTEMPTS = 4; + +export interface CreateManagedProfileInput { + creatorId: string; + displayName: string; + dateOfBirth: Date | null; + locale: string; + timezone: string; +} + +export interface CreatedManagedProfile { + profile: User; + creatorGrant: AccountGrant; +} + +export class ManagedProfileCreationError extends Error { + constructor() { + super("Unable to allocate a managed profile identity"); + this.name = "ManagedProfileCreationError"; + } +} + +function managedUsername(): string { + return `managed-${randomBytes(18).toString("hex")}`; +} + +function isUniqueViolation(error: unknown): boolean { + return ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ); +} + +/** + * Create the credential-less record and its first Guardian grant together. + * + * A profile is never useful before the creator can manage it, and a creator + * grant without its record is invalid by definition. Keeping both writes in + * one transaction makes a failed grant insert leave no login-less user behind. + */ +export async function createManagedProfile( + input: CreateManagedProfileInput, +): Promise { + for (let attempt = 0; attempt < USERNAME_ATTEMPTS; attempt += 1) { + try { + return await prisma.$transaction(async (tx) => { + const now = new Date(); + const profile = await tx.user.create({ + data: { + username: managedUsername(), + email: null, + passwordHash: null, + displayName: input.displayName, + dateOfBirth: input.dateOfBirth, + locale: input.locale, + timezone: input.timezone, + managedProfileAt: now, + }, + }); + const creatorGrant = await tx.accountGrant.create({ + data: { + grantorId: profile.id, + granteeId: input.creatorId, + access: "MANAGE", + scopeJson: Prisma.DbNull, + invitedAt: now, + acceptedAt: now, + expiresAt: null, + }, + }); + return { profile, creatorGrant }; + }); + } catch (error) { + if (isUniqueViolation(error) && attempt + 1 < USERNAME_ATTEMPTS) { + continue; + } + throw error; + } + } + + throw new ManagedProfileCreationError(); +} diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts new file mode 100644 index 000000000..8bb929660 --- /dev/null +++ b/src/lib/managed-profiles/lifecycle.ts @@ -0,0 +1,173 @@ +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import type { Prisma } from "@/generated/prisma/client"; + +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 type ManagedProfileLifecycleErrorCode = + "not_found" | "not_managed" | "not_guardian" | "managed_grantee"; + +export class ManagedProfileLifecycleError extends Error { + constructor(readonly code: ManagedProfileLifecycleErrorCode) { + super(`managed profile lifecycle refused: ${code}`); + this.name = "ManagedProfileLifecycleError"; + } +} + +/** A refusal the caller can recover from by adding another Guardian. */ +export class LastManagedGuardianError extends Error { + constructor() { + super("A managed profile needs another Guardian before this change"); + this.name = "LastManagedGuardianError"; + } +} + +/** + * Serialize lifecycle changes on one managed-record key for the lifetime of + * the caller's transaction. The lock is held before re-reading the marker and + * every active Guardian row, so a second reducer sees the first committed + * change instead of making its decision from a stale count. + */ +export async function withManagedProfileLock( + tx: Transaction, + profileId: string, + operation: ( + profile: { id: string; managedProfileAt: Date | null } | null, + ) => Promise, +): Promise { + await tx.$queryRaw` + SELECT 1 AS locked + FROM pg_advisory_xact_lock(hashtextextended(${`managed-profile:${profileId}`}, 0)) + `; + const profile = await tx.user.findUnique({ + where: { id: profileId }, + select: { id: true, managedProfileAt: true }, + }); + return operation(profile); +} + +/** + * Run one removal of an active Guardian after holding the record lock and + * re-reading the invariant in the same transaction. + */ +export async function reduceManagedProfileGuardian( + tx: Transaction, + profileId: string, + reduction: () => Promise, +): Promise { + 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(); + + return reduction(); + }); +} + +/** + * Keep account deletion and its Guardian checks inside the same transaction. + * Profiles are locked in id order so an account protecting more than one + * profile cannot deadlock with a concurrent lifecycle operation. + */ +export async function deleteGuardianAccountWithLifecycle( + guardianId: string, + deleteAccount: (tx: Transaction) => Promise, +): Promise { + return prisma.$transaction(async (tx) => { + const now = new Date(); + const grants = await tx.accountGrant.findMany({ + where: { + granteeId: guardianId, + ...activeGuardianWhere(now), + grantor: { managedProfileAt: { not: null } }, + }, + select: { grantorId: true }, + distinct: ["grantorId"], + orderBy: { grantorId: "asc" }, + }); + + for (const { grantorId } of grants) { + await withManagedProfileLock(tx, grantorId, async (profile) => { + if (!profile?.managedProfileAt) return; + + const stillActive = await tx.accountGrant.findFirst({ + where: { + grantorId, + granteeId: guardianId, + ...activeGuardianWhere(new Date()), + }, + select: { id: true }, + }); + if (!stillActive) return; + + const activeGuardians = await tx.accountGrant.count({ + where: { grantorId, ...activeGuardianWhere(new Date()) }, + }); + if (activeGuardians <= 1) throw new LastManagedGuardianError(); + }); + } + + return deleteAccount(tx); + }); +} + +export async function deleteManagedProfile(input: { + profileId: string; + guardianId: string; +}): Promise { + await prisma.$transaction(async (tx) => { + await withManagedProfileLock(tx, input.profileId, async (profile) => { + if (!profile) throw new ManagedProfileLifecycleError("not_found"); + if (!profile.managedProfileAt) { + throw new ManagedProfileLifecycleError("not_managed"); + } + + const guardian = await tx.accountGrant.findFirst({ + where: { + grantorId: profile.id, + granteeId: input.guardianId, + ...activeGuardianWhere(new Date()), + }, + select: { id: true }, + }); + if (!guardian) throw new ManagedProfileLifecycleError("not_guardian"); + + await auditLog("managed_profile.deleted", { + userId: input.guardianId, + details: { profileId: profile.id }, + client: tx, + }); + await tx.user.delete({ where: { id: profile.id } }); + }); + }); +} + +/** + * Internal-only future compatibility hook. There is intentionally no route for + * this state change in the current product; holding the same lock ensures any + * later handover cannot race a Guardian reduction. + */ +export async function clearManagedProfileMarker(input: { + profileId: string; +}): Promise { + await prisma.$transaction(async (tx) => { + await withManagedProfileLock(tx, input.profileId, async (profile) => { + if (!profile) throw new ManagedProfileLifecycleError("not_found"); + if (!profile.managedProfileAt) return; + await tx.user.update({ + where: { id: profile.id }, + data: { managedProfileAt: null }, + }); + }); + }); +} diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 908294b4e..06fdc954a 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -52,8 +52,16 @@ * transition computed against the stale row. */ import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; import { isP2002 } from "@/lib/prisma-errors"; import { clearActingSessions } from "@/lib/sharing/acting-session"; +import { + activeGuardianWhere, + LastManagedGuardianError, + ManagedProfileLifecycleError, + reduceManagedProfileGuardian, + withManagedProfileLock, +} from "@/lib/managed-profiles/lifecycle"; import { ENTIRE_RECORD, isShareDomain } from "@/lib/sharing/scope"; import type { ShareDomain, ShareScope } from "@/lib/sharing/scope"; import { Prisma } from "@/generated/prisma/client"; @@ -414,25 +422,170 @@ export interface AcceptGrantInput { */ export async function acceptGrant( input: AcceptGrantInput, - db: GrantDb = prisma, ): Promise { - const now = new Date(); - const { count } = await db.accountGrant.updateMany({ - where: { - id: input.grantId, - granteeId: input.granteeId, - acceptedAt: null, - revokedAt: null, - OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], - }, - data: { acceptedAt: now }, - }); + return prisma.$transaction((tx) => acceptGrantInTransaction(input, tx)); +} - if (count === 0) { - throw await refusalFor(input.grantId, { granteeId: input.granteeId }, db); +async function acceptGrantInTransaction( + input: AcceptGrantInput, + tx: Prisma.TransactionClient, +): Promise { + const candidate = await tx.accountGrant.findUnique({ + where: { id: input.grantId }, + select: { grantorId: true, access: true }, + }); + if (!candidate) { + throw await refusalFor(input.grantId, { granteeId: input.granteeId }, tx); } - return db.accountGrant.findUniqueOrThrow({ where: { id: input.grantId } }); + const accept = async (managedProfile: boolean) => { + const now = new Date(); + const { count } = await tx.accountGrant.updateMany({ + where: { + id: input.grantId, + granteeId: input.granteeId, + acceptedAt: null, + revokedAt: null, + OR: [{ expiresAt: null }, { expiresAt: { gt: now } }], + }, + data: { + acceptedAt: now, + ...(managedProfile ? { expiresAt: null } : {}), + }, + }); + if (count === 0) { + throw await refusalFor(input.grantId, { granteeId: input.granteeId }, tx); + } + return tx.accountGrant.findUniqueOrThrow({ where: { id: input.grantId } }); + }; + + if (candidate.access !== "MANAGE") return accept(false); + + return withManagedProfileLock(tx, candidate.grantorId, async (profile) => + accept(Boolean(profile?.managedProfileAt)), + ); +} + +export async function inviteManagedProfileGuardian(input: { + profileId: string; + guardianId: string; + granteeId: string; + expiresAt?: Date | null; +}): Promise { + return prisma.$transaction(async (tx) => { + return withManagedProfileLock(tx, input.profileId, async (profile) => { + if (!profile) throw new ManagedProfileLifecycleError("not_found"); + if (!profile.managedProfileAt) { + throw new ManagedProfileLifecycleError("not_managed"); + } + const guardian = await tx.accountGrant.findFirst({ + where: { + grantorId: profile.id, + granteeId: input.guardianId, + ...activeGuardianWhere(new Date()), + }, + select: { id: true }, + }); + if (!guardian) throw new ManagedProfileLifecycleError("not_guardian"); + const grantee = await tx.user.findUnique({ + where: { id: input.granteeId }, + select: { managedProfileAt: true }, + }); + if (grantee?.managedProfileAt) { + throw new ManagedProfileLifecycleError("managed_grantee"); + } + const grant = await inviteGrant( + { + grantorId: profile.id, + granteeId: input.granteeId, + access: "MANAGE", + scope: null, + expiresAt: input.expiresAt ?? null, + }, + tx, + ); + await auditLog("managed_profile.guardian.invited", { + userId: profile.id, + actorUserId: input.guardianId, + details: { grantId: grant.id }, + client: tx, + }); + return grant; + }); + }); +} + +export async function revokeManagedProfileGuardian(input: { + profileId: string; + guardianId: string; + grantId: string; +}): Promise { + return prisma.$transaction(async (tx) => { + return withManagedProfileLock(tx, input.profileId, async (profile) => { + if (!profile) throw new ManagedProfileLifecycleError("not_found"); + if (!profile.managedProfileAt) { + throw new ManagedProfileLifecycleError("not_managed"); + } + const requester = await tx.accountGrant.findFirst({ + where: { + grantorId: profile.id, + granteeId: input.guardianId, + ...activeGuardianWhere(new Date()), + }, + select: { id: true }, + }); + if (!requester) throw new ManagedProfileLifecycleError("not_guardian"); + + const target = await tx.accountGrant.findFirst({ + where: { + id: input.grantId, + grantorId: profile.id, + access: "MANAGE", + revokedAt: null, + }, + select: { + id: true, + granteeId: true, + acceptedAt: true, + expiresAt: true, + }, + }); + if (!target) throw new ManagedProfileLifecycleError("not_guardian"); + if (target.granteeId === input.guardianId) { + throw new ManagedProfileLifecycleError("not_guardian"); + } + + const now = new Date(); + const targetIsActive = + target.acceptedAt !== null && + (target.expiresAt === null || target.expiresAt > now); + if (targetIsActive) { + const activeGuardians = await tx.accountGrant.count({ + where: { grantorId: profile.id, ...activeGuardianWhere(now) }, + }); + if (activeGuardians <= 1) throw new LastManagedGuardianError(); + } + + await tx.accountGrant.update({ + where: { id: target.id }, + // `revokedBy` records which grant party withdrew access. The managed + // profile is the grantor; the individual Guardian is durable audit + // attribution below rather than an overloaded enum value. + data: { revokedAt: now, revokedBy: "GRANTOR" }, + }); + await clearActingSessions( + { grantorId: profile.id, granteeId: target.granteeId }, + tx, + ); + await auditLog("managed_profile.guardian.revoked", { + userId: profile.id, + actorUserId: input.guardianId, + details: { grantId: target.id }, + client: tx, + }); + return tx.accountGrant.findUniqueOrThrow({ where: { id: target.id } }); + }); + }); } export interface RevokeGrantInput { @@ -451,9 +604,10 @@ export interface RevokeGrantInput { */ export async function revokeGrant( input: RevokeGrantInput, - db: GrantDb = prisma, ): Promise { - return endGrant(input.grantId, { grantorId: input.grantorId }, "GRANTOR", db); + return prisma.$transaction((tx) => + endGrant(input.grantId, { grantorId: input.grantorId }, "GRANTOR", tx), + ); } export interface RenounceGrantInput { @@ -472,9 +626,10 @@ export interface RenounceGrantInput { */ export async function renounceGrant( input: RenounceGrantInput, - db: GrantDb = prisma, ): Promise { - return endGrant(input.grantId, { granteeId: input.granteeId }, "GRANTEE", db); + return prisma.$transaction((tx) => + endGrant(input.grantId, { granteeId: input.granteeId }, "GRANTEE", tx), + ); } /** @@ -511,7 +666,9 @@ export interface EndedGrant { export async function revokeGrantAndClearSwitch( input: RevokeGrantInput, ): Promise { - return endAndClear((tx) => revokeGrant(input, tx)); + return endAndClear((tx) => + endGrant(input.grantId, { grantorId: input.grantorId }, "GRANTOR", tx), + ); } /** @@ -525,7 +682,9 @@ export async function revokeGrantAndClearSwitch( export async function renounceGrantAndClearSwitch( input: RenounceGrantInput, ): Promise { - return endAndClear((tx) => renounceGrant(input, tx)); + return endAndClear((tx) => + endGrant(input.grantId, { granteeId: input.granteeId }, "GRANTEE", tx), + ); } // ── Resolution ────────────────────────────────────────────────────────────── @@ -603,16 +762,42 @@ async function endGrant( grantId: string, actor: { grantorId: string } | { granteeId: string }, revokedBy: "GRANTOR" | "GRANTEE", - db: GrantDb, + db: Prisma.TransactionClient, ): Promise { - const { count } = await db.accountGrant.updateMany({ - where: { id: grantId, ...actor, revokedAt: null }, - data: { revokedAt: new Date(), revokedBy }, + const current = await db.accountGrant.findUnique({ + where: { id: grantId }, + select: { + grantorId: true, + granteeId: true, + access: true, + acceptedAt: true, + revokedAt: true, + expiresAt: true, + }, }); - if (count === 0) throw await refusalFor(grantId, actor, db); + const end = async () => { + const { count } = await db.accountGrant.updateMany({ + where: { id: grantId, ...actor, revokedAt: null }, + data: { revokedAt: new Date(), revokedBy }, + }); + if (count === 0) throw await refusalFor(grantId, actor, db); + return db.accountGrant.findUniqueOrThrow({ where: { id: grantId } }); + }; - return db.accountGrant.findUniqueOrThrow({ where: { id: grantId } }); + const actorOwnsGrant = + current && + ("grantorId" in actor + ? current.grantorId === actor.grantorId + : current.granteeId === actor.granteeId); + const isActiveManagedGuardian = + current?.access === "MANAGE" && + current.acceptedAt !== null && + current.revokedAt === null && + (current.expiresAt === null || current.expiresAt > new Date()); + + if (!actorOwnsGrant || !isActiveManagedGuardian) return end(); + return reduceManagedProfileGuardian(db, current.grantorId, end); } /** 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 new file mode 100644 index 000000000..39c7219e2 --- /dev/null +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -0,0 +1,882 @@ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar, queuedSessionIds } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; +import { createManagedProfile } from "@/lib/managed-profiles/create"; +import { + acceptGrant, + findActiveGrant, + inviteGrant, +} from "@/lib/sharing/grants"; +import { + clearManagedProfileMarker, + ManagedProfileLifecycleError, +} from "@/lib/managed-profiles/lifecycle"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar, queuedSessionIds } = + await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => { + const snapshot = new Map(cookieJar); + const queuedSessionId = queuedSessionIds.shift(); + if (queuedSessionId) { + snapshot.set("healthlog_session", queuedSessionId); + } + return { + get: (name: string) => { + const value = snapshot.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + }; + }), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +let sequence = 0; + +async function createFreshMfaUser(label = "guardian") { + const suffix = sequence++; + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + username: `${label}-${suffix}`, + email: `${label}-${suffix}@example.test`, + totpConfirmedAt: new Date(), + }, + }); + const session = await prisma.session.create({ + data: { + userId: user.id, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + mfaVerifiedAt: new Date(), + }, + }); + return { user, sessionId: session.id }; +} + +async function signInWithFreshMfa() { + const { user, sessionId } = await createFreshMfaUser(); + cookieJar.set("healthlog_session", sessionId); + return user; +} + +async function makeAdult(label: string) { + const suffix = sequence++; + return getPrismaClient().user.create({ + data: { + username: `${label}-${suffix}`, + email: `${label}-${suffix}@example.test`, + }, + }); +} + +async function createTwoGuardians() { + const creator = await createFreshMfaUser("creator"); + const secondGuardian = await createFreshMfaUser("second-guardian"); + const { profile, creatorGrant } = await createManagedProfile({ + creatorId: creator.user.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const invitation = await inviteGrant({ + grantorId: profile.id, + granteeId: secondGuardian.user.id, + access: "MANAGE", + scope: null, + }); + const secondGrant = await acceptGrant({ + grantId: invitation.id, + granteeId: secondGuardian.user.id, + }); + return { creator, secondGuardian, profile, creatorGrant, secondGrant }; +} + +function createRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/managed-profiles", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + queuedSessionIds.length = 0; +}); + +afterEach(async () => { + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe( + 'DROP TRIGGER IF EXISTS managed_profile_creation_failure ON "account_grants"', + ); + await prisma.$executeRawUnsafe( + "DROP FUNCTION IF EXISTS fail_managed_profile_creation()", + ); + await prisma.$executeRawUnsafe( + 'DROP TRIGGER IF EXISTS managed_profile_acceptance_failure ON "account_grants"', + ); + await prisma.$executeRawUnsafe( + "DROP FUNCTION IF EXISTS fail_managed_profile_acceptance()", + ); + await prisma.$executeRawUnsafe( + "DROP TRIGGER IF EXISTS managed_profile_delete_audit_failure ON audit_logs", + ); + await prisma.$executeRawUnsafe( + "DROP FUNCTION IF EXISTS fail_managed_profile_delete_audit()", + ); + await prisma.$executeRawUnsafe( + "DROP TRIGGER IF EXISTS managed_guardian_audit_failure ON audit_logs", + ); + await prisma.$executeRawUnsafe( + "DROP FUNCTION IF EXISTS fail_managed_guardian_audit()", + ); +}); + +describe("managed profile lifecycle (real Postgres)", () => { + it("creation records a complete DOB and one accepted non-expiring Guardian grant", async () => { + const guardian = await signInWithFreshMfa(); + const { POST } = await import("@/app/api/managed-profiles/route"); + + const response = await POST( + createRequest({ + displayName: "Managed profile", + dateOfBirth: "2012-03-04", + locale: "de", + timezone: "Europe/Zurich", + }), + ); + expect(response.status).toBe(201); + + const body = await response.json(); + const profile = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: body.data.id }, + }); + expect(profile.username).toMatch(/^managed-/); + expect(profile.username).not.toContain("Managed profile"); + expect(profile.email).toBeNull(); + expect(profile.passwordHash).toBeNull(); + expect(profile.dateOfBirth?.toISOString().slice(0, 10)).toBe("2012-03-04"); + expect(profile.locale).toBe("de"); + expect(profile.timezone).toBe("Europe/Zurich"); + expect(profile.managedProfileAt).not.toBeNull(); + + const grant = await getPrismaClient().accountGrant.findFirstOrThrow({ + where: { grantorId: profile.id, granteeId: guardian.id }, + }); + expect(grant.access).toBe("MANAGE"); + expect(grant.acceptedAt).not.toBeNull(); + expect(grant.expiresAt).toBeNull(); + }); + + it("creation accepts an absent DOB without fabricating one", async () => { + await signInWithFreshMfa(); + const { POST } = await import("@/app/api/managed-profiles/route"); + + const response = await POST( + createRequest({ + displayName: "No birth date", + locale: "en", + timezone: "UTC", + }), + ); + expect(response.status).toBe(201); + const body = await response.json(); + const profile = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: body.data.id }, + }); + expect(profile.dateOfBirth).toBeNull(); + }); + + it("creation rolls the profile back when its Guardian grant fails", async () => { + await signInWithFreshMfa(); + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe( + "CREATE FUNCTION fail_managed_profile_creation() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'managed profile grant failure'; END; $$", + ); + await prisma.$executeRawUnsafe( + 'CREATE TRIGGER managed_profile_creation_failure BEFORE INSERT ON "account_grants" FOR EACH ROW EXECUTE FUNCTION fail_managed_profile_creation()', + ); + + const { POST } = await import("@/app/api/managed-profiles/route"); + const response = await POST( + createRequest({ + displayName: "Must roll back", + locale: "en", + timezone: "UTC", + }), + ); + expect(response.status).toBe(500); + expect( + await prisma.user.count({ + where: { managedProfileAt: { not: null } }, + }), + ).toBe(0); + expect(await prisma.accountGrant.count()).toBe(0); + }); + + it("second Guardian accepts through the ordinary invitation and does not expire", async () => { + const creator = await signInWithFreshMfa(); + const secondGuardian = await makeAdult("second-guardian"); + const { profile } = await createManagedProfile({ + creatorId: creator.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const invitationExpiry = new Date(Date.now() + 60 * 60 * 1000); + + const invitation = await inviteGrant({ + grantorId: profile.id, + granteeId: secondGuardian.id, + access: "MANAGE", + scope: null, + expiresAt: invitationExpiry, + }); + expect(invitation.acceptedAt).toBeNull(); + expect(invitation.expiresAt).toEqual(invitationExpiry); + + const accepted = await acceptGrant({ + grantId: invitation.id, + granteeId: secondGuardian.id, + }); + expect(accepted.acceptedAt).not.toBeNull(); + expect(accepted.expiresAt).toBeNull(); + expect( + await findActiveGrant( + { grantorId: profile.id, granteeId: secondGuardian.id }, + getPrismaClient(), + new Date(Date.now() + 24 * 60 * 60 * 1000), + ), + ).not.toBeNull(); + }); + + it("lets a cookie-authenticated Guardian invite and revoke another Guardian", async () => { + const creator = await createFreshMfaUser("creator"); + const secondGuardian = await createFreshMfaUser("second-guardian"); + cookieJar.set("healthlog_session", creator.sessionId); + const { profile } = await createManagedProfile({ + creatorId: creator.user.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const invitationExpiry = new Date(Date.now() + 60 * 60 * 1000); + + const { POST: inviteGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/route"); + const invited = await inviteGuardian( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + identifier: secondGuardian.user.username, + expiresAt: invitationExpiry.toISOString(), + }), + }, + ), + { params: Promise.resolve({ id: profile.id }) }, + ); + expect(invited.status).toBe(201); + const invitation = (await invited.json()).data; + expect(invitation.acceptedAt).toBeNull(); + expect(new Date(invitation.expiresAt).getTime()).toBe( + invitationExpiry.getTime(), + ); + + cookieJar.set("healthlog_session", secondGuardian.sessionId); + const { POST: acceptInvitation } = + await import("@/app/api/account/grants/[id]/accept/route"); + const accepted = await acceptInvitation( + new NextRequest( + `http://localhost/api/account/grants/${invitation.id}/accept`, + { + method: "POST", + }, + ), + { params: Promise.resolve({ id: invitation.id }) }, + ); + expect(accepted.status).toBe(200); + expect( + await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invitation.id }, + }), + ).toMatchObject({ acceptedAt: expect.any(Date), expiresAt: null }); + await getPrismaClient().session.update({ + where: { id: secondGuardian.sessionId }, + data: { actingAsUserId: profile.id }, + }); + + cookieJar.set("healthlog_session", creator.sessionId); + const { DELETE: revokeGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/[grantId]/route"); + const revoked = await revokeGuardian( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians/${invitation.id}`, + { method: "DELETE" }, + ), + { params: Promise.resolve({ id: profile.id, grantId: invitation.id }) }, + ); + expect(revoked.status).toBe(200); + expect( + await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invitation.id }, + }), + ).toMatchObject({ revokedBy: "GRANTOR" }); + expect( + await getPrismaClient().session.findUniqueOrThrow({ + where: { id: secondGuardian.sessionId }, + }), + ).toMatchObject({ actingAsUserId: null }); + const audit = await getPrismaClient().auditLog.findMany({ + where: { + userId: profile.id, + action: { + in: [ + "managed_profile.guardian.invited", + "managed_profile.guardian.revoked", + ], + }, + }, + orderBy: { createdAt: "asc" }, + }); + expect(audit).toHaveLength(2); + expect(audit.map((entry) => entry.actorUserId)).toEqual([ + creator.user.id, + creator.user.id, + ]); + expect(audit.map((entry) => JSON.parse(entry.details ?? "{}"))).toEqual([ + { grantId: invitation.id }, + { grantId: invitation.id }, + ]); + }); + + it("withdraws a pending Guardian invitation without touching the active count and permits re-invitation", async () => { + const creator = await createFreshMfaUser("creator"); + const secondGuardian = await createFreshMfaUser("second-guardian"); + cookieJar.set("healthlog_session", creator.sessionId); + const { profile } = await createManagedProfile({ + creatorId: creator.user.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const { POST: inviteGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/route"); + const { DELETE: revokeGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/[grantId]/route"); + const invite = async () => { + const response = await inviteGuardian( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier: secondGuardian.user.username }), + }, + ), + { params: Promise.resolve({ id: profile.id }) }, + ); + expect(response.status).toBe(201); + return (await response.json()).data; + }; + + const pending = await invite(); + const withdrawn = await revokeGuardian( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians/${pending.id}`, + { method: "DELETE" }, + ), + { params: Promise.resolve({ id: profile.id, grantId: pending.id }) }, + ); + expect(withdrawn.status).toBe(200); + expect( + await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: pending.id }, + }), + ).toMatchObject({ acceptedAt: null, revokedAt: expect.any(Date) }); + + const replacement = await invite(); + expect(replacement.id).not.toBe(pending.id); + expect(replacement.acceptedAt).toBeNull(); + }); + + it("refuses a managed profile as a Guardian invitee", async () => { + const creator = await createFreshMfaUser("creator"); + cookieJar.set("healthlog_session", creator.sessionId); + const { profile } = await createManagedProfile({ + creatorId: creator.user.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const { profile: managedInvitee } = await createManagedProfile({ + creatorId: creator.user.id, + displayName: "Another managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const { POST: inviteGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/route"); + + const response = await inviteGuardian( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier: managedInvitee.username }), + }, + ), + { params: Promise.resolve({ id: profile.id }) }, + ); + expect(response.status).toBe(422); + expect( + await getPrismaClient().accountGrant.count({ + where: { grantorId: profile.id }, + }), + ).toBe(1); + }); + + it("rolls Guardian invitations back when their audit entry fails", async () => { + const creator = await createFreshMfaUser("creator"); + const secondGuardian = await createFreshMfaUser("second-guardian"); + cookieJar.set("healthlog_session", creator.sessionId); + const { profile } = await createManagedProfile({ + creatorId: creator.user.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe( + "CREATE FUNCTION fail_managed_guardian_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'managed_profile.guardian.invited' THEN RAISE EXCEPTION 'managed Guardian audit failure'; END IF; RETURN NEW; END; $$", + ); + await prisma.$executeRawUnsafe( + "CREATE TRIGGER managed_guardian_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION fail_managed_guardian_audit()", + ); + const { POST: inviteGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/route"); + + const response = await inviteGuardian( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ identifier: secondGuardian.user.username }), + }, + ), + { params: Promise.resolve({ id: profile.id }) }, + ); + expect(response.status).toBe(500); + expect( + await prisma.accountGrant.count({ where: { grantorId: profile.id } }), + ).toBe(1); + }); + + it("rolls active Guardian revocation back when its audit entry fails", async () => { + const { creator, profile, secondGuardian, secondGrant } = + await createTwoGuardians(); + cookieJar.set("healthlog_session", creator.sessionId); + await getPrismaClient().session.update({ + where: { id: secondGuardian.sessionId }, + data: { actingAsUserId: profile.id }, + }); + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe( + "CREATE FUNCTION fail_managed_guardian_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'managed_profile.guardian.revoked' THEN RAISE EXCEPTION 'managed Guardian audit failure'; END IF; RETURN NEW; END; $$", + ); + await prisma.$executeRawUnsafe( + "CREATE TRIGGER managed_guardian_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION fail_managed_guardian_audit()", + ); + const { DELETE: revokeGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/[grantId]/route"); + + const response = await revokeGuardian( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians/${secondGrant.id}`, + { method: "DELETE" }, + ), + { params: Promise.resolve({ id: profile.id, grantId: secondGrant.id }) }, + ); + expect(response.status).toBe(500); + expect( + await prisma.accountGrant.findUniqueOrThrow({ + where: { id: secondGrant.id }, + }), + ).toMatchObject({ revokedAt: null }); + expect( + await prisma.session.findUniqueOrThrow({ + where: { id: secondGuardian.sessionId }, + }), + ).toMatchObject({ actingAsUserId: profile.id }); + }); + + it("rolls managed Guardian acceptance back when the grant update fails", async () => { + const creator = await signInWithFreshMfa(); + const secondGuardian = await makeAdult("second-guardian"); + const { profile } = await createManagedProfile({ + creatorId: creator.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + const invitation = await inviteGrant({ + grantorId: profile.id, + granteeId: secondGuardian.id, + access: "MANAGE", + scope: null, + expiresAt, + }); + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe( + "CREATE FUNCTION fail_managed_profile_acceptance() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.accepted_at IS NOT NULL AND OLD.accepted_at IS NULL THEN RAISE EXCEPTION 'managed profile acceptance failure'; END IF; RETURN NEW; END; $$", + ); + await prisma.$executeRawUnsafe( + 'CREATE TRIGGER managed_profile_acceptance_failure BEFORE UPDATE ON "account_grants" FOR EACH ROW EXECUTE FUNCTION fail_managed_profile_acceptance()', + ); + + await expect( + acceptGrant({ grantId: invitation.id, granteeId: secondGuardian.id }), + ).rejects.toThrow("managed profile acceptance failure"); + expect( + await prisma.accountGrant.findUniqueOrThrow({ + where: { id: invitation.id }, + }), + ).toMatchObject({ acceptedAt: null, expiresAt }); + expect( + await prisma.user.findUniqueOrThrow({ where: { id: profile.id } }), + ).toMatchObject({ managedProfileAt: expect.any(Date) }); + }); + + it("serializes Guardian acceptance against marker clearing", async () => { + const creator = await signInWithFreshMfa(); + const secondGuardian = await makeAdult("second-guardian"); + const { profile } = await createManagedProfile({ + creatorId: creator.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const expiresAt = new Date(Date.now() + 60 * 60 * 1000); + const invitation = await inviteGrant({ + grantorId: profile.id, + granteeId: secondGuardian.id, + access: "MANAGE", + scope: null, + expiresAt, + }); + + await Promise.all([ + acceptGrant({ grantId: invitation.id, granteeId: secondGuardian.id }), + clearManagedProfileMarker({ profileId: profile.id }), + ]); + + const [profileAfter, grantAfter] = await Promise.all([ + getPrismaClient().user.findUniqueOrThrow({ where: { id: profile.id } }), + getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invitation.id }, + }), + ]); + expect(profileAfter.managedProfileAt).toBeNull(); + expect(grantAfter.acceptedAt).toEqual(expect.any(Date)); + expect([null, expiresAt.getTime()]).toContain( + grantAfter.expiresAt?.getTime() ?? null, + ); + }); + + 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("preserves account, session, and audit rows when last-Guardian deletion is refused", async () => { + const guardian = await signInWithFreshMfa(); + const prisma = getPrismaClient(); + await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + await prisma.auditLog.create({ + data: { action: "test.before.guardian.refusal", userId: guardian.id }, + }); + const [sessionsBefore, auditsBefore] = await Promise.all([ + prisma.session.count({ where: { userId: guardian.id } }), + prisma.auditLog.count({ where: { userId: guardian.id } }), + ]); + 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); + await expect( + Promise.all([ + prisma.session.count({ where: { userId: guardian.id } }), + prisma.auditLog.count({ where: { userId: guardian.id } }), + prisma.auditLog.count({ + where: { userId: guardian.id, action: "user.account.delete" }, + }), + ]), + ).resolves.toEqual([sessionsBefore, auditsBefore, 0]); + }); + + it("deletes a managed profile through its fresh-MFA Guardian route", 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/managed-profiles/[id]/route"); + const response = await DELETE( + new NextRequest(`http://localhost/api/managed-profiles/${profile.id}`, { + method: "DELETE", + }), + { params: Promise.resolve({ id: profile.id }) }, + ); + expect(response.status).toBe(200); + expect( + await getPrismaClient().user.findUnique({ where: { id: profile.id } }), + ).toBeNull(); + }); + + it("rolls profile deletion back when its audit entry fails", async () => { + const guardian = await signInWithFreshMfa(); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed profile", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + }); + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe( + "CREATE FUNCTION fail_managed_profile_delete_audit() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.action = 'managed_profile.deleted' THEN RAISE EXCEPTION 'managed profile deletion audit failure'; END IF; RETURN NEW; END; $$", + ); + await prisma.$executeRawUnsafe( + "CREATE TRIGGER managed_profile_delete_audit_failure BEFORE INSERT ON audit_logs FOR EACH ROW EXECUTE FUNCTION fail_managed_profile_delete_audit()", + ); + const { DELETE } = await import("@/app/api/managed-profiles/[id]/route"); + + const response = await DELETE( + new NextRequest(`http://localhost/api/managed-profiles/${profile.id}`, { + method: "DELETE", + }), + { params: Promise.resolve({ id: profile.id }) }, + ); + expect(response.status).toBe(500); + expect( + await prisma.user.findUnique({ where: { id: profile.id } }), + ).not.toBeNull(); + expect( + await prisma.accountGrant.count({ where: { grantorId: profile.id } }), + ).toBe(1); + }); + + it("serializes a Guardian renunciation against that Guardian's account deletion", async () => { + const { profile, secondGuardian, secondGrant } = await createTwoGuardians(); + const { POST: renounce } = + await import("@/app/api/account/grants/[id]/renounce/route"); + const { DELETE: deleteAccount } = + await import("@/app/api/settings/account/route"); + queuedSessionIds.push( + secondGuardian.sessionId, + secondGuardian.sessionId, + secondGuardian.sessionId, + ); + + const [renounced, deleted] = await Promise.all([ + renounce( + new NextRequest( + `http://localhost/api/account/grants/${secondGrant.id}/renounce`, + { method: "POST" }, + ), + { params: Promise.resolve({ id: secondGrant.id }) }, + ), + deleteAccount( + new NextRequest("http://localhost/api/settings/account", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }), + }), + ), + ]); + + expect([200, 404]).toContain(renounced.status); + expect(deleted.status).toBe(200); + await expect( + Promise.all([ + getPrismaClient().user.findUnique({ + where: { id: secondGuardian.user.id }, + }), + getPrismaClient().session.count({ + where: { userId: secondGuardian.user.id }, + }), + getPrismaClient().accountGrant.count({ + where: { + grantorId: profile.id, + access: "MANAGE", + acceptedAt: { not: null }, + revokedAt: null, + }, + }), + ]), + ).resolves.toEqual([null, 0, 1]); + }); + + it("serializes a Guardian renunciation against managed profile deletion", async () => { + const { creator, profile, secondGuardian, secondGrant } = + await createTwoGuardians(); + const { POST: renounce } = + await import("@/app/api/account/grants/[id]/renounce/route"); + const { DELETE: deleteProfile } = + await import("@/app/api/managed-profiles/[id]/route"); + queuedSessionIds.push(secondGuardian.sessionId, creator.sessionId); + + const [renounced, deleted] = await Promise.all([ + renounce( + new NextRequest( + `http://localhost/api/account/grants/${secondGrant.id}/renounce`, + { method: "POST" }, + ), + { params: Promise.resolve({ id: secondGrant.id }) }, + ), + deleteProfile( + new NextRequest(`http://localhost/api/managed-profiles/${profile.id}`, { + method: "DELETE", + }), + { params: Promise.resolve({ id: profile.id }) }, + ), + ]); + + expect([200, 404]).toContain(renounced.status); + expect(deleted.status).toBe(200); + await expect( + Promise.all([ + getPrismaClient().user.findUnique({ where: { id: profile.id } }), + getPrismaClient().accountGrant.count({ + where: { grantorId: profile.id }, + }), + getPrismaClient().auditLog.count({ + where: { + userId: creator.user.id, + action: "managed_profile.deleted", + }, + }), + ]), + ).resolves.toEqual([null, 0, 1]); + }); + + it("serializes a Guardian revocation route against internal marker clearing", async () => { + const { creator, profile, secondGrant } = await createTwoGuardians(); + const { DELETE: revokeGuardian } = + await import("@/app/api/managed-profiles/[id]/guardians/[grantId]/route"); + queuedSessionIds.push(creator.sessionId); + + const [revoked] = await Promise.all([ + revokeGuardian( + new NextRequest( + `http://localhost/api/managed-profiles/${profile.id}/guardians/${secondGrant.id}`, + { method: "DELETE" }, + ), + { + params: Promise.resolve({ id: profile.id, grantId: secondGrant.id }), + }, + ), + clearManagedProfileMarker({ profileId: profile.id }), + ]); + + expect([200, 404]).toContain(revoked.status); + const [profileAfter, grantAfter] = await Promise.all([ + getPrismaClient().user.findUniqueOrThrow({ where: { id: profile.id } }), + getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: secondGrant.id }, + }), + ]); + expect(profileAfter.managedProfileAt).toBeNull(); + expect([null, "GRANTOR"]).toContain(grantAfter.revokedBy); + }); + + 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(ManagedProfileLifecycleError); + }); +}); diff --git a/tests/integration/mock-next-headers.ts b/tests/integration/mock-next-headers.ts index e2acd04f0..edce8a7cc 100644 --- a/tests/integration/mock-next-headers.ts +++ b/tests/integration/mock-next-headers.ts @@ -18,3 +18,9 @@ */ export const cookieJar = new Map(); export const headerJar = new Map(); + +/** + * Session snapshots for invoking concurrent route handlers as different users. + * A request consumes one entry when its mocked cookie store is created. + */ +export const queuedSessionIds: string[] = [];