mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +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:
|
||||
|
||||
Reference in new issue
Block a user