From 3f8c2a98dd51328d746c6d2e61a0f0547955dcf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 13:41:11 +0200 Subject: [PATCH] fix(measurements): answer the day drill-down without the sealed note The dayKey drill-down of GET /api/measurements answered the stored rows as they were, notesEncrypted ciphertext included and the note itself unread. It now selects the contract's columns by name and shapes each row like the plain page: the note decrypted onto notes, the ciphertext never on the wire. A route test walks the measurement reads over rows with a sealed note (the page, the drill-down, the day-grouped list, an aggregate, the detail) and fails on any sealed field; it failed on the drill-down before this change. The parity walk drops its contract exemption for the drill-down. --- src/lib/measurements/list/measurement-list.ts | 38 +++++- .../routes/measurements-no-ciphertext.test.ts | 108 ++++++++++++++++++ .../measurements.get-05-drill-down.json.snap | 1 - .../routes/parity/measurements.parity.test.ts | 15 +-- 4 files changed, 148 insertions(+), 14 deletions(-) create mode 100644 tests/integration/routes/measurements-no-ciphertext.test.ts diff --git a/src/lib/measurements/list/measurement-list.ts b/src/lib/measurements/list/measurement-list.ts index 57e0535fd..2aea645b1 100644 --- a/src/lib/measurements/list/measurement-list.ts +++ b/src/lib/measurements/list/measurement-list.ts @@ -28,7 +28,38 @@ import { import { dayGroupedListResponse } from "@/lib/measurements/list/day-grouped"; import { sleepListResponse } from "@/lib/measurements/list/sleep-list"; import { resolveStoredTimezone, resolveUserTimezone } from "@/lib/tz/resolver"; -import type { MeasurementType } from "@/generated/prisma/client"; +import type { MeasurementType, Prisma } from "@/generated/prisma/client"; + +/** + * The columns a measurement row answers with (`MeasurementResource`), plus + * the sealed note `shapeMeasurementNotes` decrypts onto `notes` and drops. + * Named column by column so a column added to the model reaches the wire + * only when someone adds it here. + */ +const MEASUREMENT_WIRE_SELECT = { + id: true, + userId: true, + type: true, + value: true, + valueMin: true, + valueMax: true, + unit: true, + source: true, + measuredAt: true, + notes: true, + notesEncrypted: true, + externalId: true, + externalSourceVersion: true, + aggregationProvenance: true, + glucoseContext: true, + sleepStage: true, + rhythmClassification: true, + deviceType: true, + syncVersion: true, + deletedAt: true, + createdAt: true, + updatedAt: true, +} as const satisfies Prisma.MeasurementSelect; type ListQuery = z.output; type ListUser = { id: string; timezone: string | null }; @@ -162,13 +193,16 @@ async function drillDownResponse( // returning 422 when a caller asks for more). `limit` is read straight // through because the validator has already clamped it. take: limit, + select: MEASUREMENT_WIRE_SELECT, }); annotate({ action: { name: "measurement.list" }, meta: { total: samples.length, type, dayKey, mode: "drill-down" }, }); return apiSuccess({ - measurements: samples, + // The same row shape as the plain page: the note decrypted onto `notes`, + // the ciphertext never on the wire. + measurements: samples.map(shapeMeasurementNotes), meta: { total: samples.length, limit, offset: 0, dayKey }, }); } diff --git a/tests/integration/routes/measurements-no-ciphertext.test.ts b/tests/integration/routes/measurements-no-ciphertext.test.ts new file mode 100644 index 000000000..702e6a688 --- /dev/null +++ b/tests/integration/routes/measurements-no-ciphertext.test.ts @@ -0,0 +1,108 @@ +/** + * No measurement read sends a sealed column to the client. + * + * A measurement's note is stored sealed in `notesEncrypted` and answered + * decrypted on `notes`. The `dayKey` drill-down of `GET /api/measurements` + * once answered the stored rows as they were, ciphertext included. Every + * read below runs over rows that carry a sealed note, and each must answer + * the readable note and no `*Encrypted` field or raw bytes. `callRoute` + * refuses sealed fields on every route it drives; the explicit checks here + * say which reads were looked at. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { GET as listMeasurements } from "@/app/api/measurements/route"; +import { GET as readMeasurement } from "@/app/api/measurements/[id]/route"; +import { encryptNote } from "@/lib/crypto/note-cipher"; + +import { callRoute, sealedFieldPaths } from "../../kit/call-route"; +import { + makeMeasurement, + makeSession, + makeUser, + type SessionFixture, +} from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; + +const NOTE = "felt dizzy after standing up"; + +let session: SessionFixture; +let weightId: string; + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + const user = await makeUser({ timezone: "UTC" }); + session = await makeSession(user); + const weight = await makeMeasurement(user, { + notes: null, + notesEncrypted: encryptNote(NOTE), + }); + weightId = weight.id; + await makeMeasurement(user, { + type: "ACTIVITY_STEPS", + value: 420, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: new Date("2026-03-02T09:00:00Z"), + externalId: "uuid-steps-1", + notes: null, + notesEncrypted: encryptNote(NOTE), + }); +}); + +type Row = { notes?: string | null }; + +async function list(query: Record) { + return callRoute<{ measurements: Row[] }>(listMeasurements, { + method: "GET", + route: "/api/measurements", + query, + auth: session, + }); +} + +describe("measurement reads answer the note, never its ciphertext", () => { + it("the plain page", async () => { + const res = await list({ limit: 10 }); + expect(res.status).toBe(200); + expect(sealedFieldPaths(res.body)).toEqual([]); + expect(res.data.measurements.map((m) => m.notes)).toEqual([NOTE, NOTE]); + }); + + it("the cumulative day drill-down", async () => { + const res = await list({ type: "ACTIVITY_STEPS", dayKey: "2026-03-02" }); + expect(res.status).toBe(200); + expect(sealedFieldPaths(res.body)).toEqual([]); + expect(res.data.measurements).toHaveLength(1); + expect(res.data.measurements[0].notes).toBe(NOTE); + }); + + it("the day-grouped list and an aggregate", async () => { + const queries: Array> = [ + { type: "ACTIVITY_STEPS", groupBy: "day" }, + { + type: "WEIGHT", + aggregate: "daily", + from: "2026-03-01T00:00:00.000Z", + to: "2026-03-03T00:00:00.000Z", + }, + ]; + for (const query of queries) { + const res = await list(query); + expect(res.status).toBe(200); + expect(sealedFieldPaths(res.body)).toEqual([]); + } + }); + + it("the detail read", async () => { + const res = await callRoute(readMeasurement, { + method: "GET", + route: "/api/measurements/{id}", + params: { id: weightId }, + auth: session, + }); + expect(res.status).toBe(200); + expect(sealedFieldPaths(res.body)).toEqual([]); + expect(res.data.notes).toBe(NOTE); + }); +}); diff --git a/tests/integration/routes/parity/__snapshots__/measurements.get-05-drill-down.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.get-05-drill-down.json.snap index 4e6691c32..9867be855 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.get-05-drill-down.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.get-05-drill-down.json.snap @@ -14,7 +14,6 @@ "source": "APPLE_HEALTH", "measuredAt": "2026-10-13T12:00:00.000Z", "notes": null, - "notesEncrypted": null, "externalId": "stats:HKQuantityTypeIdentifierStepCount:2026-10-13", "externalSourceVersion": null, "aggregationProvenance": "HEALTHKIT_STATISTICS", diff --git a/tests/integration/routes/parity/measurements.parity.test.ts b/tests/integration/routes/parity/measurements.parity.test.ts index 48f63850e..5055c39e0 100644 --- a/tests/integration/routes/parity/measurements.parity.test.ts +++ b/tests/integration/routes/parity/measurements.parity.test.ts @@ -378,17 +378,10 @@ describe("GET /api/measurements parity", () => { it("the list modes", async () => { await get("get-04-sleep", { type: "SLEEP_DURATION" }); - // The drill-down answers the stored rows as they are, `notesEncrypted` - // included, which the contract does not declare (the other list modes - // strip it). Pinned as it is; this walk records behaviour, it does not - // change it. - await get( - "get-05-drill-down", - { type: "ACTIVITY_STEPS", dayKey: "2026-10-13" }, - { - skip: "the drill-down returns notesEncrypted, which the contract omits", - }, - ); + await get("get-05-drill-down", { + type: "ACTIVITY_STEPS", + dayKey: "2026-10-13", + }); await get("get-06-day-grouped", { type: "ACTIVITY_STEPS", groupBy: "day" }); await get("get-07-rollup-series", { type: "ACTIVITY_STEPS",