fix(measurements): scope external-id delete to app-minted HealthKit rows

DELETE /api/measurements/by-external-ids now only tombstones 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 — falls outside the predicate
and is a no-op rather than a delete. Defence-in-depth so a foreign
HealthKit sample with a colliding UUID cannot be tombstoned by the
periodic reconciliation. The delete stays user-scoped as before.

Add a route test proving a foreign-origin externalId is not deleted, and
regenerate the OpenAPI spec for the narrowed contract.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 16:30:28 +02:00
1 parent 74986116cd
commit 29db83ee5a
4 files changed
+182 -3

No files matched your search

+4 -2
View File
@@ -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:
@@ -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();
});
});
@@ -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<Response> {
// 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<Response> {
const result = await prisma.measurement.updateMany({
where: {
userId: user.id,
source: APP_MINTED_SOURCE,
externalId: { in: externalIds },
deletedAt: null,
},
+1 -1
View File
@@ -638,7 +638,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["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.",
"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: {