mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(demo): the profile route writes only the baseline fields in the demo
Allowlisting `PUT /api/auth/profile` so the setup flow's baseline step could finish also handed every visitor the shared demo account's contact email, its display name, its full name and its insurer fields: the allowlist admits a path and a method, and every field the handler behind it writes comes with them. The justification beside the entry named three fields; the entry admitted eleven. The write is narrowed on the server, in `applyProfileUpdate`, to height, date of birth and sex while `DEMO_MODE` is set — not in the client, which is the party the allowlist exists to distrust. It happens ahead of the email conflict check, which would otherwise answer "is this address registered here?" to anybody who can open the demo. The display-name box goes read-only there, as the anamnesis card already is, so nothing accepts input it will discard. The entry's comment now lists what the handler writes and what is admitted.
This commit is contained in:
1 parent
ceefbc5571
commit
45599c527e
7 files changed
+338
-6
No files matched your search
@@ -144,4 +144,21 @@ describe("<ConfirmScreen> — 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/);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,7 @@ export function BaselineFields({
|
||||
onChange,
|
||||
heightAdapter,
|
||||
errors,
|
||||
readOnlyDisplayName = false,
|
||||
}: {
|
||||
value: BaselineFieldValues;
|
||||
onChange: <K extends keyof BaselineFieldValues>(
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<AnamnesisCard
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { afterEach, describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
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<string, unknown>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,28 @@ const extendedProfileSchema = profileSchema.extend({
|
||||
|
||||
type ExtendedProfileInput = z.infer<typeof extendedProfileSchema>;
|
||||
|
||||
/**
|
||||
* 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<ExtendedProfileInput>;
|
||||
}
|
||||
|
||||
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
|
||||
|
||||
if (data.email !== undefined && normalizedEmail) {
|
||||
|
||||
+11
-3
@@ -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:
|
||||
|
||||
@@ -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<string> {
|
||||
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<number> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user