diff --git a/src/__tests__/proxy-demo-mode-allowlist.test.ts b/src/__tests__/proxy-demo-mode-allowlist.test.ts index 4badffb3a..bf43fe9d2 100644 --- a/src/__tests__/proxy-demo-mode-allowlist.test.ts +++ b/src/__tests__/proxy-demo-mode-allowlist.test.ts @@ -51,6 +51,31 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => { expect(res.status).not.toBe(403); }); + it("permits the three setup-flow writes so the demo can finish onboarding", () => { + // v1.39 (C1) — the design spec requires the demo to complete the same flow + // with preset answers, and none of the three creates health data: closed + // enum answers, the step ledger, and the module map derived from them. + for (const [path, method] of [ + ["/api/onboarding/answers", "PATCH"], + ["/api/onboarding/complete", "POST"], + ["/api/onboarding/restart", "POST"], + ] as const) { + expect( + proxy(makeRequest(path, method)).status, + `${method} ${path} is blocked in the demo`, + ).not.toBe(403); + } + }); + + it("does not drag in a sibling verb on an allowlisted onboarding path", () => { + // The allowlist pins method per path, so admitting the answers PATCH must + // not open a DELETE somebody adds to that route later. + expect(proxy(makeRequest("/api/onboarding/answers", "DELETE")).status).toBe( + 403, + ); + expect(proxy(makeRequest("/api/onboarding/step", "POST")).status).toBe(403); + }); + it("still blocks a health-data mutation (POST /api/measurements)", () => { const res = proxy(makeRequest("/api/measurements", "POST")); expect(res.status).toBe(403); diff --git a/src/app/api/onboarding/complete/__tests__/route.test.ts b/src/app/api/onboarding/complete/__tests__/route.test.ts index 667d16d7e..ccd333d57 100644 --- a/src/app/api/onboarding/complete/__tests__/route.test.ts +++ b/src/app/api/onboarding/complete/__tests__/route.test.ts @@ -1,12 +1,21 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; -vi.mock("@/lib/db", () => ({ - prisma: { - user: { update: vi.fn() }, - auditLog: { create: vi.fn() }, - }, -})); +vi.mock("@/lib/db", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + prisma: { + user: { update: vi.fn(), findUnique: vi.fn() }, + auditLog: { create: vi.fn() }, + // v1.39 (C1) — the needs half of the route looks for a setup row before + // it does anything. These cases are the LEGACY caller, which has none, + // so the lookup answers null and the route falls straight through to the + // acknowledgement it always returned. + onboardingRecord: { findUnique: vi.fn(), update: vi.fn() }, + }, + }; +}); vi.mock("@/lib/auth/session", async (importOriginal) => { const actual = await importOriginal(); @@ -58,6 +67,7 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.user.update).mockResolvedValue({} as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(prisma.onboardingRecord.findUnique).mockResolvedValue(null); }); describe("POST /api/onboarding/complete", () => { diff --git a/tests/integration/onboarding-needs.test.ts b/tests/integration/onboarding-needs.test.ts new file mode 100644 index 000000000..c7526a0bc --- /dev/null +++ b/tests/integration/onboarding-needs.test.ts @@ -0,0 +1,418 @@ +/** + * The three needs-onboarding writes against real Postgres. + * + * The unit suite proves the pure parts — the area map, the merge that never + * retracts a hand-made decision, the apply function's idempotence. This file + * proves the four things a unit suite cannot, because each is about what is in + * the database afterwards: + * + * * **The answers persist and a replay changes nothing.** Idempotence is a + * claim on the wire, and a route can satisfy the pure function and still + * write a fresh row each time. + * * **The derivation happens once.** The second confirm has to leave the + * module map alone, including a module switched off in Settings between + * the two calls — that is the whole reason the latch exists. + * * **A restart resets the steps and not the modules.** The two are written + * to different tables by different routes, so only a real run can show + * that the second one stays where it was. + * * **A delegate cannot write another record's setup.** Proved with a live + * accepted grant and a switched session, not with a mocked resolver. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +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), +})); + +import { acceptGrant, inviteGrant } from "@/lib/sharing/grants"; +import type { + OnboardingStateDto, + OnboardingStepId, +} from "@/lib/onboarding/needs"; + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `onb-${suffix}`, + email: `onb-${suffix}@example.test`, + role: "USER", + }, + }); +} + +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; +} + +/** Attach the record-session assertion the shipped browser client attaches. */ +async function assertCurrentContext(sessionId: string) { + const row = await getPrismaClient().session.findUniqueOrThrow({ + where: { id: sessionId }, + }); + headerJar.set("x-healthlog-record-epoch", String(row.recordEpoch)); + headerJar.set("x-healthlog-record-scope", row.actingAsUserId ?? "self"); +} + +function jsonRequest(path: string, method: string, body: unknown): Request { + return new Request(`http://localhost${path}`, { + method, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function patchAnswer(body: unknown): Promise { + const { PATCH } = await import("@/app/api/onboarding/answers/route"); + return (PATCH as (r: Request) => Promise)( + jsonRequest("/api/onboarding/answers", "PATCH", body), + ); +} + +async function postComplete(body: unknown = {}): Promise { + const { POST } = await import("@/app/api/onboarding/complete/route"); + return (POST as (r: Request) => Promise)( + jsonRequest("/api/onboarding/complete", "POST", body), + ); +} + +async function postRestart(): Promise { + const { POST } = await import("@/app/api/onboarding/restart/route"); + return (POST as (r: Request) => Promise)( + jsonRequest("/api/onboarding/restart", "POST", {}), + ); +} + +async function readState(res: Response): Promise { + const body = (await res.json()) as { + data: { onboarding: OnboardingStateDto }; + }; + return body.data.onboarding; +} + +function statusOf(state: OnboardingStateDto, id: OnboardingStepId) { + return state.steps.find((step) => step.id === id)?.status; +} + +/** The five answers a "blood pressure and medication" setup gives. */ +async function answerTheQuestions() { + await patchAnswer({ step: "who", recordTarget: "me" }); + await patchAnswer({ step: "areas", areas: ["blood-pressure", "labs"] }); + await patchAnswer({ step: "medication", medication: "yes" }); + await patchAnswer({ step: "sources", status: "skipped" }); + await patchAnswer({ step: "visit", visit: "no" }); +} + +async function modulePrefs(userId: string) { + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: userId }, + select: { modulePreferencesJson: true }, + }); + return (row.modulePreferencesJson ?? {}) as Record; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("PATCH /api/onboarding/answers", () => { + it("saves one step at a time and answers with the full nine", async () => { + const user = await makeUser("answers"); + await signIn(user.id); + + const first = await patchAnswer({ step: "who", recordTarget: "both" }); + expect(first.status).toBe(200); + const state = await readState(first); + expect(state.needs.recordTarget).toBe("both"); + expect(state.steps).toHaveLength(9); + expect(statusOf(state, "who")).toBe("done"); + expect(statusOf(state, "areas")).toBe("pending"); + + const second = await readState( + await patchAnswer({ step: "areas", areas: ["sleep"] }), + ); + expect(second.needs.recordTarget).toBe("both"); + expect(second.needs.areas).toEqual(["sleep"]); + expect(statusOf(second, "areas")).toBe("done"); + }); + + it("is idempotent — a replay leaves one row and the same state", async () => { + const user = await makeUser("replay"); + await signIn(user.id); + + const once = await readState( + await patchAnswer({ step: "medication", medication: "sometimes" }), + ); + const twice = await readState( + await patchAnswer({ step: "medication", medication: "sometimes" }), + ); + expect(twice).toEqual(once); + expect( + await getPrismaClient().onboardingRecord.count({ + where: { userId: user.id }, + }), + ).toBe(1); + }); + + it("records a skip without erasing the answer already given", async () => { + const user = await makeUser("skip"); + await signIn(user.id); + + await patchAnswer({ step: "visit", visit: "within-a-month" }); + const skipped = await readState( + await patchAnswer({ step: "visit", status: "skipped" }), + ); + expect(statusOf(skipped, "visit")).toBe("skipped"); + expect(skipped.needs.visit).toBe("within-a-month"); + }); + + it("writes the record's real unit columns, and records the answer beside them", async () => { + const user = await makeUser("units"); + await signIn(user.id); + + const state = await readState( + await patchAnswer({ + step: "units", + units: { glucoseUnit: "mmol/L", unitPreference: "imperial" }, + }), + ); + expect(state.needs.units).toEqual({ + glucoseUnit: "mmol/L", + unitPreference: "imperial", + }); + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: user.id }, + select: { glucoseUnit: true, unitPreference: true }, + }); + expect(row).toEqual({ glucoseUnit: "mmol/L", unitPreference: "imperial" }); + }); + + it("refuses a body that names a step it does not answer", async () => { + const user = await makeUser("invalid"); + await signIn(user.id); + + const res = await patchAnswer({ step: "areas", medication: "yes" }); + expect(res.status).toBe(422); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("onboarding.answers.invalid"); + expect( + await getPrismaClient().onboardingRecord.count({ + where: { userId: user.id }, + }), + ).toBe(0); + }); +}); + +describe("POST /api/onboarding/complete", () => { + it("derives the module map from the answers, once", async () => { + const user = await makeUser("derive"); + await signIn(user.id); + await answerTheQuestions(); + + const first = await postComplete(); + expect(first.status).toBe(200); + + const derived = await modulePrefs(user.id); + // What the answers named. + expect(derived.medications).toBe(true); + expect(derived.labs).toBe(true); + expect(derived.insights).toBe(true); + expect(derived.achievements).toBe(true); + // What they did not. + expect(derived.workouts).toBe(false); + expect(derived.mood).toBe(false); + expect(derived.doctorReport).toBe(false); + expect(derived.vaccinations).toBe(false); + // The delegated keys are never written into the blob. + expect(derived).not.toHaveProperty("cycle"); + expect(derived).not.toHaveProperty("coach"); + + const state = await readState(first); + expect(statusOf(state, "confirm")).toBe("done"); + expect(state.completedAt).not.toBeNull(); + + // Somebody changes their mind in Settings, then the confirm screen is + // replayed. The second call must leave that decision alone: the latch is + // what stops a questionnaire from re-applying itself over a later choice. + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { modulePreferencesJson: { ...derived, labs: false } }, + }); + const second = await postComplete(); + expect(second.status).toBe(200); + expect((await modulePrefs(user.id)).labs).toBe(false); + }); + + it("never switches off a module the person switched on by hand", async () => { + const user = await makeUser("handmade"); + await signIn(user.id); + // The remote MCP endpoint is off by default and nothing in the flow asks + // for it, so a derivation that ignored the stored map would retract it. + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { modulePreferencesJson: { mcp: true } }, + }); + await answerTheQuestions(); + await postComplete(); + + expect((await modulePrefs(user.id)).mcp).toBe(true); + }); + + it("switches cycle tracking on through its own column when the area is chosen", async () => { + const user = await makeUser("cycle"); + await signIn(user.id); + await patchAnswer({ step: "who", recordTarget: "me" }); + await patchAnswer({ step: "areas", areas: ["cycle"] }); + await postComplete(); + + const profile = await getPrismaClient().cycleProfile.findUniqueOrThrow({ + where: { userId: user.id }, + }); + expect(profile.cycleTrackingEnabled).toBe(true); + }); + + it("leaves a record that never answered the questions exactly as it was", async () => { + const user = await makeUser("legacy"); + await signIn(user.id); + + const res = await postComplete({ heightCm: 180 }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { completed: boolean; onboarding?: unknown }; + }; + expect(body.data.completed).toBe(true); + expect(body.data.onboarding).toBeUndefined(); + + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: user.id }, + select: { modulePreferencesJson: true, onboardingCompletedAt: true }, + }); + expect(row.modulePreferencesJson).toBeNull(); + expect(row.onboardingCompletedAt).not.toBeNull(); + }); +}); + +describe("POST /api/onboarding/restart", () => { + it("resets the steps and leaves the modules where they are", async () => { + const user = await makeUser("restart"); + await signIn(user.id); + await answerTheQuestions(); + await postComplete(); + const derived = await modulePrefs(user.id); + + const res = await postRestart(); + expect(res.status).toBe(200); + const state = await readState(res); + expect(state.steps.every((step) => step.status === "pending")).toBe(true); + expect(state.completedAt).toBeNull(); + // The answers survive as the prefill for the re-run. + expect(state.needs.medication).toBe("yes"); + expect(await modulePrefs(user.id)).toEqual(derived); + + // The latch is cleared, so the next confirm may derive again — and a + // module switched off by hand in between still survives it. + await getPrismaClient().user.update({ + where: { id: user.id }, + data: { modulePreferencesJson: { ...derived, labs: true } }, + }); + await patchAnswer({ step: "who", recordTarget: "me" }); + await patchAnswer({ step: "areas", areas: [] }); + await postComplete(); + expect((await modulePrefs(user.id)).labs).toBe(true); + }); + + it("leaves the first-run redirect's own stamp alone", async () => { + const user = await makeUser("redirect"); + await signIn(user.id); + await answerTheQuestions(); + await postComplete(); + await postRestart(); + + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: user.id }, + select: { onboardingCompletedAt: true }, + }); + expect(row.onboardingCompletedAt).not.toBeNull(); + }); +}); + +describe("a delegate and somebody else's record", () => { + it("refuses every setup write while the session is acting on another record", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + scope: null, + }); + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + + const session = await signIn(delegate.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + await assertCurrentContext(session.id); + + for (const call of [ + () => patchAnswer({ step: "who", recordTarget: "me" }), + () => postComplete(), + () => postRestart(), + ]) { + const res = await call(); + expect(res.status).toBe(403); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe("sharing.not_permitted"); + } + + // Neither record acquired a setup row — not the owner's, and not the + // delegate's own, which is the quiet failure a fall-back would produce. + expect(await getPrismaClient().onboardingRecord.count()).toBe(0); + const ownerRow = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: owner.id }, + select: { modulePreferencesJson: true, onboardingCompletedAt: true }, + }); + expect(ownerRow.modulePreferencesJson).toBeNull(); + expect(ownerRow.onboardingCompletedAt).toBeNull(); + }); +});