diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f0241e651..30b9cbc17 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2363,8 +2363,10 @@ paths: tags: - Measurements summary: Delete measurements by external ID (iOS deletion-sync) - description: Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are - silently skipped (cross-user 404 guard). Up to 500 externalIds per call. + description: "Removes the user's HealthKit-ingested measurement rows (source `APPLE_HEALTH`) whose externalId is in the + request list. Only app-minted rows are eligible: an externalId that maps to an integration- or manually-sourced + row (a colliding externalUUID) is a no-op, never a delete. Rows owned by another user are silently skipped + (cross-user 404 guard). Up to 500 externalIds per call." requestBody: required: true content: diff --git a/src/app/api/measurements/by-external-ids/__tests__/route.test.ts b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts new file mode 100644 index 000000000..c618a6bc8 --- /dev/null +++ b/src/app/api/measurements/by-external-ids/__tests__/route.test.ts @@ -0,0 +1,157 @@ +/** + * DELETE /api/measurements/by-external-ids — iOS HealthKit deletion-sync. + * + * Asserts the origin-scoped soft-delete contract (v1.25, iOS #35): the + * reconciliation only tombstones rows the app minted through its HealthKit + * ingestion path (`source = APPLE_HEALTH`). A foreign-origin externalId — one + * that collides with an integration- or manually-sourced row — is excluded by + * the source predicate and is a silent no-op, never a delete. Cross-user and + * idempotency guards stay as before. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { + findMany: vi.fn(), + updateMany: vi.fn(), + }, + auditLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); +vi.mock("@/lib/insights/comprehensive-generate", () => ({ + invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rollups/measurement-rollups", async () => { + const actual = await vi.importActual< + typeof import("@/lib/rollups/measurement-rollups") + >("@/lib/rollups/measurement-rollups"); + return { + collapseToTypeDayKeys: actual.collapseToTypeDayKeys, + recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined), + }; +}); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { DELETE } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; +import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function delReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/measurements/by-external-ids", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(recomputeBucketsForMeasurement).mockResolvedValue(undefined); + vi.mocked(invalidateStatusInsightsForTypes).mockResolvedValue(undefined); +}); + +describe("DELETE /api/measurements/by-external-ids", () => { + it("scopes both the pre-fetch and the soft-delete to the app-minted APPLE_HEALTH source", async () => { + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { type: "WEIGHT", measuredAt: new Date("2026-01-01T08:00:00Z") }, + ] as never); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ + count: 1, + } as never); + + const res = await DELETE(delReq({ externalIds: ["uuid-aaa"] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deletedCount: number } }; + expect(body.data.deletedCount).toBe(1); + + const findWhere = vi.mocked(prisma.measurement.findMany).mock.calls[0]![0]! + .where; + expect(findWhere).toMatchObject({ + userId: "user-1", + source: "APPLE_HEALTH", + externalId: { in: ["uuid-aaa"] }, + deletedAt: null, + }); + + const updateWhere = vi.mocked(prisma.measurement.updateMany).mock + .calls[0]![0].where; + expect(updateWhere).toMatchObject({ + userId: "user-1", + source: "APPLE_HEALTH", + externalId: { in: ["uuid-aaa"] }, + deletedAt: null, + }); + }); + + it("is a no-op for a foreign-origin externalId that collides on a non-app-minted row", async () => { + // The caller's reconciliation list carries an externalId that, on the + // server, maps to a WITHINGS-sourced row (a colliding externalUUID). The + // `source = APPLE_HEALTH` predicate excludes it, so the pre-fetch and the + // soft-delete both match zero rows. + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ + count: 0, + } as never); + + const res = await DELETE(delReq({ externalIds: ["withings-collision"] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deletedCount: number } }; + expect(body.data.deletedCount).toBe(0); + + // The delete was still source-scoped (the foreign row was never eligible). + const updateWhere = vi.mocked(prisma.measurement.updateMany).mock + .calls[0]![0].where; + expect(updateWhere).toMatchObject({ source: "APPLE_HEALTH" }); + + // Zero deletions → no cache eviction, no rollup recompute, no insight bust. + expect(invalidateUserMeasurements).not.toHaveBeenCalled(); + expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled(); + expect(invalidateStatusInsightsForTypes).not.toHaveBeenCalled(); + }); + + it("returns deletedCount 0 with 200 for an empty externalIds array", async () => { + const res = await DELETE(delReq({ externalIds: [] })); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: { deletedCount: number } }; + expect(body.data.deletedCount).toBe(0); + expect(prisma.measurement.updateMany).not.toHaveBeenCalled(); + }); + + it("rejects a batch over the 500-id cap with 422", async () => { + const externalIds = Array.from({ length: 501 }, (_, i) => `uuid-${i}`); + const res = await DELETE(delReq({ externalIds })); + expect(res.status).toBe(422); + expect(prisma.measurement.updateMany).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/measurements/by-external-ids/route.ts b/src/app/api/measurements/by-external-ids/route.ts index 9c07d6a53..9914cb12b 100644 --- a/src/app/api/measurements/by-external-ids/route.ts +++ b/src/app/api/measurements/by-external-ids/route.ts @@ -10,6 +10,13 @@ * rather than removing the row, so deletions surface as tombstones on the * `/api/sync/changes` delta feed). * + * Origin scope (v1.25, iOS #35): the delete only touches rows the iOS + * client minted through its HealthKit ingestion path (`source = + * APPLE_HEALTH`). An externalId the app never minted — an integration- or + * manually-sourced row that happens to collide on externalUUID — is a + * no-op, never a delete. Defence-in-depth so a foreign HealthKit sample + * with a colliding UUID cannot be tombstoned by this reconciliation. + * * Idempotency contract: * - Cross-user safety: rows owned by another user are silently skipped * (Prisma's `updateMany` with `userId` in the where-clause guarantees @@ -105,9 +112,21 @@ async function deleteByExternalIds(request: NextRequest): Promise { // tombstoned by this call. We pre-fetch the (type, measuredAt) tuples // of the live matches so the rollup recompute below knows which // buckets to refresh. + // + // v1.25 (iOS #35) — scope to the app's HealthKit ingestion source + // (`APPLE_HEALTH`). This reconciliation removes HealthKit samples the + // iOS client minted; only `source = APPLE_HEALTH` rows are app-minted on + // this path. Defence-in-depth against a colliding externalId: an + // integration- or manually-sourced row (WITHINGS / OURA / MANUAL / …) + // that happens to share an externalUUID with a now-absent HealthKit + // sample is NOT app-minted, so it falls outside the predicate and the + // delete is a no-op for it rather than tombstoning a foreign-origin row. + const APP_MINTED_SOURCE = "APPLE_HEALTH" as const; + const affectedRows = await prisma.measurement.findMany({ where: { userId: user.id, + source: APP_MINTED_SOURCE, externalId: { in: externalIds }, deletedAt: null, }, @@ -117,6 +136,7 @@ async function deleteByExternalIds(request: NextRequest): Promise { const result = await prisma.measurement.updateMany({ where: { userId: user.id, + source: APP_MINTED_SOURCE, externalId: { in: externalIds }, deletedAt: null, }, diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 650d157f8..09aab36be 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -638,7 +638,7 @@ export const measurementPaths: NonNullable = { tags: ["Measurements"], summary: "Delete measurements by external ID (iOS deletion-sync)", description: - "Removes the user's measurement rows whose externalId is in the request list. Rows owned by another user are silently skipped (cross-user 404 guard). Up to 500 externalIds per call.", + "Removes the user's HealthKit-ingested measurement rows (source `APPLE_HEALTH`) whose externalId is in the request list. Only app-minted rows are eligible: an externalId that maps to an integration- or manually-sourced row (a colliding externalUUID) is a no-op, never a delete. Rows owned by another user are silently skipped (cross-user 404 guard). Up to 500 externalIds per call.", requestBody: { required: true, content: {