diff --git a/tests/integration/managed-profile-oauth-connect.test.ts b/tests/integration/managed-profile-oauth-connect.test.ts new file mode 100644 index 000000000..beca90d08 --- /dev/null +++ b/tests/integration/managed-profile-oauth-connect.test.ts @@ -0,0 +1,288 @@ +/** + * A guardian connects an OAuth provider for a managed profile, against real + * Postgres: the connect route mints a state naming the profile, the + * provider's callback lands the tokens on the profile, and every refusal the + * flow promises holds (an adult's record, a revoked grant, a READ grant, no + * guardian session). Oura stands for the signed-state flows; the ledger + * flows share `verifyCallbackActor`, which is exercised directly too. + */ +import { NextRequest } from "next/server"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +process.env.API_TOKEN_HMAC_KEY ??= + "integration-hmac-key-0123456789abcdef0123456789"; + +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), +})); + +const OURA_TOKEN_URL = "https://moi.ouraring.com/oauth/v2/ext/oauth-token"; +const OURA_ISS = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; +const GUARDIAN = "guardian-oauth"; +const PROFILE = "profile-oauth"; +const ADULT = "adult-oauth"; + +function urlOf(input: unknown): string { + return typeof input === "string" ? input : (input as Request).url; +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +async function grant( + grantorId: string, + access: "READ" | "WRITE" | "MANAGE" = "MANAGE", +) { + return getPrismaClient().accountGrant.create({ + data: { grantorId, granteeId: GUARDIAN, access, acceptedAt: new Date() }, + }); +} + +async function startConnect(): Promise { + const { POST } = + await import("@/app/api/record-settings/integrations/[provider]/connect/route"); + return POST( + new NextRequest( + "http://localhost/api/record-settings/integrations/oura/connect", + { method: "POST" }, + ), + { params: Promise.resolve({ provider: "oura" }) }, + ); +} + +async function ouraCallback(state: string) { + const { GET } = await import("@/app/api/oura/callback/route"); + const params = new URLSearchParams({ + code: "oura-code", + state, + iss: OURA_ISS, + }); + return GET( + new NextRequest(`http://localhost/api/oura/callback?${params}`, { + method: "GET", + headers: { cookie: `oura_oauth_state=${state}` }, + }), + ); +} + +function stateOf(authorizationUrl: string): string { + const state = new URL(authorizationUrl).searchParams.get("state"); + if (!state) throw new Error("no state in the consent URL"); + return state; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; + delete process.env.OURA_CLIENT_ID; + delete process.env.OURA_CLIENT_SECRET; + delete process.env.OURA_REDIRECT_URI; + + const { encrypt } = await import("@/lib/crypto"); + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: GUARDIAN, + username: "guardian-oauth", + email: "guardian-oauth@example.test", + role: "USER", + // The guardian's own Oura app; the profile has none. + ouraClientIdEncrypted: encrypt("guardian-oura-client"), + ouraClientSecretEncrypted: encrypt("guardian-oura-secret"), + }, + }); + await prisma.user.create({ + data: { + id: PROFILE, + username: "profile-oauth", + email: "profile-oauth@example.test", + role: "USER", + managedProfileAt: new Date(), + }, + }); + await prisma.user.create({ + data: { + id: ADULT, + username: "adult-oauth", + email: "adult-oauth@example.test", + role: "USER", + }, + }); + + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + if (urlOf(input) !== OURA_TOKEN_URL) { + throw new Error(`Unexpected fetch: ${urlOf(input)}`); + } + return new Response( + JSON.stringify({ + access_token: "profile-access", + refresh_token: "profile-refresh", + expires_in: 86400, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("a guardian connects Oura for a managed profile", () => { + it("lands the tokens on the profile, never on the guardian, and audits both steps", async () => { + await grant(PROFILE); + const session = await signIn(GUARDIAN); + await switchSessionTo(session.id, PROFILE); + + const started = await startConnect(); + expect(started.status).toBe(200); + const body = (await started.json()) as { + data: { recordId: string; authorizationUrl: string }; + }; + expect(body.data.recordId).toBe(PROFILE); + + const { verifySignedState } = await import("@/lib/oauth/signed-state"); + const state = stateOf(body.data.authorizationUrl); + expect(verifySignedState("oura", state)?.userId).toBe(PROFILE); + + const res = await ouraCallback(state); + expect(res.headers.get("location")).toContain("oura=connected"); + + const { decrypt } = await import("@/lib/crypto"); + const prisma = getPrismaClient(); + const profile = await prisma.user.findUniqueOrThrow({ + where: { id: PROFILE }, + select: { + ouraAccessTokenEncrypted: true, + ouraRefreshTokenEncrypted: true, + ouraClientIdEncrypted: true, + }, + }); + expect(decrypt(profile.ouraAccessTokenEncrypted!)).toBe("profile-access"); + expect(decrypt(profile.ouraRefreshTokenEncrypted!)).toBe("profile-refresh"); + // The guardian's app was borrowed, sealed, for the refresh to use later. + expect(decrypt(profile.ouraClientIdEncrypted!)).toBe( + "guardian-oura-client", + ); + const guardian = await prisma.user.findUniqueOrThrow({ + where: { id: GUARDIAN }, + select: { ouraAccessTokenEncrypted: true }, + }); + expect(guardian.ouraAccessTokenEncrypted).toBeNull(); + + const audits = await prisma.auditLog.findMany({ + where: { userId: PROFILE }, + select: { action: true, actorUserId: true }, + orderBy: { createdAt: "asc" }, + }); + expect(audits).toEqual( + expect.arrayContaining([ + { action: "integration.client_app.shared", actorUserId: GUARDIAN }, + { action: "integration.connect.started", actorUserId: GUARDIAN }, + { action: "oura.connect", actorUserId: GUARDIAN }, + ]), + ); + }); + + it("refuses the callback once the grant was revoked on the consent screen", async () => { + const live = await grant(PROFILE); + const session = await signIn(GUARDIAN); + await switchSessionTo(session.id, PROFILE); + const started = await startConnect(); + const { data } = (await started.json()) as { + data: { authorizationUrl: string }; + }; + + await getPrismaClient().accountGrant.update({ + where: { id: live.id }, + data: { revokedAt: new Date(), revokedBy: "GRANTOR" }, + }); + + const res = await ouraCallback(stateOf(data.authorizationUrl)); + expect(res.headers.get("location")).toContain("reason=cross_user"); + const profile = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: PROFILE }, + select: { ouraAccessTokenEncrypted: true }, + }); + expect(profile.ouraAccessTokenEncrypted).toBeNull(); + }); + + it("does not start a connect on an adult's record, even under MANAGE", async () => { + await grant(ADULT); + const session = await signIn(GUARDIAN); + await switchSessionTo(session.id, ADULT); + const res = await startConnect(); + expect(res.status).toBe(403); + }); +}); + +describe("verifyCallbackActor against real grants", () => { + it("admits only a live MANAGE grant on a managed profile", async () => { + const { verifyCallbackActor } = + await import("@/lib/integrations/oauth-kit/connect-target"); + await expect(verifyCallbackActor(PROFILE, GUARDIAN)).resolves.toEqual({ + ok: false, + reason: "cross_user", + }); + + const read = await grant(PROFILE, "READ"); + await expect(verifyCallbackActor(PROFILE, GUARDIAN)).resolves.toMatchObject( + { ok: false }, + ); + await getPrismaClient().accountGrant.delete({ where: { id: read.id } }); + + await grant(PROFILE, "MANAGE"); + await expect(verifyCallbackActor(PROFILE, GUARDIAN)).resolves.toEqual({ + ok: true, + guardianUserId: GUARDIAN, + }); + // A managed profile has no session of its own to finish a connect with. + await expect(verifyCallbackActor(PROFILE, null)).resolves.toEqual({ + ok: false, + reason: "guardian_session_required", + }); + + await grant(ADULT, "MANAGE"); + await expect(verifyCallbackActor(ADULT, GUARDIAN)).resolves.toEqual({ + ok: false, + reason: "cross_user", + }); + await expect(verifyCallbackActor(ADULT, null)).resolves.toEqual({ + ok: true, + guardianUserId: null, + }); + }); +});