diff --git a/tests/integration/bearer-scope-enforcement.test.ts b/tests/integration/bearer-scope-enforcement.test.ts index c4e2d32dc..d8e082401 100644 --- a/tests/integration/bearer-scope-enforcement.test.ts +++ b/tests/integration/bearer-scope-enforcement.test.ts @@ -504,7 +504,7 @@ describe("B8 — the measurement-ingest scope reaches its two routes and no othe } as never); } - it("admits the scope on POST /api/measurements, attributed MANUAL", async () => { + it("admits the scope on POST /api/measurements, attributed EXTERNAL", async () => { await armToken(["measurements:write"], "mwrite1"); const { POST } = await import("@/app/api/measurements/route"); const res = await POST(postMeasurement()); @@ -513,7 +513,41 @@ describe("B8 — the measurement-ingest scope reaches its two routes and no othe const row = await getPrismaClient().measurement.findFirstOrThrow({ where: { userId: USER_ID, type: "WEIGHT" }, }); - expect(row.source).toBe("MANUAL"); + // v1.38.x — the body names no source (see `measurementBody`), and the + // route resolves one from the credential rather than falling back on the + // schema default. This assertion is the whole feature: a reading pushed + // by a bridge is distinguishable afterwards from one typed by hand. + expect(row.source).toBe("EXTERNAL"); + }); + + it("refuses a body naming MANUAL, rather than overriding it", async () => { + // The source is server-resolved now, so an explicit assertion has only + // two possible fates: honoured (and the row is indistinguishable from a + // typed one, defeating the point) or overridden in silence (which hands + // the caller a row it did not ask for and no way to notice). The route + // takes neither and refuses. MANUAL specifically, because it used to be + // the permitted value — the widening from "names APPLE_HEALTH" to "names + // anything" is exactly this case. + await armToken(["measurements:write"], "mwrite1b"); + const { POST } = await import("@/app/api/measurements/route"); + const res = await POST( + new NextRequest("https://health.example/api/measurements", { + method: "POST", + headers: { + authorization: headerJar.get("authorization")!, + "content-type": "application/json", + }, + body: JSON.stringify({ + type: "WEIGHT", + value: 74.2, + measuredAt: new Date().toISOString(), + source: "MANUAL", + }), + } as never), + ); + + expect(res.status).toBe(422); + expect(await getPrismaClient().measurement.count()).toBe(0); }); it("admits the scope on the batch route, attributed MANUAL", async () => { @@ -545,8 +579,11 @@ describe("B8 — the measurement-ingest scope reaches its two routes and no othe const row = await getPrismaClient().measurement.findFirstOrThrow({ where: { userId: USER_ID, externalId: "uuid-measurements-write-1" }, }); - // Absent `source` defaults to APPLE_HEALTH for the phone and MANUAL here. - expect(row.source).toBe("MANUAL"); + // Absent `source` defaults to APPLE_HEALTH for the phone and resolves to + // EXTERNAL here. That also moves the dedup key to + // `(userId, type, EXTERNAL, externalId)`, so a bridge's externalIds can + // never collide with the phone's. + expect(row.source).toBe("EXTERNAL"); }); it("does not move the native sync checkpoint", async () => { @@ -611,6 +648,83 @@ describe("B8 — the measurement-ingest scope reaches its two routes and no othe expect(await getPrismaClient().measurement.count()).toBe(0); }); + it("refuses a batch entry naming MANUAL too, not only APPLE_HEALTH", async () => { + // MANUAL was permitted here while it was also the value the route forced, + // so nothing was ever overridden. Now it would be, and the sibling case + // above says why that is refused instead. + await armToken(["measurements:write"], "mwrite4b"); + const { POST } = await import("@/app/api/measurements/batch/route"); + const res = await POST( + new NextRequest("https://health.example/api/measurements/batch", { + method: "POST", + headers: { + authorization: headerJar.get("authorization")!, + "content-type": "application/json", + }, + body: JSON.stringify({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 73, + unit: "kg", + startDate: new Date().toISOString(), + endDate: new Date().toISOString(), + externalId: "uuid-measurements-write-3b", + source: "MANUAL", + }, + ], + }), + } as never), + ); + + expect(res.status).toBe(422); + expect(await getPrismaClient().measurement.count()).toBe(0); + }); + + it("leaves the row it wrote editable by its owner", async () => { + // The counterpart to the source split. `EXTERNAL` is outside + // `WRITABLE_MEASUREMENT_SOURCES` — no client may name it — but it is + // inside `USER_CORRECTABLE_MEASUREMENT_SOURCES`, because the hardware + // behind an ingest token is the user's own scale, not a provider. A later + // tidy-up that folds the two constants back together turns every bridge + // row value-locked with a 409, and this case is what notices. + await armToken(["measurements:write"], "mwrite1c"); + const { POST } = await import("@/app/api/measurements/route"); + expect((await POST(postMeasurement())).status).toBe(201); + const row = await getPrismaClient().measurement.findFirstOrThrow({ + where: { userId: USER_ID, type: "WEIGHT" }, + }); + expect(row.source).toBe("EXTERNAL"); + + // Edit as the owner: a cookie session, no Bearer. The scoped credential + // could not reach `PUT` at all — that is the point of a write-only scope. + headerJar.delete("authorization"); + const session = await getPrismaClient().session.create({ + data: { + userId: USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); + + const { PUT } = await import("@/app/api/measurements/[id]/route"); + const res = await PUT( + new NextRequest(`https://health.example/api/measurements/${row.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ value: 75.1 }), + } as never), + { params: Promise.resolve({ id: row.id }) }, + ); + + expect(res.status).toBe(200); + const reread = await getPrismaClient().measurement.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(reread.value).toBe(75.1); + expect(reread.source).toBe("EXTERNAL"); + }); + it("is refused on the read leg of the very same path", async () => { // What makes the scope write-only rather than write-named. await armToken(["measurements:write"], "mwrite5"); diff --git a/tests/integration/measurement-source-write-smoke.test.ts b/tests/integration/measurement-source-write-smoke.test.ts index ff4ac25b7..937100196 100644 --- a/tests/integration/measurement-source-write-smoke.test.ts +++ b/tests/integration/measurement-source-write-smoke.test.ts @@ -8,12 +8,14 @@ import type { import { POST as postCsvImport } from "@/app/api/import/csv/route"; import { POST as postAppleHealthBatch } from "@/app/api/measurements/batch/route"; import { POST as postManualMeasurement } from "@/app/api/measurements/route"; +import { hashToken } from "@/lib/auth/hmac"; import { encrypt } from "@/lib/crypto"; import { upsertFitbitMeasurements } from "@/lib/fitbit/sync-core"; import { upsertGoogleHealthMeasurements } from "@/lib/google-health/sync-core"; import { upsertScoreRow } from "@/lib/insights/score-row"; import { logMcpMeasurement } from "@/lib/mcp/writes"; import { logTelegramMeasurement } from "@/lib/measurements/create-from-telegram"; +import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement"; import { upsertNightscoutEntries } from "@/lib/nightscout/sync"; import { upsertOuraMeasurements } from "@/lib/oura/sync"; @@ -309,6 +311,50 @@ const SOURCE_CASES = { expect(verdict.status).toBe("inserted"); }, }, + // v1.38.x — the third-party ingest token. Written through the ordinary + // single-entry POST, because that IS the production path: the route reads + // the credential and resolves the source itself. The body names none — one + // that did would be refused 422. + EXTERNAL: { + expectedType: "PULSE", + expectedExternalId: null, + write: async () => { + // The trap in this suite: `beforeEach` seeds a session cookie and the + // caller resolver is cookie-first, so arming only the `authorization` + // header would resolve as that session and quietly write MANUAL — the + // case would then fail on a row it never actually tested. Clear the + // cookie for the duration, then put it back: this is the last case + // today, but nothing stops one being appended after it. + const prisma = getPrismaClient(); + const session = cookieJar.get("healthlog_session"); + cookieJar.clear(); + + const raw = `hlk_external_${"0".repeat(48)}`; + await prisma.apiToken.create({ + data: { + userId: USER_ID, + name: "source-smoke-external", + tokenHash: hashToken(raw), + permissions: [MEASUREMENTS_WRITE_SCOPE], + }, + }); + headerJar.set("authorization", `Bearer ${raw}`); + + try { + const response = await postManualMeasurement( + jsonRequest("http://localhost/api/measurements", { + type: "PULSE", + value: 61, + measuredAt: MEASURED_AT.toISOString(), + }), + ); + expect(response.status).toBe(201); + } finally { + headerJar.delete("authorization"); + if (session) cookieJar.set("healthlog_session", session); + } + }, + }, } satisfies Record; beforeEach(async () => { @@ -359,9 +405,9 @@ describe("MeasurementSource write paths against real Postgres", () => { }); } - expect(sourceCases).toHaveLength(14); + expect(sourceCases).toHaveLength(15); expect(await prisma.measurement.count({ where: { userId: USER_ID } })).toBe( - 14, + 15, ); }); });