mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(import): bound JSON measurement timestamps and dedup on externalId
The generic JSON import validated measurement `measuredAt` with a bare `z.string().datetime()` — no ceiling — so a crafted payload could forward-date a reading, the gap the single-POST plausibility bound already closed. Route the measurement and mood instant fields through the shared `validateEntryInstant` (no future beyond a 5-minute skew, no instant before 1900). Measurement rows were also always created with a NULL externalId, which is distinct in the `(userId, type, source, externalId)` unique key, so a re-import duplicated the whole set. Give measurements the same optional externalId upsert mood already has: present -> idempotent upsert, absent -> first-write-wins create.
This commit is contained in:
1 parent
111fa7a323
commit
82e2d7dfb1
2 files changed
+214
-18
No files matched your search
@@ -10,8 +10,8 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { create: vi.fn() },
|
||||
moodEntry: { create: vi.fn() },
|
||||
measurement: { create: vi.fn(), upsert: vi.fn() },
|
||||
moodEntry: { create: vi.fn(), upsert: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -241,3 +241,145 @@ describe("POST /api/import — measurement rollup hook (v1.4.39.1)", () => {
|
||||
expect(recomputeBucketsForMeasurement).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// v1.17.1 — two real bugs the data-portability audit found in the JSON
|
||||
// import. Regression guards so a future refactor can't silently re-open
|
||||
// them.
|
||||
describe("POST /api/import — entry-instant bound (v1.17.1)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: new Date(Date.now() + 1000),
|
||||
} as never);
|
||||
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
|
||||
});
|
||||
|
||||
it("rejects a future-dated measurement (previously accepted)", async () => {
|
||||
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
measurements: [
|
||||
{ type: "WEIGHT", value: 80, unit: "kg", measuredAt: future },
|
||||
],
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(422);
|
||||
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a pre-1900 measurement", async () => {
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
measurements: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: "1899-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(422);
|
||||
});
|
||||
|
||||
it("accepts a backdated (past) measurement", async () => {
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
measurements: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: "2020-01-01T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBeLessThan(400);
|
||||
expect(vi.mocked(prisma.measurement.create)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/import — measurement externalId dedup (v1.17.1)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: new Date(Date.now() + 1000),
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("upserts (not creates) when a measurement carries an externalId", async () => {
|
||||
vi.mocked(prisma.measurement.upsert).mockResolvedValue({} as never);
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
measurements: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-05-01T08:00:00.000Z",
|
||||
externalId: "scale-row-42",
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBeLessThan(400);
|
||||
expect(vi.mocked(prisma.measurement.upsert)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(prisma.measurement.create)).not.toHaveBeenCalled();
|
||||
const arg = vi.mocked(prisma.measurement.upsert).mock.calls[0][0];
|
||||
expect(arg.where).toEqual({
|
||||
userId_type_source_externalId: {
|
||||
userId: "u-1",
|
||||
type: "WEIGHT",
|
||||
source: "IMPORT",
|
||||
externalId: "scale-row-42",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("still creates (first-write-wins) when no externalId is present", async () => {
|
||||
vi.mocked(prisma.measurement.create).mockResolvedValue({} as never);
|
||||
|
||||
const response = await POST(
|
||||
new NextRequest("http://localhost/api/import", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
measurements: [
|
||||
{
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: "2026-05-01T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBeLessThan(400);
|
||||
expect(vi.mocked(prisma.measurement.create)).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(prisma.measurement.upsert)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+70
-16
@@ -17,6 +17,7 @@ import {
|
||||
measurementTypeEnum,
|
||||
glucoseContextEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
import { validateEntryInstant } from "@/lib/validations/entry-instant";
|
||||
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
collapseToTypeDayKeys,
|
||||
@@ -32,10 +33,23 @@ const measurementSchema = z
|
||||
type: measurementTypeEnum,
|
||||
value: z.number(),
|
||||
unit: z.string(),
|
||||
measuredAt: z.string().datetime(),
|
||||
// v1.17.1 — share the canonical entry-instant bound used by the single
|
||||
// POST + edit paths (v1.17 W1b). Previously a bare `z.string().datetime()`
|
||||
// with no ceiling, so a crafted JSON import could forward-date a reading.
|
||||
// `validateEntryInstant` rejects any future instant beyond a 5-min skew
|
||||
// tolerance and any instant before 1900; the field transforms to a `Date`.
|
||||
measuredAt: validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
),
|
||||
glucoseContext: glucoseContextEnum.optional(),
|
||||
source: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
// v1.17.1 — optional source-stable id. When present, the import upserts on
|
||||
// `(userId, type, source=IMPORT, externalId)` so a re-import of the same
|
||||
// export is idempotent rather than minting a duplicate set — mirrors the
|
||||
// mood path. Absent → first-write-wins create with a NULL externalId
|
||||
// (distinct in the unique key), which DOES duplicate on re-import.
|
||||
externalId: z.string().min(1).max(120).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => validateMeasurementRange(data.type, data.value) === null,
|
||||
@@ -47,7 +61,11 @@ const moodEntrySchema = z.object({
|
||||
mood: z.enum(["SUPER_GUT", "GUT", "OKAY", "SCHLECHT", "LAUSIG"]),
|
||||
score: z.number().int().min(1).max(5),
|
||||
tags: z.string().optional(),
|
||||
loggedAt: z.string().datetime().optional(),
|
||||
// v1.17.1 — same entry-instant bound as measurements; an imported mood
|
||||
// log cannot be forward-dated. Optional + transforms to a `Date` when set.
|
||||
loggedAt: validateEntryInstant(
|
||||
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
|
||||
).optional(),
|
||||
// v1.12.1 — optional source-stable id (e.g. a Daylio row id). When
|
||||
// present, the import upserts on `(userId, source, externalId)` so a
|
||||
// re-import of the same export is idempotent rather than minting a
|
||||
@@ -106,19 +124,55 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (data.measurements?.length) {
|
||||
for (const m of data.measurements) {
|
||||
try {
|
||||
const measuredAt = new Date(m.measuredAt);
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId,
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
measuredAt,
|
||||
notes: m.notes || null,
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
});
|
||||
// `measuredAt` is already a `Date` (validateEntryInstant transform).
|
||||
const measuredAt = m.measuredAt;
|
||||
if (m.externalId) {
|
||||
// v1.17.1 — idempotent re-import keyed on the source-stable id, so
|
||||
// re-uploading the same export updates in place rather than minting
|
||||
// a duplicate. Mirrors the mood path. The unique key already exists
|
||||
// (`userId_type_source_externalId`, schema.prisma:942).
|
||||
await prisma.measurement.upsert({
|
||||
where: {
|
||||
userId_type_source_externalId: {
|
||||
userId,
|
||||
type: m.type,
|
||||
source: "IMPORT",
|
||||
externalId: m.externalId,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
measuredAt,
|
||||
notes: m.notes || null,
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
create: {
|
||||
userId,
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
externalId: m.externalId,
|
||||
measuredAt,
|
||||
notes: m.notes || null,
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId,
|
||||
type: m.type,
|
||||
value: m.value,
|
||||
unit: m.unit,
|
||||
source: "IMPORT",
|
||||
measuredAt,
|
||||
notes: m.notes || null,
|
||||
glucoseContext: m.glucoseContext ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
touchedMeasurements.push({
|
||||
type: m.type as MeasurementType,
|
||||
measuredAt,
|
||||
@@ -135,7 +189,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
if (data.moodEntries?.length) {
|
||||
for (const e of data.moodEntries) {
|
||||
try {
|
||||
const loggedAt = e.loggedAt ? new Date(e.loggedAt) : new Date();
|
||||
const loggedAt = e.loggedAt ?? new Date();
|
||||
if (e.externalId) {
|
||||
// v1.12.1 — idempotent re-import keyed on the source-stable id.
|
||||
// A second import of the same export updates the row in place
|
||||
|
||||
Reference in new issue
Block a user