reconcile: fold in the outstanding hardening and data-integrity fixes

Merge the eight reviewed audit fixes that were cut a week ago and never
landed. Main has moved on since, so each fix was re-checked against the
current tree and each of the five conflicts resolved by understanding
both sides.

Kept, still open on main:
- Proxy early exits now carry the transport security header subset via a
  shared helper. Reconciled with main's /api/health worker-only carve-out.
- Batch measurement ingest bounds the entry instant so a future-dated row
  can no longer win "latest reading" or poison the rollup buckets.
- Web Push dials through the pinned safeFetch dispatcher with
  requirePublicHost, closing the last user-controlled-host egress.
- Mood bulk upsert and its tag/factor links run in one transaction, so a
  rejected rating rolls the mood row back instead of leaving a tagless row.
- Full backup carries NutrientIntakeDay with its unit and source. Folded
  into main's portable-export / disaster-recovery split.
- Live daily-series fallback restores the min/max spread band the tiered
  read already emitted.
- Coach stream aborts its in-flight reader on unmount and resets send state
  on a conversation switch.
- Manual water writes share the day-bound helper, nutrient add clamps after
  the increment, and the thiamin cap drops to a real supplement ceiling.
- Compliance point window widens symmetrically instead of wrapping overnight.
- Oura, Polar and Fitbit token helpers classify an empty or non-JSON 2xx
  body as a handled transient error.
- WebAuthn relying-party fallback to localhost is gated to development.
- Chart-token strip list is derived from the allowlist and pinned by a test.
- Regression tests pin the snapshot-cache tenant key, the GlitchTip
  path-secret redaction and the Withings credentials delete.

Superseded by main, tests re-pointed at the current shape:
- The intake CSV import was rewritten on main to run through a background
  job with a single-active-job admission gate. The future-date bound is
  re-applied at request validation and the per-user limiter is kept for
  parity with the bulk twin; the old synchronous-write test is replaced.
This commit is contained in:
Marc-André Bombeck committed 2026-07-23 17:15:21 +02:00
commit b1e36d9fb0
51 files changed
+2066 -228

No files matched your search

@@ -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(() => {
@@ -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() },
@@ -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([]) },
+1
View File
@@ -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,
},
});
+1
View File
@@ -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,
},
});
@@ -0,0 +1,202 @@
/**
* `POST /api/measurements/batch` — entry-instant plausibility bound.
*
* The single-entry twin has applied `validateEntryInstant` to `measuredAt`
* since v1.17 W1b; the 500-entry batch path did not, so a future-dated row
* landed and permanently won "latest reading" on every dashboard while
* poisoning the rollup buckets and the canonical picker.
*
* The past floor deliberately stays at 1900: a historical Apple Health
* backfill is a legitimate shape on this route. Only the future side is a bug.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
measurement: {
findMany: vi.fn(),
createManyAndReturn: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
return (fn as (tx: unknown) => unknown)(prisma);
}
}),
},
}));
// Main routes every ingested row through `reconcileExternalMeasurement`; the
// plausibility guard runs BEFORE that call, so a valid entry only needs the
// reconcile to report a non-skip status for the assertion to hold.
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: vi.fn(
async (_tx: unknown, input: Record<string, unknown>) => ({
status: "inserted",
row: { id: "row-1", ...input },
}),
),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
collapseToTypeDayKeys: vi.fn(() => []),
}));
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/arrivals/emit-shared", () => ({
emitDataArrival: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
/** A step entry anchored on an offset from now, so the test never date-bombs. */
function stepEntryAt(externalId: string, startMs: number, endMs = startMs) {
return {
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value: 1200,
unit: "count",
startDate: new Date(startMs).toISOString(),
endDate: new Date(endMs).toISOString(),
externalId,
};
}
const DAY_MS = 24 * 60 * 60 * 1000;
async function statusesFor(entries: unknown[]) {
const res = await POST(makeRequest({ entries }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
entries: Array<{ index: number; status: string; reason?: string }>;
};
};
return body.data.entries;
}
beforeEach(() => {
// clearAllMocks (not resetAllMocks): the fire-and-forget post-processing
// mocks above carry `.mockResolvedValue` implementations that the route
// chains `.catch` onto; resetAllMocks would wipe those to bare `undefined`.
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue([
{ id: "row-1" },
] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 0 });
});
describe("POST /api/measurements/batch — entry-instant plausibility", () => {
it("skips a far-future entry instead of persisting it", async () => {
const results = await statusesFor([
stepEntryAt("ext-future", Date.now() + 365 * DAY_MS),
]);
expect(results[0]).toMatchObject({
index: 0,
status: "skipped",
reason: "implausible_timestamp",
});
});
it("skips an entry predating the 1900 floor", async () => {
const results = await statusesFor([
stepEntryAt("ext-ancient", Date.UTC(1899, 11, 31)),
]);
expect(results[0]).toMatchObject({
status: "skipped",
reason: "implausible_timestamp",
});
});
it("accepts a historical backfill entry — the past floor stays at 1900", async () => {
const results = await statusesFor([
stepEntryAt("ext-old", Date.now() - 8 * 365 * DAY_MS),
]);
expect(results[0]!.status).not.toBe("skipped");
});
it("tolerates a client clock a couple of minutes fast", async () => {
const results = await statusesFor([
stepEntryAt("ext-skew", Date.now() + 2 * 60 * 1000),
]);
expect(results[0]!.status).not.toBe("skipped");
});
it("skips only the offending entry and keeps the rest of the batch", async () => {
const results = await statusesFor([
stepEntryAt("ext-ok", Date.now() - DAY_MS),
stepEntryAt("ext-bad", Date.now() + 365 * DAY_MS),
]);
expect(results[0]!.status).not.toBe("skipped");
expect(results[1]).toMatchObject({ reason: "implausible_timestamp" });
});
});
+17
View File
@@ -61,6 +61,7 @@ import {
type MergeCandidate,
} from "@/lib/measurements/cross-source-merge";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -294,6 +295,22 @@ async function postBatch(request: NextRequest): Promise<Response> {
continue;
}
// Time-plausibility guard, the bound the single-entry twin already applies
// through `validateEntryInstant` on `createMeasurementSchema.measuredAt`.
// Without it a future-dated row permanently won "latest reading" on every
// dashboard and poisoned the rollup buckets and the canonical picker. The
// past floor stays at 1900 (no `maxAgeMs`) because a historical Apple
// Health backfill is a legitimate, expected shape on this route; only the
// future side is a bug. Skipped per entry, like the range guard above.
if (!isPlausibleEntryInstant(mapped.takenAt)) {
results[index] = {
index,
status: "skipped",
reason: "implausible_timestamp",
};
continue;
}
// v1.30.7 (iOS #34) — go-forward aggregated heart-rate wire
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<10-min-ISO-Z>`
// row is the 10-minute-average PULSE bucket; it rides the generic
@@ -36,6 +36,7 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(),
@@ -57,6 +58,7 @@ import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { checkRateLimit } from "@/lib/rate-limit";
import {
MEDICATION_INTAKE_IMPORT_QUEUE,
MEDICATION_INTAKE_IMPORT_STALE_AFTER_MS,
@@ -86,6 +88,11 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(getGlobalBoss).mockReturnValue({
send: vi.fn().mockResolvedValue("boss-1"),
} as never);
@@ -344,3 +351,64 @@ describe("POST /api/medications/[id]/intake/import — durable kickoff", () => {
});
});
});
describe("POST /api/medications/[id]/intake/import — future bound and limiter", () => {
// The `\d{4}-\d{2}-\d{2}` regex has no upper bound and the calendar-validity
// refine accepts any real date, future ones included. A future-dated row
// would enqueue a "taken" intake ahead of now, which the worker then feeds
// into compliance and the dose-history ledger. The single and bulk intake
// twins bound this through `boundedTakenAtSchema`; reject at validation here.
it("rejects a future-dated row with 422 before creating a job", async () => {
const future = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000);
const datum = future.toISOString().slice(0, 10);
const res = await POST(
postReq([{ datum, uhrzeit: "07:00:00" }]),
ROUTE_CTX,
);
expect(res.status).toBe(422);
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
expect(getGlobalBoss).not.toHaveBeenCalled();
});
it("still admits a historical row — the past side stays unbounded", async () => {
const res = await POST(
postReq([{ datum: "2019-03-04", uhrzeit: "07:00:00" }]),
ROUTE_CTX,
);
expect(res.status).toBe(202);
expect(prisma.medicationIntakeImportJob.create).toHaveBeenCalledTimes(1);
});
// This was the one intake write path with no limiter; the heavy loop now runs
// in the worker, but the admission transaction still does per-request DB work.
it("refuses with 429 once the import limiter trips", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
postReq([{ datum: "2026-01-01", uhrzeit: "07:00:00" }]),
ROUTE_CTX,
);
expect(res.status).toBe(429);
expect(prisma.medicationIntakeImportJob.create).not.toHaveBeenCalled();
expect(getGlobalBoss).not.toHaveBeenCalled();
});
it("buckets the limiter per user", async () => {
await POST(
postReq([{ datum: "2026-01-01", uhrzeit: "07:00:00" }]),
ROUTE_CTX,
);
expect(checkRateLimit).toHaveBeenCalledWith(
"medications:intake:import:user-1",
expect.any(Number),
expect.any(Number),
);
});
});
@@ -22,6 +22,8 @@ import {
} from "@/lib/jobs/medication-intake-import";
import { annotate } from "@/lib/logging/context";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { checkRateLimit } from "@/lib/rate-limit";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
const importEntrySchema = z.object({
datum: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
@@ -43,16 +45,36 @@ function isValidImportTimestamp(datum: string, uhrzeit: string): boolean {
);
}
const validatedImportEntrySchema = importEntrySchema.refine(
({ datum, uhrzeit }) => isValidImportTimestamp(datum, uhrzeit),
{ message: "Invalid date or time" },
);
const validatedImportEntrySchema = importEntrySchema
.refine(({ datum, uhrzeit }) => isValidImportTimestamp(datum, uhrzeit), {
message: "Invalid date or time",
})
// The `\d{4}-\d{2}-\d{2}` regex above has no upper bound, so a `2999-12-31`
// row would enqueue a future "taken" intake that then feeds compliance and
// the dose-history ledger through the worker. The single and bulk intake
// twins bound this through `boundedTakenAtSchema`; this path did not. The
// past side stays open (no `maxAgeMs`, only the 1900 floor) because
// importing years of history is the whole point of this route, so only the
// future bound applies.
.refine(
({ datum, uhrzeit }) =>
isPlausibleEntryInstant(new Date(`${datum}T${uhrzeit}`)),
{ message: "Timestamp is not a plausible capture time" },
);
const validatedImportSchema = z
.array(validatedImportEntrySchema)
.min(1)
.max(1000);
// Mirrors the bulk intake twin's budget. The heavy find + create +
// inventory-consume loop now runs in the background worker, but the admission
// transaction still does per-request DB work (a `FOR UPDATE` lock plus a stale
// -job sweep), so the same limiter the bulk twin carries keeps a rapid-fire
// caller from hammering it.
const IMPORT_RATE_LIMIT_MAX = 60;
const IMPORT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
@@ -65,6 +87,15 @@ export const POST = apiHandler(
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const rl = await checkRateLimit(
`medications:intake:import:${user.id}`,
IMPORT_RATE_LIMIT_MAX,
IMPORT_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many import submissions, try again later", 429);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024 * 1024,
});
@@ -13,13 +13,30 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// The transaction client is a DISTINCT object from `prisma` — otherwise a
// write that escapes the transaction (handed the base client) is
// indistinguishable from one that stays inside it, and the atomicity
// assertion below is vacuous. It SHARES the model mocks so every existing
// per-model assertion in this file keeps working unchanged.
const { moodEntryMock, txClient } = vi.hoisted(() => {
const moodEntryMock = { findMany: vi.fn(), upsert: vi.fn() };
return {
moodEntryMock,
txClient: { __brand: "transaction-client", moodEntry: moodEntryMock },
};
});
vi.mock("@/lib/db", () => ({
prisma: {
moodEntry: {
findMany: vi.fn(),
upsert: vi.fn(),
},
moodEntry: moodEntryMock,
auditLog: { create: vi.fn() },
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (fn as any)(txClient);
}
return undefined;
}),
},
}));
@@ -247,7 +264,9 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
"entry-1",
"user-1",
["movies", "gaming"],
prisma,
// The TRANSACTION client, not the base client — the write must not
// escape the transaction the mood row is being upserted in.
txClient,
[],
);
});
@@ -298,7 +317,7 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
"entry-1",
"user-1",
["movies"],
prisma,
txClient,
[{ key: "factor_work", rating: 4 }],
);
});
@@ -530,3 +549,69 @@ describe("POST /api/mood-entries/bulk — tombstone suppression", () => {
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(1);
});
});
describe("POST /api/mood-entries/bulk — upsert + tag links are atomic", () => {
// `createTagLinks` validates `ratedFactors` AFTER the mood row is written.
// Split across two awaits on the base client, an out-of-scale rating left a
// persisted entry with no links while the client was told "skipped" — the
// one status that promises nothing was saved.
it("runs the upsert and the tag-link write inside one transaction", async () => {
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
id: "mood-1",
} as never);
await POST(
postReq({
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
tagKeys: ["sleep"],
},
],
}),
);
expect(prisma.$transaction).toHaveBeenCalled();
// The link write must receive the transaction client, not the base client
// — handing it `prisma` would silently escape the transaction.
const txArg = vi.mocked(createTagLinks).mock.calls[0]?.[3];
const handedToTransaction = vi.mocked(prisma.$transaction).mock.calls
.length;
expect(handedToTransaction).toBeGreaterThan(0);
expect(txArg).toBeDefined();
});
it("does not report a skipped entry as inserted when the factor write throws", async () => {
const { RatedFactorOutOfRangeError } = await import("@/lib/mood/tag-links");
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
id: "mood-1",
} as never);
vi.mocked(createTagLinks).mockRejectedValueOnce(
new RatedFactorOutOfRangeError("factor_conflict", 5, 1, 2),
);
const res = await POST(
postReq({
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
ratedFactors: [{ key: "factor_conflict", rating: 5 }],
},
],
}),
);
const json = (await res.json()) as {
data: { inserted: number; entries: Array<{ status: string }> };
};
expect(json.data.entries[0]!.status).toBe("skipped");
expect(json.data.inserted).toBe(0);
// The throw must have propagated out of the transaction callback, so the
// rollback is the database's job rather than a compensating write.
expect(prisma.$transaction).toHaveBeenCalled();
});
});
+66 -56
View File
@@ -300,63 +300,73 @@ async function postBulk(request: NextRequest): Promise<Response> {
continue;
}
const result = await prisma.moodEntry.upsert({
where: probeWhere,
create: {
userId: user.id,
date,
tz,
mood: entry.mood,
score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: null,
noteEncrypted: encryptNote(entry.note ?? null),
source: resolvedSource,
externalId: entry.externalId ?? null,
moodLoggedAt: entry.moodLoggedAt,
},
update: {
// Last-writer-wins on the mood + tags + note triple. The
// iOS client only re-posts an existing entry when it has
// new data; the server trusts that decision. When the dedup
// key is `externalId`, also refresh `date` / `moodLoggedAt`
// so a re-zoned re-import lands the corrected wall-clock on
// the same row.
mood: entry.mood,
score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: null,
noteEncrypted: encryptNote(entry.note ?? null),
...(entry.externalId
? { date, moodLoggedAt: entry.moodLoggedAt }
: {}),
},
});
// The upsert and the tag/factor links run in ONE transaction, matching
// the single-entry POST path. Split apart, an out-of-scale
// `ratedFactors` value (validated inside `createTagLinks`, i.e. AFTER
// the row committed) left a persisted mood entry with no links while the
// per-entry catch reported the entry as "skipped" — the client believes
// nothing saved and a half-written row is on disk.
const result = await prisma.$transaction(async (tx) => {
const upserted = await tx.moodEntry.upsert({
where: probeWhere,
create: {
userId: user.id,
date,
tz,
mood: entry.mood,
score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: null,
noteEncrypted: encryptNote(entry.note ?? null),
source: resolvedSource,
externalId: entry.externalId ?? null,
moodLoggedAt: entry.moodLoggedAt,
},
update: {
// Last-writer-wins on the mood + tags + note triple. The
// iOS client only re-posts an existing entry when it has
// new data; the server trusts that decision. When the dedup
// key is `externalId`, also refresh `date` / `moodLoggedAt`
// so a re-zoned re-import lands the corrected wall-clock on
// the same row.
mood: entry.mood,
score,
tags: entry.tags ? JSON.stringify(entry.tags) : null,
note: null,
noteEncrypted: encryptNote(entry.note ?? null),
...(entry.externalId
? { date, moodLoggedAt: entry.moodLoggedAt }
: {}),
},
});
// v1.12.0 — persist structured-tag links, mirroring the
// single-entry `createTagLinks` path. Additive + idempotent:
// `createTagLinks` resolves keys against the catalog (dropping
// unknown keys) and `skipDuplicates` on the join insert keeps a
// re-posted entry from minting duplicate links. Runs for both
// fresh and re-posted (upserted) rows so a backfill that adds tag
// keys on a second pass still lands them.
// v1.12.0 — rated factors ride the same path; an out-of-scale
// rating throws `RatedFactorOutOfRangeError`, which the per-entry
// catch below turns into a `skipped` result (the rest of the batch
// still lands). The mood row itself already upserted, so a skipped
// factor leaves a valid entry with no rated links.
if (
(entry.tagKeys && entry.tagKeys.length > 0) ||
(entry.ratedFactors && entry.ratedFactors.length > 0)
) {
await createTagLinks(
result.id,
user.id,
entry.tagKeys ?? [],
prisma,
entry.ratedFactors ?? [],
);
}
// v1.12.0 — persist structured-tag links, mirroring the
// single-entry `createTagLinks` path. Additive + idempotent:
// `createTagLinks` resolves keys against the catalog (dropping
// unknown keys) and `skipDuplicates` on the join insert keeps a
// re-posted entry from minting duplicate links. Runs for both
// fresh and re-posted (upserted) rows so a backfill that adds tag
// keys on a second pass still lands them.
// v1.12.0 — rated factors ride the same path; an out-of-scale
// rating throws `RatedFactorOutOfRangeError`. Inside the
// transaction that throw now rolls the mood row back with it, so a
// "skipped" result means nothing was written — which is what the
// client was already being told.
if (
(entry.tagKeys && entry.tagKeys.length > 0) ||
(entry.ratedFactors && entry.ratedFactors.length > 0)
) {
await createTagLinks(
upserted.id,
user.id,
entry.tagKeys ?? [],
tx,
entry.ratedFactors ?? [],
);
}
return upserted;
});
if (existing) {
duplicates += 1;
+4 -12
View File
@@ -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<Response> {
}
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 }> = [];
@@ -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();
});
});
+21 -2
View File
@@ -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<Response> {
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<Response> {
},
};
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<Response> {
: { 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.
@@ -0,0 +1,96 @@
/**
* `DELETE /api/withings/credentials` — disconnect honesty.
*
* The handler deletes the connection row (holding the encrypted OAuth access +
* refresh tokens) before nulling the credential columns. That delete used to be
* `.catch(() => {})`: on a real failure the handler carried on, nulled the
* client id/secret, and returned `{ deleted: true }` while the token-bearing
* row survived — the user is told they are disconnected and they are not.
*
* It now narrows to P2025 ("already gone", a genuine no-op) and rethrows
* everything else. Withings had no test file at all, which is why this one
* exists; the WHOOP and Fitbit twins carry the same shape.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
withingsConnection: { delete: vi.fn() },
user: { update: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { DELETE } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
/** Shape of a Prisma "record to delete does not exist" rejection. */
function p2025() {
return Object.assign(new Error("Record to delete does not exist."), {
code: "P2025",
name: "PrismaClientKnownRequestError",
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
});
describe("DELETE /api/withings/credentials", () => {
it("disconnects and nulls the credential columns on the happy path", async () => {
vi.mocked(prisma.withingsConnection.delete).mockResolvedValue({} as never);
const res = await DELETE();
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
data: {
withingsClientIdEncrypted: null,
withingsClientSecretEncrypted: null,
},
}),
);
});
it("treats an already-missing connection row (P2025) as a benign no-op", async () => {
vi.mocked(prisma.withingsConnection.delete).mockRejectedValue(p2025());
const res = await DELETE();
expect(res.status).toBe(200);
// Idempotent disconnect still clears the credentials.
expect(prisma.user.update).toHaveBeenCalled();
});
it("does NOT report success when the token-bearing row fails to delete", async () => {
vi.mocked(prisma.withingsConnection.delete).mockRejectedValue(
new Error("connection terminated unexpectedly"),
);
const res = await DELETE();
expect(res.status).toBe(500);
// Critically: the credentials must NOT be nulled, because doing so while
// the encrypted OAuth tokens survive is the worst of both states.
expect(prisma.user.update).not.toHaveBeenCalled();
});
});