mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
The MCP measurement and blood-pressure write tools accepted a client-supplied measuredAt with no temporal bound and persisted it verbatim, so a far-future or ancient instant could poison "latest" reads and rollup buckets. Bound it through the shared entry-instant validator both manual capture paths use — no future beyond a 5-minute clock-skew tolerance, nothing before 1900 — inside the write cores, and return the structured out_of_range refusal rather than throwing. Extract the type/range/instant checks into shared pre-write validators so the same verdict can be reused by the confirm-gate preview.
350 lines
11 KiB
TypeScript
350 lines
11 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),
|
|
}));
|
|
|
|
vi.mock("@/lib/moodlog/push", () => ({
|
|
pushMoodEntriesToMoodLog: 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("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);
|
|
}
|
|
});
|
|
});
|