mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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]]),
|
||||
|
||||
Reference in new issue
Block a user