mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
The dedup key on the medication intake surfaces is not only `externalId`. `MedicationIntakeEvent.idempotency_key` is a `@unique` column persisted with the dose row forever, with no TTL and no sweep, matched by plain equality on the per-medication POST, the bulk drain and the external ingest bridge. That makes it an identity, not a replay token, and it carries the same failure mode: a key that rotates per client launch never matches its own earlier post, so every retry logs the dose again. The blank case is sharper still — the column is unique table-wide, so an empty key resolves to whichever unrelated row stored it first. Refuse both shapes at the schema on the single-entry paths and per entry on the bulk drain. The MCP write path hashes a host-supplied key into the `externalId` half of its dedup key, so the shape is laundered before anything downstream could see it and the check has to run on the input. That closes a live one: every tool handler falls back to `""` when the host omits the argument, and `""` digests to a constant, so each keyless write after the first reported `already_logged` and was silently dropped. The HTTP `Idempotency-Key` header stays out, and now says why in the guard: `IdempotencyKey.expiresAt` bounds it and the cleanup job sweeps it, so it is a windowed replay token and rotation is its design. Extend the structural guard with a provenance rule for identities that never pass a Zod request schema — a spreadsheet column, an MCP tool argument. Inside the import and MCP trees, a module handling an identity must apply the floor itself or import a module that does. That is a real chain rather than a list of known offenders: a new importer that mints an `externalId` from file input and imports nothing checked fails it, with no allowlist to escape into.
468 lines
15 KiB
TypeScript
468 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
const { measurement, moodEntry, $transaction } = vi.hoisted(() => ({
|
|
measurement: {
|
|
findUnique: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
moodEntry: {
|
|
findUnique: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
$transaction: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: { measurement, moodEntry, $transaction },
|
|
}));
|
|
|
|
vi.mock("@/lib/cache/invalidate", () => ({
|
|
invalidateUserMeasurements: vi.fn(),
|
|
invalidateUserMood: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/rollups/measurement-rollups", () => ({
|
|
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/rollups/mood-rollups", () => ({
|
|
recomputeMoodBucketsForEntry: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
const auditLog = vi.fn().mockResolvedValue(undefined);
|
|
vi.mock("@/lib/auth/audit", () => ({
|
|
auditLog: (...args: unknown[]) => auditLog(...args),
|
|
}));
|
|
|
|
vi.mock("@/lib/logging/context", () => ({
|
|
annotate: vi.fn(),
|
|
getEvent: () => ({ addMeta: vi.fn() }),
|
|
}));
|
|
|
|
import { logMcpMeasurement, logMcpMood, logMcpBloodPressure } from "../writes";
|
|
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
measurement.findUnique.mockResolvedValue(null);
|
|
measurement.create.mockResolvedValue({ id: "m-1" });
|
|
$transaction.mockResolvedValue([{ id: "s-1" }, { id: "d-1" }]);
|
|
moodEntry.findUnique.mockResolvedValue(null);
|
|
moodEntry.create.mockResolvedValue({
|
|
date: "2026-06-27",
|
|
mood: "GUT",
|
|
note: null,
|
|
tags: [],
|
|
});
|
|
});
|
|
|
|
describe("logMcpMeasurement", () => {
|
|
it("writes one row with source MCP for a capturable type", async () => {
|
|
const result = await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
idempotencyKey: "key-1",
|
|
});
|
|
|
|
expect(result.status).toBe("written");
|
|
expect(measurement.create).toHaveBeenCalledTimes(1);
|
|
const data = measurement.create.mock.calls[0][0].data;
|
|
expect(data.source).toBe("MCP");
|
|
expect(data.userId).toBe("u-1");
|
|
expect(data.type).toBe("WEIGHT");
|
|
expect(data.value).toBe(80);
|
|
expect(data.unit).toBe("kg");
|
|
// externalId is namespaced + hashed (never the raw key).
|
|
expect(data.externalId).toMatch(/^mcp:measure:[0-9a-f]{64}$/);
|
|
expect(data.externalId).not.toContain("key-1");
|
|
expect(invalidateUserMeasurements).toHaveBeenCalledWith("u-1", {
|
|
evict: true,
|
|
});
|
|
expect(auditLog).toHaveBeenCalledWith(
|
|
"mcp.write.measurement",
|
|
expect.objectContaining({ userId: "u-1" }),
|
|
);
|
|
});
|
|
|
|
it("converts a lb unit hint to canonical kg before writing", async () => {
|
|
const result = await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "WEIGHT",
|
|
value: 210,
|
|
unit: "lb",
|
|
idempotencyKey: "key-lb",
|
|
});
|
|
|
|
expect(result.status).toBe("written");
|
|
const data = measurement.create.mock.calls[0][0].data;
|
|
expect(data.unit).toBe("kg");
|
|
// 210 lb → 95.25 kg (stored canonical, never the caller's lb verbatim).
|
|
expect(data.value).toBeCloseTo(210 * 0.45359237, 5);
|
|
if (result.status === "written") {
|
|
expect(result.measurement.unit).toBe("kg");
|
|
}
|
|
});
|
|
|
|
it("refuses an unsupported unit hint without writing", async () => {
|
|
const result = await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "WEIGHT",
|
|
value: 12,
|
|
unit: "stone",
|
|
idempotencyKey: "key-stone",
|
|
});
|
|
|
|
expect(result.status).toBe("unsupported_unit");
|
|
expect(measurement.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stamps canonical kg when the unit hint is omitted", async () => {
|
|
const result = await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
idempotencyKey: "key-none",
|
|
});
|
|
|
|
expect(result.status).toBe("written");
|
|
const data = measurement.create.mock.calls[0][0].data;
|
|
expect(data.unit).toBe("kg");
|
|
expect(data.value).toBe(80);
|
|
});
|
|
|
|
it("refuses a non-capturable / clinical-only type without writing", async () => {
|
|
const result = await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "RECOVERY_SCORE" as never,
|
|
value: 50,
|
|
idempotencyKey: "key-x",
|
|
});
|
|
expect(result.status).toBe("unsupported_type");
|
|
expect(measurement.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses an out-of-range value without writing", async () => {
|
|
const result = await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "WEIGHT",
|
|
value: 99999,
|
|
idempotencyKey: "key-y",
|
|
});
|
|
expect(result.status).toBe("out_of_range");
|
|
expect(measurement.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("is idempotent — a second call with the same key writes nothing", async () => {
|
|
measurement.findUnique.mockResolvedValue({
|
|
id: "m-1",
|
|
value: 80,
|
|
unit: "kg",
|
|
measuredAt: new Date("2026-06-27T08:00:00Z"),
|
|
});
|
|
const result = await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
idempotencyKey: "key-1",
|
|
});
|
|
expect(result.status).toBe("already_logged");
|
|
expect(measurement.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses a far-future (year 9999) measuredAt without writing", async () => {
|
|
const result = await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
measuredAt: new Date("9999-01-01T00:00:00Z"),
|
|
idempotencyKey: "key-future",
|
|
});
|
|
expect(result.status).toBe("out_of_range");
|
|
expect(measurement.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses an ancient (year 1000) measuredAt without writing", async () => {
|
|
const result = await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
measuredAt: new Date("1000-01-01T00:00:00Z"),
|
|
idempotencyKey: "key-ancient",
|
|
});
|
|
expect(result.status).toBe("out_of_range");
|
|
expect(measurement.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts a now-ish measuredAt", async () => {
|
|
const result = await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
measuredAt: new Date(),
|
|
idempotencyKey: "key-now",
|
|
});
|
|
expect(result.status).toBe("written");
|
|
expect(measurement.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("derives the same externalId for the same idempotencyKey", async () => {
|
|
await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "PULSE",
|
|
value: 60,
|
|
idempotencyKey: "stable",
|
|
});
|
|
const first = measurement.create.mock.calls[0][0].data.externalId;
|
|
vi.clearAllMocks();
|
|
measurement.findUnique.mockResolvedValue(null);
|
|
measurement.create.mockResolvedValue({ id: "m-2" });
|
|
await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "PULSE",
|
|
value: 61,
|
|
idempotencyKey: "stable",
|
|
});
|
|
const second = measurement.create.mock.calls[0][0].data.externalId;
|
|
expect(first).toBe(second);
|
|
});
|
|
});
|
|
|
|
describe("logMcpMood", () => {
|
|
it("writes one mood row with source MCP", async () => {
|
|
const result = await logMcpMood({
|
|
userId: "u-1",
|
|
score: 4,
|
|
idempotencyKey: "mood-1",
|
|
});
|
|
expect(result.status).toBe("written");
|
|
expect(moodEntry.create).toHaveBeenCalledTimes(1);
|
|
const data = moodEntry.create.mock.calls[0][0].data;
|
|
expect(data.source).toBe("MCP");
|
|
expect(data.userId).toBe("u-1");
|
|
expect(data.externalId).toMatch(/^mcp:mood:[0-9a-f]{64}$/);
|
|
expect(auditLog).toHaveBeenCalledWith(
|
|
"mcp.write.mood",
|
|
expect.objectContaining({ userId: "u-1" }),
|
|
);
|
|
});
|
|
|
|
it("rejects an out-of-band score without writing", async () => {
|
|
const result = await logMcpMood({
|
|
userId: "u-1",
|
|
score: 9,
|
|
idempotencyKey: "mood-bad",
|
|
});
|
|
expect(result.status).toBe("invalid_score");
|
|
expect(moodEntry.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("is idempotent on the same key", async () => {
|
|
moodEntry.findUnique.mockResolvedValue({
|
|
mood: "GUT",
|
|
score: 4,
|
|
note: null,
|
|
date: "2026-06-27",
|
|
});
|
|
const result = await logMcpMood({
|
|
userId: "u-1",
|
|
score: 4,
|
|
idempotencyKey: "mood-1",
|
|
});
|
|
expect(result.status).toBe("already_logged");
|
|
expect(moodEntry.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("logMcpBloodPressure", () => {
|
|
it("writes BOTH systolic and diastolic rows atomically with one timestamp", async () => {
|
|
const result = await logMcpBloodPressure({
|
|
userId: "u-1",
|
|
systolic: 120,
|
|
diastolic: 80,
|
|
idempotencyKey: "bp-key",
|
|
});
|
|
|
|
expect(result.status).toBe("written");
|
|
// Two create calls (sys + dia), executed inside one transaction.
|
|
expect(measurement.create).toHaveBeenCalledTimes(2);
|
|
expect($transaction).toHaveBeenCalledTimes(1);
|
|
|
|
const sys = measurement.create.mock.calls[0][0].data;
|
|
const dia = measurement.create.mock.calls[1][0].data;
|
|
expect(sys.type).toBe("BLOOD_PRESSURE_SYS");
|
|
expect(sys.value).toBe(120);
|
|
expect(dia.type).toBe("BLOOD_PRESSURE_DIA");
|
|
expect(dia.value).toBe(80);
|
|
expect(sys.source).toBe("MCP");
|
|
expect(dia.source).toBe("MCP");
|
|
expect(sys.unit).toBe("mmHg");
|
|
// Same shared externalId namespace + the SAME measuredAt instant.
|
|
expect(sys.externalId).toMatch(/^mcp:bp:[0-9a-f]{64}$/);
|
|
expect(sys.externalId).toBe(dia.externalId);
|
|
expect(sys.measuredAt.getTime()).toBe(dia.measuredAt.getTime());
|
|
expect(auditLog).toHaveBeenCalledWith(
|
|
"mcp.write.blood_pressure",
|
|
expect.objectContaining({ userId: "u-1" }),
|
|
);
|
|
});
|
|
|
|
it("rejects an implausible pair (systolic ≤ diastolic) without writing", async () => {
|
|
const result = await logMcpBloodPressure({
|
|
userId: "u-1",
|
|
systolic: 80,
|
|
diastolic: 120,
|
|
idempotencyKey: "bp-bad",
|
|
});
|
|
expect(result.status).toBe("out_of_range");
|
|
expect(measurement.create).not.toHaveBeenCalled();
|
|
expect($transaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects an out-of-range value without writing", async () => {
|
|
const result = await logMcpBloodPressure({
|
|
userId: "u-1",
|
|
systolic: 9999,
|
|
diastolic: 80,
|
|
idempotencyKey: "bp-oor",
|
|
});
|
|
expect(result.status).toBe("out_of_range");
|
|
expect(measurement.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses a far-future (year 9999) measuredAt without writing", async () => {
|
|
const result = await logMcpBloodPressure({
|
|
userId: "u-1",
|
|
systolic: 120,
|
|
diastolic: 80,
|
|
measuredAt: new Date("9999-01-01T00:00:00Z"),
|
|
idempotencyKey: "bp-future",
|
|
});
|
|
expect(result.status).toBe("out_of_range");
|
|
expect(measurement.create).not.toHaveBeenCalled();
|
|
expect($transaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses an ancient (year 1000) measuredAt without writing", async () => {
|
|
const result = await logMcpBloodPressure({
|
|
userId: "u-1",
|
|
systolic: 120,
|
|
diastolic: 80,
|
|
measuredAt: new Date("1000-01-01T00:00:00Z"),
|
|
idempotencyKey: "bp-ancient",
|
|
});
|
|
expect(result.status).toBe("out_of_range");
|
|
expect(measurement.create).not.toHaveBeenCalled();
|
|
expect($transaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("accepts a now-ish measuredAt", async () => {
|
|
const result = await logMcpBloodPressure({
|
|
userId: "u-1",
|
|
systolic: 120,
|
|
diastolic: 80,
|
|
measuredAt: new Date(),
|
|
idempotencyKey: "bp-now",
|
|
});
|
|
expect(result.status).toBe("written");
|
|
expect($transaction).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("is idempotent — a replay with the same key writes nothing", async () => {
|
|
measurement.findUnique
|
|
.mockResolvedValueOnce({
|
|
value: 120,
|
|
measuredAt: new Date("2026-06-27T08:00:00Z"),
|
|
})
|
|
.mockResolvedValueOnce({ value: 80 });
|
|
const result = await logMcpBloodPressure({
|
|
userId: "u-1",
|
|
systolic: 120,
|
|
diastolic: 80,
|
|
idempotencyKey: "bp-key",
|
|
});
|
|
expect(result.status).toBe("already_logged");
|
|
expect($transaction).not.toHaveBeenCalled();
|
|
if (result.status === "already_logged") {
|
|
expect(result.bloodPressure.systolic).toBe(120);
|
|
expect(result.bloodPressure.diastolic).toBe(80);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("MCP writes — idempotency-key stability floor", () => {
|
|
/**
|
|
* The MCP key is hashed into the `externalId` half of
|
|
* `(userId, type, MCP, externalId)` and matched forever, so it is an
|
|
* identity. The hash launders the shape — a pointer address and a real
|
|
* key both digest to clean hex — so the check has to run on the INPUT,
|
|
* before `mcpExternalId`.
|
|
*
|
|
* The blank case is the live one: every tool handler falls back to `""`
|
|
* when the host omits the argument, and `""` digests to a CONSTANT, so
|
|
* without this floor every keyless write after the first would report
|
|
* `already_logged` and be silently dropped.
|
|
*/
|
|
it.each([
|
|
["blank", ""],
|
|
["whitespace", " "],
|
|
["pointer address", "0x12568db80"],
|
|
["object description", "<NSObject: 0x600000c1a2b0>"],
|
|
])("refuses a %s measurement key before it is hashed", async (_l, key) => {
|
|
const result = await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
idempotencyKey: key,
|
|
});
|
|
expect(result.status).toBe("unstable_idempotency_key");
|
|
expect(measurement.findUnique).not.toHaveBeenCalled();
|
|
expect(measurement.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses a blank mood key instead of collapsing onto one row", async () => {
|
|
const result = await logMcpMood({
|
|
userId: "u-1",
|
|
score: 4,
|
|
idempotencyKey: "",
|
|
});
|
|
expect(result.status).toBe("unstable_idempotency_key");
|
|
expect(moodEntry.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses a blank blood-pressure key instead of collapsing onto one row", async () => {
|
|
const result = await logMcpBloodPressure({
|
|
userId: "u-1",
|
|
systolic: 120,
|
|
diastolic: 80,
|
|
idempotencyKey: "",
|
|
});
|
|
expect(result.status).toBe("unstable_idempotency_key");
|
|
expect($transaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("carries a message naming the field a host has to fix", async () => {
|
|
const result = await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
idempotencyKey: "<NSObject: 0x600000c1a2b0>",
|
|
});
|
|
expect(result).toMatchObject({ status: "unstable_idempotency_key" });
|
|
if (result.status !== "unstable_idempotency_key") return;
|
|
expect(result.reason).toContain("idempotency key");
|
|
expect(result.reason).toContain("stable across app restarts");
|
|
});
|
|
|
|
it("still accepts a normal host-chosen key", async () => {
|
|
const result = await logMcpMeasurement({
|
|
userId: "u-1",
|
|
type: "WEIGHT",
|
|
value: 80,
|
|
idempotencyKey: "weight-2026-07-25-morning",
|
|
});
|
|
expect(result.status).toBe("written");
|
|
expect(measurement.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|