mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user