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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 16:38:12 +02:00
1 parent f665a42789
commit 6dd71f0103
2 files changed
+146 -3

No files matched your search

@@ -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<typeof import("@/lib/api-handler")>();
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:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...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<Response> {
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();
});
});
+9 -3
View File
@@ -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<Response> {
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<Response> {
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]]),