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",