mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge managed profile lifecycle
This commit is contained in:
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
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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[] = [];
|
||||
Reference in new issue
Block a user