fix(medications): guard intake edits against implausible timestamps

An intake edit accepted any takenAt value, so a date typo could park a
dose a month away from its slot and poison the today feed, the rollup
and the rolling next-due anchor at once. The update schema now rejects
future timestamps (with clock-skew tolerance) and values older than five
years, the route rejects edits before the medication's start date, and
the edit dialog caps the picker at now, shows the scheduled slot and
warns when the new time sits more than 48 hours from it. The dialog's
note field is gone — it was never persisted and silently discarded
input.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 11:17:48 +02:00
1 parent 0761ce2346
commit 09dbb1131d
14 files changed
+369 -31

No files matched your search

@@ -0,0 +1,76 @@
/**
* v1.15.19 — `updateIntakeEventSchema.takenAt` plausibility bounds
* (audit P0-4).
*
* A date typo on an intake edit could previously park `takenAt` on any
* instant — a month before the slot, a year in the future — with no
* pushback. The schema now rejects a future `takenAt` (beyond a small
* clock-skew allowance) and anything older than 5 years; the
* per-medication start-date floor lives in the route, the slot-distance
* hint in the edit dialog.
*/
import { describe, expect, it } from "vitest";
import { updateIntakeEventSchema } from "@/lib/validations/medication";
const HOUR_MS = 60 * 60 * 1000;
const YEAR_MS = 365 * 24 * HOUR_MS;
describe("updateIntakeEventSchema — takenAt bounds (P0-4)", () => {
it("accepts a takenAt in the recent past", () => {
const result = updateIntakeEventSchema.safeParse({
takenAt: new Date(Date.now() - 2 * HOUR_MS).toISOString(),
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.takenAt).toBeInstanceOf(Date);
}
});
it("accepts a takenAt slightly ahead of now (clock skew)", () => {
const result = updateIntakeEventSchema.safeParse({
takenAt: new Date(Date.now() + 2 * 60 * 1000).toISOString(),
});
expect(result.success).toBe(true);
});
it("rejects a takenAt in the future beyond the skew allowance", () => {
const result = updateIntakeEventSchema.safeParse({
takenAt: new Date(Date.now() + HOUR_MS).toISOString(),
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toContain("future");
}
});
it("rejects a takenAt more than 5 years in the past", () => {
const result = updateIntakeEventSchema.safeParse({
takenAt: new Date(Date.now() - 6 * YEAR_MS).toISOString(),
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.message).toContain("5 years");
}
});
it("accepts a takenAt within the 5-year window", () => {
const result = updateIntakeEventSchema.safeParse({
takenAt: new Date(Date.now() - 4 * YEAR_MS).toISOString(),
});
expect(result.success).toBe(true);
});
it("still accepts an explicit null takenAt (clears the take)", () => {
const result = updateIntakeEventSchema.safeParse({
takenAt: null,
skipped: true,
});
expect(result.success).toBe(true);
});
it("still accepts a body that omits takenAt entirely", () => {
const result = updateIntakeEventSchema.safeParse({ skipped: false });
expect(result.success).toBe(true);
});
});
+20 -1
View File
@@ -693,11 +693,30 @@ export const listIntakeEventsSchema = z.object({
.default("all"),
});
/**
* v1.15.19 — `takenAt` plausibility bounds on the edit path (audit P0-4).
* A date typo on an intake edit could park `takenAt` a month before its
* slot with no pushback anywhere. The schema rejects the physically
* implausible cases: a future instant (small skew allowance for client
* clocks) and anything older than the 5-year window the GLP-1 dose-change
* validator already established (`glp1DoseChangePostSchema`). Slot-distance
* checks stay out of the schema — it cannot see the medication — and live
* in the route (start-date guard) + the edit dialog (non-blocking hint).
*/
const TAKEN_AT_CLOCK_SKEW_MS = 5 * 60 * 1000;
const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000;
export const updateIntakeEventSchema = z
.object({
takenAt: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.refine((d) => d.getTime() <= Date.now() + TAKEN_AT_CLOCK_SKEW_MS, {
message: "takenAt must not be in the future",
})
.refine((d) => d.getTime() >= Date.now() - TAKEN_AT_MAX_AGE_MS, {
message: "takenAt must be within the last 5 years",
})
.nullable()
.optional(),
skipped: z.boolean().optional(),
@@ -723,7 +742,7 @@ export const updateIntakeEventSchema = z
.meta({
id: "UpdateMedicationIntakeEventRequest",
description:
"Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot; an explicit `scheduledFor` still wins when supplied directly.",
"Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot; an explicit `scheduledFor` still wins when supplied directly. `takenAt` must not be in the future (5-minute clock-skew allowance) nor more than 5 years in the past; a `takenAt` before the medication's start date returns 422.",
});
/**