From 6dd71f010320fd1be88db1d556604fe64bc7d91a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 16:16:40 +0200 Subject: [PATCH] fix(medications): keep scoped credentials out of the dose arrival record The dose arrival record describes the phone's Apple Health sync. The measurement batch already leaves it alone for a narrow, single-purpose credential; the intake batch now does the same, so the record stays honest should the route ever accept such a credential. --- .../bulk/__tests__/dose-arrival-scope.test.ts | 137 ++++++++++++++++++ src/app/api/medications/intake/bulk/route.ts | 12 +- 2 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 src/app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts diff --git a/src/app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts b/src/app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts new file mode 100644 index 000000000..3c8e9c0aa --- /dev/null +++ b/src/app/api/medications/intake/bulk/__tests__/dose-arrival-scope.test.ts @@ -0,0 +1,137 @@ +/** + * The Apple Health dose arrival record (`MEDICATION_DOSE` in the per-type + * arrival table) describes the phone's Apple Health sync. A narrow, + * single-purpose credential is not that sync, so a batch it sends never + * writes the record, the same rule the measurement batch keeps. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medication: { + findMany: vi.fn(), + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + medicationIntakeEvent: { + create: vi.fn(), + findUnique: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + findFirst: vi.fn(), + }, + user: { update: vi.fn() }, + auditLog: { create: vi.fn() }, + }, +})); +vi.mock("@/lib/api-handler", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, requireAuth: vi.fn() }; +}); +vi.mock("@/lib/integrations/healthkit-type-sync", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@/lib/integrations/healthkit-type-sync") + >(); + return { ...actual, recordHealthKitTypeArrivals: vi.fn() }; +}); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: + (fn: (...args: Args) => Promise) => + (...args: Args) => + fn(...args), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMedications: vi.fn(), +})); +vi.mock("@/lib/notifications/medication-intake-sync", () => ({ + queueMedicationIntakeSync: vi.fn(), +})); +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 { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { requireAuth } from "@/lib/api-handler"; +import { recordHealthKitTypeArrivals } from "@/lib/integrations/healthkit-type-sync"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const USER = { + id: "user-1", + username: "tester", + role: "USER" as const, + timezone: "Europe/Berlin", +}; + +/** A replayed Apple dose: the cheapest batch that still counts as an arrival. */ +async function postReplayedDose(): Promise { + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce([ + { id: "row-existing", externalId: "hk-dose-1" }, + ] as never); + return POST( + new NextRequest("http://localhost/api/medications/intake/bulk", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + syncTrigger: "background", + entries: [ + { + medicationId: "med-apple", + takenAt: "2026-06-15T12:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "hk-dose-1", + }, + ], + }), + }), + ); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); + vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { id: "med-apple", externalSource: "APPLE_HEALTH" }, + ] as never); +}); + +describe("the dose arrival record and the credential that sent the batch", () => { + it("is written for the phone's own session", async () => { + vi.mocked(requireAuth).mockResolvedValue({ + session: { id: "s" }, + user: USER, + } as never); + const res = await postReplayedDose(); + expect(res.status).toBe(200); + expect(recordHealthKitTypeArrivals).toHaveBeenCalledTimes(1); + }); + + it("is not written for a narrow, single-purpose credential", async () => { + vi.mocked(requireAuth).mockResolvedValue({ + session: { id: "t" }, + user: USER, + bearerScope: "measurements:write", + } as never); + const res = await postReplayedDose(); + expect(res.status).toBe(200); + expect(recordHealthKitTypeArrivals).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 93e291a62..00bfc9d5d 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -43,7 +43,7 @@ import { NextRequest } from "next/server"; import { z } from "zod/v4"; import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { @@ -195,7 +195,13 @@ interface EntryResult { export const POST = apiHandler(withIdempotency<[NextRequest]>(postBulk)); async function postBulk(request: NextRequest): Promise { - const { user } = await requireAuth(); + const auth = await requireAuth(); + const { user } = auth; + // A narrow, single-purpose credential (a bridge, an ingest token) is no + // Apple Health sync, so it never writes the dose arrival record below. The + // bare `requireAuth()` refuses such a credential today; the check keeps the + // record honest should this route ever declare a scope. + const scoped = isScopedCredential(auth); const rl = await checkRateLimit( `medications:intake:bulk:${user.id}`, @@ -1021,7 +1027,7 @@ async function postBulk(request: NextRequest): Promise { doseArrival.accepted += 1; if (result.status !== "duplicate") doseArrival.newSamples += 1; } - if (doseArrival.accepted > 0) { + if (doseArrival.accepted > 0 && !scoped) { await recordHealthKitTypeArrivals(prisma, { userId: user.id, counts: new Map([[MEDICATION_DOSE_ARRIVAL_TYPE, doseArrival]]),