mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
test(integration): connect Oura for a managed profile against real Postgres
This commit is contained in:
1 parent
74ce17b7de
commit
ba2d7af40e
1 file changed
+288
@@ -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<Response> {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user