diff --git a/src/__tests__/managed-profile-contract.test.ts b/src/__tests__/managed-profile-contract.test.ts new file mode 100644 index 000000000..67ef25d37 --- /dev/null +++ b/src/__tests__/managed-profile-contract.test.ts @@ -0,0 +1,26 @@ +import { readFile } 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("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"); + }); +}); diff --git a/tests/integration/managed-profile-lifecycle.test.ts b/tests/integration/managed-profile-lifecycle.test.ts new file mode 100644 index 000000000..564412b04 --- /dev/null +++ b/tests/integration/managed-profile-lifecycle.test.ts @@ -0,0 +1,160 @@ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.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 signInWithFreshMfa() { + const suffix = sequence++; + const prisma = getPrismaClient(); + const guardian = await prisma.user.create({ + data: { + username: `guardian-${suffix}`, + email: `guardian-${suffix}@example.test`, + totpConfirmedAt: new Date(), + }, + }); + const session = await prisma.session.create({ + data: { + userId: guardian.id, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", session.id); + return guardian; +} + +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(); +}); + +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()", + ); +}); + +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("rolls the profile back when creating 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); + }); +});