diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 63590ac05..9fbc47346 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -555,10 +555,7 @@ paths: type: string enum: - MANUAL - - WITHINGS - - IMPORT - APPLE_HEALTH - - COMPUTED glucoseContext: type: string enum: diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index 1ab1bca1a..3b95a933e 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -287,34 +287,49 @@ describe("measurement validation", () => { expect(validateMeasurementRange("RECOVERY_SCORE", 101)).not.toBeNull(); }); - it("rejects the server-owned COMPUTED source on a client write", () => { - const parsed = createMeasurementSchema.safeParse({ - type: "RECOVERY_SCORE", - value: 72, - measuredAt: "2026-06-02T12:00:00Z", - source: "COMPUTED", - }); - expect(parsed.success).toBe(false); - if (!parsed.success) { - const issue = parsed.error.issues.find((i) => - i.path.includes("source"), - ); - expect(issue, "expected a source-path rejection").toBeDefined(); - expect(issue!.message).toMatch(/server-owned/i); + it("rejects every non-writable source on a client write", () => { + // v1.10.0 QA — the single-POST validates `source` against the + // client-writable subset {MANUAL, APPLE_HEALTH}. COMPUTED (server + // scores), WITHINGS (the Withings webhook), and IMPORT (the CSV + // importer) are all server-owned and must be rejected so a client + // cannot forge a row attributed to a source it does not own. + for (const source of ["COMPUTED", "WITHINGS", "IMPORT"]) { + const parsed = createMeasurementSchema.safeParse({ + type: "WEIGHT", + value: 80, + measuredAt: "2026-06-02T12:00:00Z", + source, + }); + expect(parsed.success, `expected ${source} to be rejected`).toBe(false); + if (!parsed.success) { + const issue = parsed.error.issues.find((i) => + i.path.includes("source"), + ); + expect(issue, `expected a source-path rejection for ${source}`).toBeDefined(); + } } }); - it("still accepts a client-writable source for a score-typed manual row", () => { - // The COMPUTED source is the only one this refine blocks; a MANUAL - // write of any type stays valid (the source guard is orthogonal to - // whether a UI ever offers manual score entry). + it("accepts the client-writable sources MANUAL and APPLE_HEALTH", () => { + for (const source of ["MANUAL", "APPLE_HEALTH"]) { + const parsed = createMeasurementSchema.safeParse({ + type: "WEIGHT", + value: 80, + measuredAt: "2026-06-02T12:00:00Z", + source, + }); + expect(parsed.success, `expected ${source} to be accepted`).toBe(true); + } + }); + + it("defaults source to MANUAL when omitted", () => { const parsed = createMeasurementSchema.safeParse({ - type: "RECOVERY_SCORE", - value: 72, + type: "WEIGHT", + value: 80, measuredAt: "2026-06-02T12:00:00Z", - source: "MANUAL", }); expect(parsed.success).toBe(true); + if (parsed.success) expect(parsed.data.source).toBe("MANUAL"); }); }); }); diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 7c17e4ed1..48c80c76c 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -147,11 +147,24 @@ export const measurementSourceEnum = z.enum([ */ export const WRITABLE_MEASUREMENT_SOURCES = [ "MANUAL", - "WITHINGS", - "IMPORT", "APPLE_HEALTH", ] as const; +/** + * v1.10.0 QA — the Zod enum the single-entry POST validates `source` against. + * Built from `WRITABLE_MEASUREMENT_SOURCES` so the allowlist has one source of + * truth. A client may only attribute a row it actually owns: `MANUAL` (a + * hand-entered reading) or `APPLE_HEALTH` (the HealthKit batch's per-row + * source). `WITHINGS` is owned by the Withings webhook, `IMPORT` by the CSV + * importer, and `COMPUTED` by the nightly score engines — letting a client + * forge any of those would pollute the per-source canonical picker with rows + * the server never produced. The batch route mirrors the same exclusion with + * its own narrower `{APPLE_HEALTH, MANUAL}` `batchSourceEnum`. + */ +export const writableMeasurementSourceEnum = z.enum( + WRITABLE_MEASUREMENT_SOURCES, +); + const unitMap: Record = { WEIGHT: "kg", BLOOD_PRESSURE_SYS: "mmHg", @@ -428,7 +441,11 @@ export const createMeasurementSchema = z value: z.number(), measuredAt: z.iso.datetime({ offset: true }).transform((s) => new Date(s)), notes: z.string().max(MEASUREMENT_NOTES_MAX_LENGTH).optional(), - source: measurementSourceEnum.optional().default("MANUAL"), + // v1.10.0 QA — validate against the client-writable subset, not the full + // `MeasurementSource` enum. A client may attribute only `MANUAL` or + // `APPLE_HEALTH`; `WITHINGS` / `IMPORT` / `COMPUTED` are server-owned and + // forging them would pollute the per-source canonical picker. + source: writableMeasurementSourceEnum.optional().default("MANUAL"), // Only applies when type === BLOOD_GLUCOSE. Mirrored by a CHECK // constraint in Postgres (see migration 0021). glucoseContext: glucoseContextEnum.optional(), @@ -453,16 +470,7 @@ export const createMeasurementSchema = z "Blood glucose measurements require a context (fasting/postprandial/random/bedtime); other types must not set one.", path: ["glucoseContext"], }, - ) - // v1.10.0 — computed scores (WX-C). The `COMPUTED` source is server-owned: - // a nightly engine mints those rows, a client never does. Reject it here - // so the single-entry POST cannot forge a server-derived row (the batch - // route enforces the same exclusion through its `{APPLE_HEALTH, MANUAL}` - // allowlist). - .refine((data) => data.source !== "COMPUTED", { - message: "The COMPUTED source is server-owned and cannot be set by a client.", - path: ["source"], - }); + ); export const updateMeasurementSchema = z.object({ value: z.number().min(0).max(500000).optional(),