mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(measurements): validate single-POST source against the writable allowlist
WRITABLE_MEASUREMENT_SOURCES was a dead export whose docblock claimed it
gated the single-entry POST, but the schema only refined out COMPUTED —
WITHINGS and IMPORT stayed client-writable, so a client could attribute a row
to a source the server owns (the Withings webhook / CSV importer) and pollute
the per-source canonical picker. Wire createMeasurementSchema.source to a Zod
enum built from the now-correct {MANUAL, APPLE_HEALTH} allowlist; the enum
subsumes the old COMPUTED refine. Regenerate the OpenAPI contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
dabdfdf68e
commit
f170198a83
3 files changed
+57
-37
No files matched your search
@@ -555,10 +555,7 @@ paths:
|
||||
type: string
|
||||
enum:
|
||||
- MANUAL
|
||||
- WITHINGS
|
||||
- IMPORT
|
||||
- APPLE_HEALTH
|
||||
- COMPUTED
|
||||
glucoseContext:
|
||||
type: string
|
||||
enum:
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {
|
||||
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(),
|
||||
|
||||
Reference in new issue
Block a user