From d2f6bf33ebad78160548dab683d179df91245c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:58:24 +0200 Subject: [PATCH] fix(workouts): sanitize WHOOP zone enrichment --- .../pick-canonical-workout-rows.test.ts | 37 ++++++++++++++++++- .../pick-canonical-workout-rows.ts | 34 ++++++++++++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts index 7599994e8..62f3fb58b 100644 --- a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts +++ b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts @@ -89,7 +89,11 @@ describe("pickCanonicalWorkoutRows", () => { avgHeartRate: 146, maxHeartRate: 181, metadata: { - zoneDurations: { zone_one_milli: 60_000 }, + zoneDurations: { + zone_one_milli: 60_000, + nestedSecret: { mustNotFlow: true }, + zone_six_milli: 90_000, + }, whoopWorkoutStrain: 12.3, }, }; @@ -107,6 +111,11 @@ describe("pickCanonicalWorkoutRows", () => { zoneDurations: { zone_one_milli: 60_000 }, }, }); + expect( + (picked.metadata as Record).zoneDurations, + ).toEqual({ + zone_one_milli: 60_000, + }); expect(picked.metadata).not.toHaveProperty("whoopWorkoutStrain"); expect(apple).toEqual({ id: "apple", @@ -149,6 +158,32 @@ describe("pickCanonicalWorkoutRows", () => { }); }); + it("rejects malformed WHOOP zone durations instead of partially copying them", () => { + const result = pickCanonicalWorkoutRows([ + { + id: "apple", + startedAt: new Date("2026-06-03T06:30:00Z"), + sportType: "running", + source: "APPLE_HEALTH", + metadata: { owner: "apple" }, + }, + { + id: "whoop", + startedAt: new Date("2026-06-03T06:30:30Z"), + sportType: "running", + source: "WHOOP", + metadata: { + zoneDurations: { + zone_one_milli: 60_000, + zone_two_milli: -1, + }, + }, + }, + ]); + + expect(result[0].metadata).toEqual({ owner: "apple" }); + }); + it("does not enrich across sport or fixed-window boundaries", () => { const apple: RowFixture = { id: "apple", diff --git a/src/lib/measurements/pick-canonical-workout-rows.ts b/src/lib/measurements/pick-canonical-workout-rows.ts index 7f410a6ad..e36604093 100644 --- a/src/lib/measurements/pick-canonical-workout-rows.ts +++ b/src/lib/measurements/pick-canonical-workout-rows.ts @@ -77,13 +77,43 @@ function jsonRecord(value: unknown): Record | null { : null; } +const WHOOP_ZONE_DURATION_KEYS = [ + "zone_zero_milli", + "zone_one_milli", + "zone_two_milli", + "zone_three_milli", + "zone_four_milli", + "zone_five_milli", +] as const; + +function sanitizedWhoopZoneDurations( + metadata: unknown, +): Record | null { + const raw = jsonRecord(jsonRecord(metadata)?.zoneDurations); + if (!raw) return null; + + const sanitized: Record = {}; + for (const key of WHOOP_ZONE_DURATION_KEYS) { + const value = raw[key]; + if (value === undefined) continue; + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < 0 + ) { + return null; + } + sanitized[key] = value; + } + return Object.keys(sanitized).length > 0 ? sanitized : null; +} + function enrichFromWhoopTwin( winner: T, donor: T, ): T { const winnerMetadata = jsonRecord(winner.metadata); - const donorMetadata = jsonRecord(donor.metadata); - const donorZones = donorMetadata?.zoneDurations; + const donorZones = sanitizedWhoopZoneDurations(donor.metadata); const needsZones = donorZones != null && winnerMetadata?.zoneDurations == null; const needsAvg =