test(measurements): cover EXTERNAL provenance on both write paths

The write-smoke suite is `satisfies Record<MeasurementSource, SourceCase>`,
so growing the enum is a compile error until a case exists; the new one
goes through the ordinary single-entry POST, which is the production path.
It carries the trap this suite sets for you written down: `beforeEach`
seeds a session cookie and the caller resolver is cookie-first, so arming
only the authorization header resolves as that session and quietly writes
MANUAL — the case would then pass without testing anything. Both hardcoded
totals move 14 → 15.

In the bearer-scope suite the two rows that asserted MANUAL now assert
EXTERNAL, and three cases join them: a body naming MANUAL is refused on
each route (the widening from "names APPLE_HEALTH" to "names anything",
which nothing else covered), and a bridge-written row is still value-
editable by its owner through PUT. That last one guards the split between
the write allowlist and the correctable set — folding the two constants
back together value-locks every bridge row behind a 409, and this is what
notices.
This commit is contained in:
Antiheld86 authored and Marc-André Bombeck committed 2026-09-25 06:55:17 +02:00
1 parent f94d358ade
commit 112a029443
2 files changed
+166 -6

No files matched your search

@@ -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");
@@ -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<MeasurementSource, SourceCase>;
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,
);
});
});