Merge managed profile lifecycle

This commit is contained in:
Marc-André Bombeck committed 2026-08-06 03:44:16 +02:00
commit 5a9f8a0df6
16 files changed
+1846 -60

No files matched your search

@@ -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
+12 -9
View File
@@ -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
@@ -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");
});
});
@@ -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;
}
},
+6
View File
@@ -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;
}
},
@@ -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;
}
},
);
@@ -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;
}
},
);
@@ -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 });
},
);
+73
View File
@@ -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,
);
});
+36 -19
View File
@@ -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" },
+88
View File
@@ -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<CreatedManagedProfile> {
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();
}
+173
View File
@@ -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<T>(
tx: Transaction,
profileId: string,
operation: (
profile: { id: string; managedProfileAt: Date | null } | null,
) => Promise<T>,
): Promise<T> {
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<T>(
tx: Transaction,
profileId: 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();
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<T>(
guardianId: string,
deleteAccount: (tx: Transaction) => Promise<T>,
): Promise<T> {
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<void> {
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<void> {
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 },
});
});
});
}
+212 -27
View File
@@ -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<AccountGrant> {
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<AccountGrant> {
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<AccountGrant> {
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<AccountGrant> {
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<AccountGrant> {
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<AccountGrant> {
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<EndedGrant> {
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<EndedGrant> {
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<AccountGrant> {
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);
}
/**
@@ -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);
});
});
@@ -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);
});
});
+6
View File
@@ -18,3 +18,9 @@
*/
export const cookieJar = new Map<string, string>();
export const headerJar = new Map<string, string>();
/**
* 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[] = [];