From 94157a50c7268263ad0dbaac8cd3ea0175b14520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 13:40:03 +0200 Subject: [PATCH] fix(nutrients): bound the manual water writes and put nutrient totals in the backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The water quick-add validated calendar realism but never the upper day bound the batch route applies, so a far-future day was accepted and — every read filters `day >= since` with no ceiling — became the permanent latest day on the settings card and the dashboard tile. Hoist the bound into one shared helper both routes use. `mode: "add"` bounded a single request to the catalog's plausible daily max but nothing across requests, so repeated quick-adds drove the stored total arbitrarily high while the batch route enforced the same cap strictly. Clamp after the increment so the write stays atomic. Thiamin's cap sat at exactly 1000 mg while its PRI is exactly 1.0 mg, so a microgram-read-as-milligram sample landed on precisely the cap and the `>` comparison let it through. 500 still clears any real supplement dose. NutrientIntakeDay was in no export path, which contradicted the schema's own reason for denormalising the unit column. Carry it in the full backup with its unit and source — source is part of the composite key, so without it a restore cannot tell a manual entry from a synced day total. --- docs/api/openapi.yaml | 5 +- .../proxy-early-exit-headers.test.ts | 7 +- .../export/__tests__/per-type-routes.test.ts | 4 + .../__tests__/soft-delete-filter.test.ts | 1 + .../export/encrypted/__tests__/route.test.ts | 1 + src/app/api/export/encrypted/route.ts | 1 + src/app/api/export/full-backup/route.ts | 1 + src/app/api/nutrients/batch/route.ts | 16 +- .../nutrients/water/__tests__/route.test.ts | 68 +++++++ src/app/api/nutrients/water/route.ts | 23 ++- src/lib/auth/__tests__/webauthn-rp.test.ts | 24 +-- src/lib/export/full-backup-payload.ts | 132 ++++++++------ src/lib/nutrients/catalog.ts | 7 +- src/lib/nutrients/day-bounds.ts | 20 +++ src/lib/validations/nutrients.ts | 2 +- .../integration/full-backup-nutrients.test.ts | 167 ++++++++++++++++++ 16 files changed, 389 insertions(+), 90 deletions(-) create mode 100644 src/lib/nutrients/day-bounds.ts create mode 100644 tests/integration/full-backup-nutrients.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1b8fc1dc1..7d5391a06 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13349,8 +13349,9 @@ components: $ref: "#/components/schemas/NutrientIntakeEntry" required: - entries - description: Bulk day-total upsert, max 500 entries. Upsert key is (day, nutrient); a re-post replaces the stored total - (last-writer-wins day-total contract). + description: Bulk day-total upsert, max 500 entries. Upsert key is the composite primary key (userId, day, nutrient, + source); this route always writes the APPLE_HEALTH source row, so a manual water entry is never clobbered. A + re-post replaces the stored total (last-writer-wins day-total contract). NutrientIntakeEntry: type: object properties: diff --git a/src/__tests__/proxy-early-exit-headers.test.ts b/src/__tests__/proxy-early-exit-headers.test.ts index fbb6f2ebd..9095f4105 100644 --- a/src/__tests__/proxy-early-exit-headers.test.ts +++ b/src/__tests__/proxy-early-exit-headers.test.ts @@ -37,17 +37,16 @@ function makeRequest( }); } -const ORIGINAL_NODE_ENV = process.env.NODE_ENV; const ORIGINAL_DEMO = process.env.DEMO_MODE; beforeEach(() => { shouldRunWeb.mockReturnValue(true); - process.env.NODE_ENV = "production"; + vi.stubEnv("NODE_ENV", "production"); delete process.env.DEMO_MODE; }); afterEach(() => { - process.env.NODE_ENV = ORIGINAL_NODE_ENV; + vi.unstubAllEnvs(); if (ORIGINAL_DEMO === undefined) delete process.env.DEMO_MODE; else process.env.DEMO_MODE = ORIGINAL_DEMO; }); @@ -110,7 +109,7 @@ describe("proxy early exits carry the baseline security headers", () => { }); it("omits HSTS in development, like the main header block", () => { - process.env.NODE_ENV = "development"; + vi.stubEnv("NODE_ENV", "development"); const res = proxy(makeRequest("/stimmung")); expect(res.status).toBe(301); expect(res.headers.get("Strict-Transport-Security")).toBeNull(); diff --git a/src/app/api/export/__tests__/per-type-routes.test.ts b/src/app/api/export/__tests__/per-type-routes.test.ts index f442c3e15..14597dfa5 100644 --- a/src/app/api/export/__tests__/per-type-routes.test.ts +++ b/src/app/api/export/__tests__/per-type-routes.test.ts @@ -16,6 +16,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn() }, moodEntry: { findMany: vi.fn() }, + nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, // v1.15.0 — cycle tables read by the full-backup helper. cycleProfile: { findUnique: vi.fn() }, menstrualCycle: { findMany: vi.fn() }, @@ -70,6 +71,9 @@ function mkReq(url: string): NextRequest { beforeEach(() => { vi.resetAllMocks(); + // `resetAllMocks` clears the inline default, and the full-backup payload + // builder reads this unconditionally. + vi.mocked(prisma.nutrientIntakeDay.findMany).mockResolvedValue([] as never); }); afterEach(() => { diff --git a/src/app/api/export/__tests__/soft-delete-filter.test.ts b/src/app/api/export/__tests__/soft-delete-filter.test.ts index 05b05118e..0f039db61 100644 --- a/src/app/api/export/__tests__/soft-delete-filter.test.ts +++ b/src/app/api/export/__tests__/soft-delete-filter.test.ts @@ -24,6 +24,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn() }, medicationIntakeEvent: { findMany: vi.fn(), count: vi.fn() }, moodEntry: { findMany: vi.fn(), count: vi.fn() }, + nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, user: { findUnique: vi.fn() }, passkey: { findMany: vi.fn() }, apiToken: { count: vi.fn() }, diff --git a/src/app/api/export/encrypted/__tests__/route.test.ts b/src/app/api/export/encrypted/__tests__/route.test.ts index 075776e11..6a409c51d 100644 --- a/src/app/api/export/encrypted/__tests__/route.test.ts +++ b/src/app/api/export/encrypted/__tests__/route.test.ts @@ -14,6 +14,7 @@ vi.mock("@/lib/db", () => ({ medication: { findMany: vi.fn().mockResolvedValue([]) }, medicationIntakeEvent: { findMany: vi.fn().mockResolvedValue([]) }, moodEntry: { findMany: vi.fn().mockResolvedValue([]) }, + nutrientIntakeDay: { findMany: vi.fn().mockResolvedValue([]) }, cycleProfile: { findUnique: vi.fn().mockResolvedValue(null) }, menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, cycleDayLog: { findMany: vi.fn().mockResolvedValue([]) }, diff --git a/src/app/api/export/encrypted/route.ts b/src/app/api/export/encrypted/route.ts index 69c8b5806..1bdb223d9 100644 --- a/src/app/api/export/encrypted/route.ts +++ b/src/app/api/export/encrypted/route.ts @@ -116,6 +116,7 @@ export const POST = apiHandler(async (request: NextRequest) => { export_family_history_count: counts.familyHistory, export_workout_count: counts.workouts, export_document_count: counts.documents, + export_nutrient_day_count: counts.nutrientDays, export_archive_bytes: archive.byteLength, }, }); diff --git a/src/app/api/export/full-backup/route.ts b/src/app/api/export/full-backup/route.ts index f5fe21918..ca0f629c5 100644 --- a/src/app/api/export/full-backup/route.ts +++ b/src/app/api/export/full-backup/route.ts @@ -71,6 +71,7 @@ export const GET = apiHandler(async (request: NextRequest) => { export_family_history_count: counts.familyHistory, export_workout_count: counts.workouts, export_document_count: counts.documents, + export_nutrient_day_count: counts.nutrientDays, }, }); diff --git a/src/app/api/nutrients/batch/route.ts b/src/app/api/nutrients/batch/route.ts index 933bc7ecc..68e6ce217 100644 --- a/src/app/api/nutrients/batch/route.ts +++ b/src/app/api/nutrients/batch/route.ts @@ -7,7 +7,8 @@ * user's IANA timezone (the `stats:` day-key contract — the server * trusts the string after regex + calendar sanity, no re-derivation). * - * Upsert key = the composite PK (userId, day, nutrient). Re-post + * Upsert key = the composite PK (userId, day, nutrient, source), and this + * route always owns the APPLE_HEALTH row. Re-post * replaces amount / unit / externalSourceVersion — last-writer-wins, * the day-total contract; per-entry status is `updated` then, never * `duplicate`. Validation failures (`unit_mismatch` | `value_out_of_range` @@ -42,6 +43,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog"; +import { maxAcceptableNutrientDay } from "@/lib/nutrients/day-bounds"; import { MAX_NUTRIENT_ENTRIES_PER_BATCH, nutrientBatchSchema, @@ -64,16 +66,6 @@ function isRealCalendarDay(day: string): boolean { ); } -/** - * Upper day bound with timezone slack: "tomorrow" in the most-ahead - * IANA zone (UTC+14) is at most the UTC calendar date + 2 days, so a - * key beyond that cannot be a legitimate local day and is corruption. - */ -function maxAcceptableDay(now: Date): string { - const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000); - return limit.toISOString().slice(0, 10); -} - type EntryStatus = "inserted" | "updated" | "skipped"; interface EntryResult { index: number; @@ -133,7 +125,7 @@ async function postBatch(request: NextRequest): Promise { } const { entries } = parsed.data; - const dayCeiling = maxAcceptableDay(new Date()); + const dayCeiling = maxAcceptableNutrientDay(new Date()); const results: EntryResult[] = new Array(entries.length); const skipped: Array<{ index: number; reason: string }> = []; diff --git a/src/app/api/nutrients/water/__tests__/route.test.ts b/src/app/api/nutrients/water/__tests__/route.test.ts index 8c9bf7a1e..9139fc7e8 100644 --- a/src/app/api/nutrients/water/__tests__/route.test.ts +++ b/src/app/api/nutrients/water/__tests__/route.test.ts @@ -13,6 +13,7 @@ vi.mock("@/lib/db", () => ({ prisma: { nutrientIntakeDay: { upsert: vi.fn(), + update: vi.fn(), }, }, })); @@ -56,6 +57,7 @@ import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; +import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -216,3 +218,69 @@ describe("POST /api/nutrients/water", () => { }); }); }); + +describe("POST /api/nutrients/water — day bound and increment cap", () => { + // The batch route bounded `day` with UTC+14 slack; this route validated + // calendar realism only. Every read filters `day: { gte: since }` with no + // upper bound, so a far-future row became the permanent `latestDay` on the + // settings card and the permanent `lastSeenAt` on the dashboard water tile. + it("rejects a far-future day with 422", async () => { + const res = await POST( + postReq({ amountMl: 500, mode: "set", day: "2999-01-01" }), + ); + expect(res.status).toBe(422); + expect(prisma.nutrientIntakeDay.upsert).not.toHaveBeenCalled(); + }); + + it("still accepts a day two days ahead — the UTC+14 slack the batch route allows", async () => { + const soon = new Date(Date.now() + 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + const res = await POST(postReq({ amountMl: 500, mode: "set", day: soon })); + expect(res.status).toBe(200); + }); + + it("accepts a past day", async () => { + const past = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); + const res = await POST(postReq({ amountMl: 500, mode: "set", day: past })); + expect(res.status).toBe(200); + }); + + // `mode: "add"` was unbounded across requests: the schema caps a SINGLE + // write, so repeated quick-adds drove the stored day total arbitrarily high + // while the batch route enforced the same cap strictly on the Apple row. + it("clamps an incremented total back to the catalog's plausible daily max", async () => { + vi.mocked(prisma.nutrientIntakeDay.upsert).mockResolvedValue({ + day: "2026-07-16", + nutrient: "water", + source: "MANUAL", + amount: 99_000, + unit: "ml", + } as never); + vi.mocked(prisma.nutrientIntakeDay.update).mockResolvedValue({ + day: "2026-07-16", + nutrient: "water", + source: "MANUAL", + amount: NUTRIENT_CATALOG.water.plausibleDailyMax, + unit: "ml", + } as never); + + const res = await POST(postReq({ amountMl: 500, mode: "add" })); + expect(res.status).toBe(200); + expect(prisma.nutrientIntakeDay.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: { amount: NUTRIENT_CATALOG.water.plausibleDailyMax }, + }), + ); + const body = (await res.json()) as WaterResponse; + expect(body.data.amount).toBe(NUTRIENT_CATALOG.water.plausibleDailyMax); + }); + + it("leaves a total under the cap untouched", async () => { + const res = await POST(postReq({ amountMl: 500, mode: "add" })); + expect(res.status).toBe(200); + expect(prisma.nutrientIntakeDay.update).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/nutrients/water/route.ts b/src/app/api/nutrients/water/route.ts index d6d0f225f..84044ffba 100644 --- a/src/app/api/nutrients/water/route.ts +++ b/src/app/api/nutrients/water/route.ts @@ -30,6 +30,7 @@ import { checkRateLimit } from "@/lib/rate-limit"; import { requireModuleEnabled } from "@/lib/modules/gate"; import { invalidateUserDashboardSnapshot } from "@/lib/cache/invalidate"; import { NUTRIENT_CATALOG } from "@/lib/nutrients/catalog"; +import { maxAcceptableNutrientDay } from "@/lib/nutrients/day-bounds"; import { nutrientWaterWriteSchema } from "@/lib/validations/nutrients"; import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format"; @@ -79,7 +80,12 @@ async function postWater(request: NextRequest): Promise { const userTz = user.timezone || DEFAULT_TIMEZONE; const day = parsed.data.day ?? userDayKey(new Date(), userTz); - if (!isRealCalendarDay(day)) { + // Calendar realism AND the upper bound the batch route already applied. A + // client-supplied far-future day used to be accepted here, and since every + // read filters `day: { gte: since }` with no upper bound it became the + // permanent `latestDay` on the settings card and the permanent `lastSeenAt` + // on the dashboard water tile. + if (!isRealCalendarDay(day) || day > maxAcceptableNutrientDay(new Date())) { return apiError("Invalid day", 422, { errorCode: "nutrient.water.invalid_day", }); @@ -95,7 +101,7 @@ async function postWater(request: NextRequest): Promise { }, }; - const row = await prisma.nutrientIntakeDay.upsert({ + let row = await prisma.nutrientIntakeDay.upsert({ where: key, create: { userId: user.id, @@ -111,6 +117,19 @@ async function postWater(request: NextRequest): Promise { : { amount: amountMl }, }); + // The request schema bounds a SINGLE write to the catalog's plausible daily + // max, but `mode: "add"` is unbounded across requests — repeated quick-adds + // drove the stored day total arbitrarily high while the batch route enforced + // the same cap strictly on the Apple-sourced row. Clamp after the fact rather + // than reading first: the increment stays atomic, and the clamp converges on + // the cap no matter how the increments interleave. + if (row.amount > definition.plausibleDailyMax) { + row = await prisma.nutrientIntakeDay.update({ + where: key, + data: { amount: definition.plausibleDailyMax }, + }); + } + // Interactive single-entry write — hard-evict (not mark-stale) so the // dashboard water tile reflects the new total on the very next read, // matching the mood / medication / measurement posture. diff --git a/src/lib/auth/__tests__/webauthn-rp.test.ts b/src/lib/auth/__tests__/webauthn-rp.test.ts index f2541a7e4..2e00d458f 100644 --- a/src/lib/auth/__tests__/webauthn-rp.test.ts +++ b/src/lib/auth/__tests__/webauthn-rp.test.ts @@ -8,7 +8,7 @@ * bypass — but a configured production origin has no reason to carry it, and an * unpinned second layer is exactly what regresses unnoticed. */ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getConfiguredOrigins, @@ -16,23 +16,15 @@ import { getRpId, } from "../webauthn-rp"; -const ORIGINAL = { - NODE_ENV: process.env.NODE_ENV, - APP_URL: process.env.APP_URL, - NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, -}; - function setEnv(env: { nodeEnv?: string; appUrl?: string; publicAppUrl?: string; }) { - if (env.nodeEnv === undefined) delete process.env.NODE_ENV; - else process.env.NODE_ENV = env.nodeEnv; - if (env.appUrl === undefined) delete process.env.APP_URL; - else process.env.APP_URL = env.appUrl; - if (env.publicAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL; - else process.env.NEXT_PUBLIC_APP_URL = env.publicAppUrl; + vi.unstubAllEnvs(); + if (env.nodeEnv !== undefined) vi.stubEnv("NODE_ENV", env.nodeEnv); + vi.stubEnv("APP_URL", env.appUrl ?? ""); + vi.stubEnv("NEXT_PUBLIC_APP_URL", env.publicAppUrl ?? ""); } beforeEach(() => { @@ -40,11 +32,7 @@ beforeEach(() => { }); afterEach(() => { - setEnv({ - nodeEnv: ORIGINAL.NODE_ENV, - appUrl: ORIGINAL.APP_URL, - publicAppUrl: ORIGINAL.NEXT_PUBLIC_APP_URL, - }); + vi.unstubAllEnvs(); }); describe("getConfiguredOrigins", () => { diff --git a/src/lib/export/full-backup-payload.ts b/src/lib/export/full-backup-payload.ts index 85229e03c..1e7075d21 100644 --- a/src/lib/export/full-backup-payload.ts +++ b/src/lib/export/full-backup-payload.ts @@ -28,6 +28,7 @@ export interface FullBackupCounts extends RecordsBackupCounts { moodEntries: number; cycles: number; cycleDayLogs: number; + nutrientDays: number; } export interface FullBackupResult { @@ -43,58 +44,81 @@ export async function buildFullBackupPayload( prisma: PrismaClient, userId: string, ): Promise { - const [measurements, medications, intakeEvents, moodEntries, cycle, records] = - await Promise.all([ - // v1.28.25 — keyset-paginated read with a narrow select. The backup - // is inherently whole-history, so on a CGM / per-sample-HR account the - // previous single full-width `findMany` materialised a six-figure row - // set in one shot. The chunked read accumulates the same rows in the - // same `measuredAt desc` order; the payload FORMAT below is untouched - // (restore compatibility). The select is exactly the fields the - // serializer reads: type / value / unit / measuredAt / source + the - // note columns `readNote` decrypts + the `id` cursor key. - findMeasurementsPaged( - prisma, - { userId, deletedAt: null }, - { - id: true, - type: true, - value: true, - unit: true, - measuredAt: true, - source: true, - notes: true, - notesEncrypted: true, - }, - ), - prisma.medication.findMany({ - where: { userId }, - select: { - name: true, - dose: true, - active: true, - schedules: { - select: { - windowStart: true, - windowEnd: true, - label: true, - dose: true, - }, + const [ + measurements, + medications, + intakeEvents, + moodEntries, + cycle, + records, + nutrientDays, + ] = await Promise.all([ + // v1.28.25 — keyset-paginated read with a narrow select. The backup + // is inherently whole-history, so on a CGM / per-sample-HR account the + // previous single full-width `findMany` materialised a six-figure row + // set in one shot. The chunked read accumulates the same rows in the + // same `measuredAt desc` order; the payload FORMAT below is untouched + // (restore compatibility). The select is exactly the fields the + // serializer reads: type / value / unit / measuredAt / source + the + // note columns `readNote` decrypts + the `id` cursor key. + findMeasurementsPaged( + prisma, + { userId, deletedAt: null }, + { + id: true, + type: true, + value: true, + unit: true, + measuredAt: true, + source: true, + notes: true, + notesEncrypted: true, + }, + ), + prisma.medication.findMany({ + where: { userId }, + select: { + name: true, + dose: true, + active: true, + schedules: { + select: { + windowStart: true, + windowEnd: true, + label: true, + dose: true, }, }, - }), - prisma.medicationIntakeEvent.findMany({ - where: { userId, deletedAt: null }, - include: { medication: { select: { name: true } } }, - orderBy: { scheduledFor: "desc" }, - }), - prisma.moodEntry.findMany({ - where: { userId, deletedAt: null }, - orderBy: { moodLoggedAt: "desc" }, - }), - buildCycleBackupSection(prisma, userId), - buildRecordsBackupSection(prisma, userId), - ]); + }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { userId, deletedAt: null }, + include: { medication: { select: { name: true } } }, + orderBy: { scheduledFor: "desc" }, + }), + prisma.moodEntry.findMany({ + where: { userId, deletedAt: null }, + orderBy: { moodLoggedAt: "desc" }, + }), + buildCycleBackupSection(prisma, userId), + buildRecordsBackupSection(prisma, userId), + // Nutrient day totals were absent from every export path, which + // contradicted the schema's own justification for denormalising the unit + // column ("rows stay self-describing in exports even if the catalog ever + // drifts"). `source` is part of the composite PK, so it has to ride along + // or a restore cannot tell a manual water entry from a synced day total. + prisma.nutrientIntakeDay.findMany({ + where: { userId }, + select: { + day: true, + nutrient: true, + amount: true, + unit: true, + source: true, + }, + orderBy: [{ day: "desc" }, { nutrient: "asc" }], + }), + ]); const payload = { schemaVersion: BACKUP_SCHEMA_VERSION, @@ -148,6 +172,13 @@ export async function buildFullBackupPayload( familyHistory: records.familyHistory, workouts: records.workouts, documents: records.documents, + nutrientDays: nutrientDays.map((n) => ({ + day: n.day, + nutrient: n.nutrient, + amount: n.amount, + unit: n.unit, + source: n.source, + })), manifest: records.manifest, }; @@ -160,6 +191,7 @@ export async function buildFullBackupPayload( moodEntries: moodEntries.length, cycles: cycle.cycles.length, cycleDayLogs: cycle.cycleDayLogs.length, + nutrientDays: nutrientDays.length, ...countRecordsBackupSection(records), }, }; diff --git a/src/lib/nutrients/catalog.ts b/src/lib/nutrients/catalog.ts index c8af5db0c..dedf9dc0d 100644 --- a/src/lib/nutrients/catalog.ts +++ b/src/lib/nutrients/catalog.ts @@ -132,7 +132,12 @@ export const NUTRIENT_CATALOG: Readonly< female: 650, source: "EFSA DRV 2015 (retinol equivalents, adults)", }), - thiamin: def("thiamin", "Thiamin", "mg", 1000, { + // The cap is 500, not the 1000 the other B-vitamins carry, because thiamin's + // PRI is exactly 1.0 mg: a µg-read-as-mg sample lands on exactly 1000, and + // the ingest guard compares with `>`, so a 1000× error slipped through at + // precisely this one analyte. 500 still clears any real supplement dose + // (high-dose thiamin tops out around 300 mg) while catching that collision. + thiamin: def("thiamin", "Thiamin", "mg", 500, { kind: "PRI", direction: "target", adult: 1.0, diff --git a/src/lib/nutrients/day-bounds.ts b/src/lib/nutrients/day-bounds.ts new file mode 100644 index 000000000..ee440c155 --- /dev/null +++ b/src/lib/nutrients/day-bounds.ts @@ -0,0 +1,20 @@ +/** + * Shared upper day bound for the nutrient write paths. + * + * `NutrientIntakeDay.day` is a local `YYYY-MM-DD` key, so the server cannot + * simply compare it against the UTC date: a client in the most-ahead IANA zone + * (UTC+14) is legitimately already on "tomorrow" relative to UTC. Two days of + * slack covers every real zone and still rejects a key that cannot be a local + * day at all. + * + * The batch route carried this bound privately while `POST /api/nutrients/water` + * validated calendar realism only — so a manual write could land a `2999-01-01` + * row, and since every read filters `day: { gte: since }` with no upper bound, + * that row became the permanent `latestDay` on the settings card and the + * permanent `lastSeenAt` on the dashboard water tile. Both routes now share + * this one bound. + */ +export function maxAcceptableNutrientDay(now: Date): string { + const limit = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000); + return limit.toISOString().slice(0, 10); +} diff --git a/src/lib/validations/nutrients.ts b/src/lib/validations/nutrients.ts index 506f67ad9..20c00f344 100644 --- a/src/lib/validations/nutrients.ts +++ b/src/lib/validations/nutrients.ts @@ -51,7 +51,7 @@ export const nutrientBatchSchema = z .meta({ id: "NutrientBatchRequest", description: - "Bulk day-total upsert, max 500 entries. Upsert key is (day, nutrient); a re-post replaces the stored total (last-writer-wins day-total contract).", + "Bulk day-total upsert, max 500 entries. Upsert key is the composite primary key (userId, day, nutrient, source); this route always writes the APPLE_HEALTH source row, so a manual water entry is never clobbered. A re-post replaces the stored total (last-writer-wins day-total contract).", }); /** Per-entry outcome — a re-post is by definition an update, never a duplicate. */ diff --git a/tests/integration/full-backup-nutrients.test.ts b/tests/integration/full-backup-nutrients.test.ts new file mode 100644 index 000000000..84e0b713d --- /dev/null +++ b/tests/integration/full-backup-nutrients.test.ts @@ -0,0 +1,167 @@ +/** + * Integration guard: nutrient day totals ride the full backup. + * + * `NutrientIntakeDay` was in no export path at all — which contradicted the + * schema's own justification for denormalising the `unit` column ("rows stay + * self-describing in exports even if the catalog ever drifts"). A user who + * exported everything still lost every water and micronutrient row. + * + * `source` is part of the composite primary key, so it has to ride along too: + * without it a restore cannot tell a manual water entry from a synced day + * total, and the two collapse onto one row. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +async function seedUserSession(username: string) { + const prisma = getPrismaClient(); + const user = await prisma.user.create({ + data: { username, email: `${username}@example.test`, role: "USER" }, + }); + const session = await prisma.session.create({ + data: { userId: user.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return user; +} + +interface BackupWithNutrients { + userId: string; + nutrientDays?: Array<{ + day: string; + nutrient: string; + amount: number; + unit: string; + source: string; + }>; +} + +describe("full backup — nutrient day totals", () => { + it("exports nutrient rows for the authed user, keeping unit and source", async () => { + const prisma = getPrismaClient(); + const me = await seedUserSession("nutrient-backup-user"); + const other = await prisma.user.create({ + data: { + username: "nutrient-other", + email: "nutrient-other@example.test", + role: "USER", + }, + }); + + await prisma.nutrientIntakeDay.createMany({ + data: [ + { + userId: me.id, + day: "2026-05-01", + nutrient: "water", + amount: 1500, + unit: "ml", + source: "MANUAL", + }, + // Same (day, nutrient) under the other source — both must survive, + // which is the whole reason `source` joined the composite PK. + { + userId: me.id, + day: "2026-05-01", + nutrient: "water", + amount: 800, + unit: "ml", + source: "APPLE_HEALTH", + }, + { + userId: me.id, + day: "2026-05-01", + nutrient: "vitamin_d", + amount: 12.5, + unit: "ug", + source: "APPLE_HEALTH", + }, + { + userId: other.id, + day: "2026-05-01", + nutrient: "water", + amount: 9999, + unit: "ml", + source: "MANUAL", + }, + ], + }); + + const { GET } = await import("@/app/api/export/full-backup/route"); + const res = await GET( + new Request("http://localhost/api/export/full-backup", { + method: "GET", + }) as Parameters[0], + ); + expect(res.status).toBe(200); + + const parsed = JSON.parse(await res.text()) as BackupWithNutrients; + expect(parsed.userId).toBe(me.id); + + const rows = parsed.nutrientDays ?? []; + expect(rows).toHaveLength(3); + + // No cross-tenant leak. + expect(rows.some((r) => r.amount === 9999)).toBe(false); + + // Both source rows for the same (day, nutrient) survive independently. + const water = rows.filter((r) => r.nutrient === "water"); + expect(water).toHaveLength(2); + expect(water.map((r) => r.source).sort()).toEqual([ + "APPLE_HEALTH", + "MANUAL", + ]); + + // The denormalised unit rides along, per the schema's own contract. + const vitaminD = rows.find((r) => r.nutrient === "vitamin_d"); + expect(vitaminD).toMatchObject({ unit: "ug", amount: 12.5 }); + }); + + it("emits an empty array, not a missing key, for an account with no nutrient rows", async () => { + await seedUserSession("nutrient-empty-user"); + + const { GET } = await import("@/app/api/export/full-backup/route"); + const res = await GET( + new Request("http://localhost/api/export/full-backup", { + method: "GET", + }) as Parameters[0], + ); + expect(res.status).toBe(200); + + const parsed = JSON.parse(await res.text()) as BackupWithNutrients; + expect(parsed.nutrientDays).toEqual([]); + }); +});