From 82e2d7dfb19e6e1f0fd8d1a6bace64c07ee1ffac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 23:28:08 +0200 Subject: [PATCH] fix(import): bound JSON measurement timestamps and dedup on externalId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/app/api/import/__tests__/route.test.ts | 146 ++++++++++++++++++++- src/app/api/import/route.ts | 86 +++++++++--- 2 files changed, 214 insertions(+), 18 deletions(-) diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index dacb73085..12736470b 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/import/route.ts b/src/app/api/import/route.ts index 9785616d3..93b0152d4 100644 --- a/src/app/api/import/route.ts +++ b/src/app/api/import/route.ts @@ -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