diff --git a/src/components/onboarding/__tests__/confirm-screen.test.tsx b/src/components/onboarding/__tests__/confirm-screen.test.tsx index eb8f88c22..4e4d49779 100644 --- a/src/components/onboarding/__tests__/confirm-screen.test.tsx +++ b/src/components/onboarding/__tests__/confirm-screen.test.tsx @@ -144,4 +144,21 @@ describe(" — the anamnesis card on a shared demo account", () = expect(html).toContain('data-slot="onboarding-anamnesis"'); expect(html).toContain('data-readonly="true"'); }); + + it("takes no display name in the demo either", () => { + // `PUT /api/auth/profile` is allowlisted for the demo, but the server + // drops every field outside height / date of birth / sex — the display + // name would otherwise be the next visitor's. A box that accepts input + // and silently discards it is the same dishonesty as a failed save, so + // it does not accept input. + const html = render(state(), {}, true); + expect(html).toMatch(/id="ob-baseline-display-name"[^>]*disabled/); + // The three the step exists for still take their answers. + expect(html).not.toMatch(/id="ob-baseline-height"[^>]*disabled/); + }); + + it("takes a display name on an ordinary instance", () => { + const html = render(state()); + expect(html).not.toMatch(/id="ob-baseline-display-name"[^>]*disabled/); + }); }); diff --git a/src/components/onboarding/baseline-fields.tsx b/src/components/onboarding/baseline-fields.tsx index a2190b783..c543b42ed 100644 --- a/src/components/onboarding/baseline-fields.tsx +++ b/src/components/onboarding/baseline-fields.tsx @@ -60,6 +60,7 @@ export function BaselineFields({ onChange, heightAdapter, errors, + readOnlyDisplayName = false, }: { value: BaselineFieldValues; onChange: ( @@ -68,6 +69,15 @@ export function BaselineFields({ ) => void; heightAdapter: HeightUnitAdapter; errors: BaselineFieldErrors; + /** + * v1.39 — the instance cannot persist a display name, so the box does not + * take one. True under `DEMO_MODE`, where `applyProfileUpdate` writes only + * height, date of birth and sex: the demo's single published account means + * a display name typed here would be the next visitor's. A field that + * accepts input and silently discards it is the same dishonesty as a save + * that fails, which is why it is disabled rather than merely ignored. + */ + readOnlyDisplayName?: boolean; }) { const { t } = useTranslations(); @@ -85,6 +95,7 @@ export function BaselineFields({ id={IDS.displayName} value={value.displayName} onChange={(e) => onChange("displayName", e.target.value)} + disabled={readOnlyDisplayName} autoComplete="nickname" maxLength={50} placeholder={t("onboarding.baseline.displayNamePlaceholder")} diff --git a/src/components/onboarding/baseline-form.tsx b/src/components/onboarding/baseline-form.tsx index 9d07b3dab..d85f732c3 100644 --- a/src/components/onboarding/baseline-form.tsx +++ b/src/components/onboarding/baseline-form.tsx @@ -117,8 +117,10 @@ export function BaselineForm({ confirming?: boolean; /** * True on a `DEMO_MODE` instance, where the shared account's self-context - * write is off the edge allowlist. Makes the anamnesis card read-only so - * the refusal never reaches a visitor as a failed confirm. + * write is off the edge allowlist and its profile write is narrowed to the + * three baseline fields. Makes the anamnesis card and the display-name box + * read-only, so neither refusal reaches a visitor as a failed confirm or as + * an answer that silently vanished. */ demoMode?: boolean; }) { @@ -253,6 +255,7 @@ export function BaselineForm({ onChange={patch} heightAdapter={heightAdapter} errors={fieldErrors} + readOnlyDisplayName={demoMode} /> ({ prisma: { @@ -282,3 +282,84 @@ describe("applyProfileUpdate email conflict", () => { expect(prisma.user.update).not.toHaveBeenCalled(); }); }); + +/** + * v1.39 — what a demo instance may write through this helper. + * + * `PUT /api/auth/profile` is on the proxy's demo mutation allowlist so the + * setup flow's baseline step can complete. That allowlist admits a path and a + * method; it cannot see that the route behind the path also writes the + * account's email, display name, full name and insurer fields. The demo is + * one published account every visitor signs into, so each of those lands on + * the record the next visitor meets. + * + * The narrowing is therefore here, on the server, and not in the client that + * happens to send only three fields — the allowlist exists precisely because + * the caller is not trusted. `tests/integration/demo-mode-profile-write.test.ts` + * proves the same contract against real Postgres by reading the row back; this + * pins the written payload so the default suite catches a regression too. + */ +describe("applyProfileUpdate under DEMO_MODE", () => { + const ORIGINAL = process.env.DEMO_MODE; + + afterEach(() => { + if (ORIGINAL === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = ORIGINAL; + }); + + const HOSTILE_AND_BASELINE = { + email: "attacker@example.test", + displayName: "Owned", + fullName: "Attacker", + insurerName: "Attacker Insurance", + timezone: "America/New_York", + heightCm: 181, + dateOfBirth: "1988-04-12", + gender: "FEMALE", + }; + + it("writes the three baseline fields and nothing else", async () => { + process.env.DEMO_MODE = "true"; + + const result = await applyProfileUpdate(USER_ID, HOSTILE_AND_BASELINE); + + expect(result.ok).toBe(true); + const written = vi.mocked(prisma.user.update).mock.calls[0]![0]!.data; + expect(Object.keys(written as object).sort()).toEqual([ + "dateOfBirth", + "gender", + "heightCm", + ]); + }); + + it("never reaches the email conflict check, which is an existence oracle", async () => { + // The check 409s when another account holds the address. On a public + // demo that answers "does this person have an account here?" to anybody, + // so the narrowing has to happen ahead of it rather than after. + process.env.DEMO_MODE = "true"; + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "someone-else", + } as never); + + const result = await applyProfileUpdate(USER_ID, { + email: "taken@example.test", + }); + + expect(result.ok).toBe(true); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + }); + + it("writes every field on an ordinary instance", async () => { + delete process.env.DEMO_MODE; + + await applyProfileUpdate(USER_ID, HOSTILE_AND_BASELINE); + + const written = vi.mocked(prisma.user.update).mock.calls[0]![0]! + .data as Record; + expect(written.email).toBe("attacker@example.test"); + expect(written.displayName).toBe("Owned"); + expect(written.fullName).toBe("Attacker"); + expect(written.insurerName).toBe("Attacker Insurance"); + expect(written.heightCm).toBe(181); + }); +}); diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 55a8d5828..58c4adbf3 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -48,6 +48,28 @@ const extendedProfileSchema = profileSchema.extend({ type ExtendedProfileInput = z.infer; +/** + * The only profile fields a demo instance may write: the three the setup + * flow's baseline step collects. + * + * `PUT /api/auth/profile` sits on the proxy's `DEMO_MUTATION_ALLOWLIST` so + * that step can complete. That list is an EDGE control — it admits a path and + * a method, and it cannot see that the handler behind the path also writes + * this account's contact email, its display name, its full name and its + * insurer fields. On an ordinary instance those are the caller's own record; + * on the demo, which is ONE published account every visitor signs into, each + * of them is the record the next visitor arrives at, until a reseed. + * + * So the narrowing is here and not in the client. The web form sends only + * these three plus a display name, but a form is not a control: the whole + * reason the allowlist exists is that the caller is not trusted. + */ +export const DEMO_WRITABLE_PROFILE_FIELDS = [ + "heightCm", + "dateOfBirth", + "gender", +] as const; + /** * The profile schema has no cross-field `.refine()` — every field is an * independent scalar column. That means a rejected `gender` has no @@ -202,6 +224,17 @@ export async function applyProfileUpdate( } } + // Ahead of the conflict check below, deliberately: that check answers 409 + // when another account holds the address, which on a public demo is an + // existence oracle over the instance's accounts. Dropping the field first + // closes the write and the question in one move. + if (process.env.DEMO_MODE === "true") { + const writable = DEMO_WRITABLE_PROFILE_FIELDS as readonly string[]; + data = Object.fromEntries( + Object.entries(data).filter(([key]) => writable.includes(key)), + ) as Partial; + } + const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null; if (data.email !== undefined && normalizedEmail) { diff --git a/src/proxy.ts b/src/proxy.ts index b7dde0fcd..23c1e0cf7 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -185,9 +185,17 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> = // v1.39 — the baseline write the confirm screen makes. Without it a demo // visitor who typed a height got a 403 and a generic toast, so only the // empty-form/Skip path completed and the demo did not in fact walk the - // same flow (design spec §Principles 5). Date of birth, height and sex - // are closed, validated fields on the caller's own record and no health - // data. + // same flow (design spec §Principles 5). + // + // Read the HANDLER's field list, not the caller's. This route writes + // `email`, `displayName`, `fullName`, `insurerName`, `insurerIkNumber`, + // `insuranceNumber`, `locale`, `timezone`, `timeFormat`, `dateFormat` and + // `moodReminderEnabled` as well as the three the baseline step collects — + // admitting the path admits all of them, and on one shared published + // account each is the next visitor's starting state. So the write is + // narrowed on the SERVER: `applyProfileUpdate` drops everything outside + // `DEMO_WRITABLE_PROFILE_FIELDS` (height, date of birth, sex) while + // `DEMO_MODE` is set. What this entry admits is those three fields. // // Two neighbours of it are deliberately NOT here. The demo is one shared, // published account, so "the caller's own" is every visitor at once: diff --git a/tests/integration/demo-mode-profile-write.test.ts b/tests/integration/demo-mode-profile-write.test.ts new file mode 100644 index 000000000..5da3aa440 --- /dev/null +++ b/tests/integration/demo-mode-profile-write.test.ts @@ -0,0 +1,179 @@ +/** + * v1.39 — what `PUT /api/auth/profile` may write on a demo instance. + * + * The route is on the proxy's `DEMO_MUTATION_ALLOWLIST` so the setup flow's + * baseline step can complete in the demo. That allowlist is an EDGE control: + * it decides which path and method get through, and once a path is through, + * every field that route writes is writable. This route writes the account's + * contact email, its display name, its full name and its insurer fields — and + * the demo is one published account that every visitor signs into, so any of + * them lands on the record the next visitor sees. + * + * The narrowing therefore cannot live in the client. It is a server-side + * field filter in `applyProfileUpdate`, and this file proves it against real + * Postgres rather than against a status code: the route answers 200 either + * way — it answered 200 while writing the email — so the verdict has to be + * read back off the row. + */ +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), +})); + +/** What the shared demo account carries before any visitor touches it. */ +const SEEDED = { + username: "demo", + email: "demo@healthlog.test", + displayName: "HealthLog Demo", + fullName: "Demo Person", + insurerName: "Demo Krankenkasse", +}; + +/** What a visitor could put on the shared record through this one route. */ +const HOSTILE = { + email: "attacker@example.test", + displayName: "Owned", + fullName: "Attacker", + insurerName: "Attacker Insurance", +}; + +/** The three the baseline step exists to collect, and the only three + * the demo may write. */ +const BASELINE = { + heightCm: 181, + dateOfBirth: "1988-04-12", + gender: "FEMALE" as const, +}; + +const ORIGINAL_DEMO_MODE = process.env.DEMO_MODE; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +afterEach(() => { + if (ORIGINAL_DEMO_MODE === undefined) delete process.env.DEMO_MODE; + else process.env.DEMO_MODE = ORIGINAL_DEMO_MODE; +}); + +async function seedSignedInAccount(): Promise { + const { hashPassword } = await import("@/lib/auth/password"); + const { createSession } = await import("@/lib/auth/session"); + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { + ...SEEDED, + role: "USER", + passwordHash: await hashPassword("DemoStrongP@ssw0rd!123abc"), + }, + }); + await createSession(user.id, false); + return user.id; +} + +function makeRequest(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/auth/profile", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +async function putProfile(body: unknown): Promise { + const { PUT } = await import("@/app/api/auth/profile/route"); + const res = await PUT(makeRequest(body)); + return res.status; +} + +describe("PUT /api/auth/profile under DEMO_MODE (real Postgres)", () => { + it("writes the baseline fields and drops everything else", async () => { + process.env.DEMO_MODE = "true"; + const userId = await seedSignedInAccount(); + + // Everything in one body, exactly as a visitor could send it. The client + // never offers these, which is precisely why the client cannot be the + // control: this request does not come from the client. + const status = await putProfile({ ...HOSTILE, ...BASELINE }); + expect(status).toBe(200); + + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: userId }, + }); + + // The record the next visitor sees is the record the reseed left. + expect(row.email).toBe(SEEDED.email); + expect(row.displayName).toBe(SEEDED.displayName); + expect(row.fullName).toBe(SEEDED.fullName); + expect(row.insurerName).toBe(SEEDED.insurerName); + + // And the step the allowlist entry exists for still completes. + expect(row.heightCm).toBe(BASELINE.heightCm); + expect(row.gender).toBe(BASELINE.gender); + expect(row.dateOfBirth?.toISOString().slice(0, 10)).toBe( + BASELINE.dateOfBirth, + ); + }); + + it("does not answer the 'is this email taken?' question either", async () => { + // The conflict check runs before the write and 409s on a taken address. + // On a public demo that is an existence oracle over the instance's + // accounts, so the narrowing has to happen ahead of it, not after. + process.env.DEMO_MODE = "true"; + const prisma = getPrismaClient(); + await seedSignedInAccount(); + await prisma.user.create({ + data: { + username: "someone-else", + email: "taken@healthlog.test", + role: "USER", + passwordHash: "x", + }, + }); + + expect(await putProfile({ email: "taken@healthlog.test" })).toBe(200); + }); + + it("writes every field on an ordinary instance — the gate is demo mode, not the route", async () => { + delete process.env.DEMO_MODE; + const userId = await seedSignedInAccount(); + + expect(await putProfile({ ...HOSTILE, ...BASELINE })).toBe(200); + + const row = await getPrismaClient().user.findUniqueOrThrow({ + where: { id: userId }, + }); + expect(row.email).toBe(HOSTILE.email); + expect(row.displayName).toBe(HOSTILE.displayName); + expect(row.fullName).toBe(HOSTILE.fullName); + expect(row.insurerName).toBe(HOSTILE.insurerName); + expect(row.heightCm).toBe(BASELINE.heightCm); + }); +});