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:
Marc-André Bombeck committed 2026-06-14 23:28:08 +02:00
1 parent 111fa7a323
commit 82e2d7dfb1
2 files changed
+214 -18

No files matched your search

+144 -2
View File
@@ -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
View File
@@ -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