From 92b361c3c6b338ea78ca22ac6ed7a1f42ead6bc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 16 Oct 2026 03:57:45 +0200 Subject: [PATCH] feat(validation): add isoDateTime with a refusal counter for instants without seconds --- src/__tests__/datetime-helper-guard.test.ts | 93 +++++++++++++++++++ .../datetime-no-seconds-counter.test.ts | 36 +++++++ src/lib/logging/context.ts | 17 ++++ .../validations/__tests__/datetime.test.ts | 58 ++++++++++++ src/lib/validations/datetime.ts | 89 ++++++++++++++++++ 5 files changed, 293 insertions(+) create mode 100644 src/__tests__/datetime-helper-guard.test.ts create mode 100644 src/lib/logging/__tests__/datetime-no-seconds-counter.test.ts create mode 100644 src/lib/validations/__tests__/datetime.test.ts create mode 100644 src/lib/validations/datetime.ts diff --git a/src/__tests__/datetime-helper-guard.test.ts b/src/__tests__/datetime-helper-guard.test.ts new file mode 100644 index 000000000..42012eb8c --- /dev/null +++ b/src/__tests__/datetime-helper-guard.test.ts @@ -0,0 +1,93 @@ +/** + * Every ISO instant under `src/` goes through `isoDateTime()`. + * + * Since v1.43 a request instant must carry seconds, and the refusal is counted + * (`validation.datetime.no_seconds`) so a client on a minute-precision encoder + * shows up in the logs. Both live in `src/lib/validations/datetime.ts`. A + * schema that calls `z.iso.datetime(` or `z.string().datetime(` directly gets + * zod's default message and is invisible to the counter, so a direct call + * anywhere else is refused here. + * + * The matcher tolerates whitespace and line breaks between `z`, `.iso` / + * `.string()` and `.datetime(`, because prettier splits long chains, and it + * also refuses a bare `.datetime(` chained off anything, so an alias such as + * `const s = z.string(); s.datetime()` cannot route around it. Comments are + * stripped first so prose about the old form does not trip it. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); +const HELPER = "lib/validations/datetime.ts"; + +function sourceFiles(): string[] { + return walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx")); +} + +function stripComments(text: string): string { + return text + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/(^|[^:"'`])\/\/.*$/gm, "$1"); +} + +const DIRECT_DATETIME = /\.\s*datetime\s*\(/g; +const HELPER_CALL = /\bisoDateTime\s*\(/g; + +function directDatetimeCalls(text: string): number { + return (stripComments(text).match(DIRECT_DATETIME) ?? []).length; +} + +describe("datetime helper guard", () => { + const files = sourceFiles(); + + it("finds no direct zod datetime call outside the helper", () => { + const offenders = files + .filter((rel) => rel !== HELPER) + .flatMap((rel) => { + const n = directDatetimeCalls(readFileSync(join(SRC, rel), "utf8")); + return n > 0 ? [`${rel} (${n})`] : []; + }); + expect( + offenders, + "use isoDateTime() from @/lib/validations/datetime instead", + ).toEqual([]); + }); + + it("the helper itself is the one place that calls zod", () => { + expect(directDatetimeCalls(readFileSync(join(SRC, HELPER), "utf8"))).toBe( + 1, + ); + }); + + it("actually sees the call sites it guards", () => { + // An empty sweep agrees with an empty offender list. The helper has + // hundreds of callers; a count far below that means the walk or the + // matcher went blind, not that the tree is clean. + const calls = files + .filter((rel) => rel !== HELPER) + .reduce( + (sum, rel) => + sum + + (readFileSync(join(SRC, rel), "utf8").match(HELPER_CALL) ?? []) + .length, + 0, + ); + expect(calls).toBeGreaterThan(350); + }); + + it("matches the forms prettier produces", () => { + expect(directDatetimeCalls("z.iso.datetime({ offset: true })")).toBe(1); + expect(directDatetimeCalls("z.iso\n .datetime({ offset: true })")).toBe(1); + expect(directDatetimeCalls("z\n .string()\n .datetime()")).toBe(1); + expect(directDatetimeCalls("s . datetime ()")).toBe(1); + expect(directDatetimeCalls("// z.iso.datetime() in prose")).toBe(0); + expect(directDatetimeCalls("/* z.string().datetime() */")).toBe(0); + expect(directDatetimeCalls("isoDateTime()")).toBe(0); + }); +}); diff --git a/src/lib/logging/__tests__/datetime-no-seconds-counter.test.ts b/src/lib/logging/__tests__/datetime-no-seconds-counter.test.ts new file mode 100644 index 000000000..a58146541 --- /dev/null +++ b/src/lib/logging/__tests__/datetime-no-seconds-counter.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { eventStorage } from "@/lib/logging/context"; +import { WideEventBuilder } from "@/lib/logging/event-builder"; +import { + DATETIME_NO_SECONDS_COUNTER, + isoDateTime, +} from "@/lib/validations/datetime"; + +describe("validation.datetime.no_seconds", () => { + it("counts each minute-precision refusal on the request's wide event", () => { + const builder = new WideEventBuilder("http"); + eventStorage.run(builder, () => { + const s = isoDateTime(); + s.safeParse("2026-10-15T06:15Z"); + s.safeParse("2026-10-15T07:15+02:00"); + s.safeParse("2026-10-15T07:15:00Z"); + s.safeParse("garbage"); + }); + expect(builder.toJSON().meta?.[DATETIME_NO_SECONDS_COUNTER]).toBe(2); + }); + + it("leaves a request without such a refusal untouched", () => { + const builder = new WideEventBuilder("http"); + eventStorage.run(builder, () => { + isoDateTime().safeParse("2026-10-15T07:15:00Z"); + }); + expect( + builder.toJSON().meta?.[DATETIME_NO_SECONDS_COUNTER], + ).toBeUndefined(); + }); + + it("is a no-op outside a request", () => { + expect(() => isoDateTime().safeParse("2026-10-15T06:15Z")).not.toThrow(); + }); +}); diff --git a/src/lib/logging/context.ts b/src/lib/logging/context.ts index 5ff165dff..13e550918 100644 --- a/src/lib/logging/context.ts +++ b/src/lib/logging/context.ts @@ -2,6 +2,10 @@ import { AsyncLocalStorage } from "node:async_hooks"; import type { WideEvent } from "./types"; import { WideEventBuilder } from "./event-builder"; import { registerSourcePriorityParseObserver } from "@/lib/validations/source-priority"; +import { + DATETIME_NO_SECONDS_COUNTER, + registerDatetimeNoSecondsObserver, +} from "@/lib/validations/datetime"; /** AsyncLocalStorage fuer den aktuellen Request-Kontext */ export const eventStorage = new AsyncLocalStorage(); @@ -37,3 +41,16 @@ export function annotate(fields: { registerSourcePriorityParseObserver((fields) => { annotate(fields); }); + +// v1.43 — count every request instant refused for lacking seconds, per wide +// event, so a client still on a minute-precision encoder is visible in the +// logs (`meta["validation.datetime.no_seconds"]`) before its users report +// the 422. Same bundle-safe registration as the breadcrumb above. +const noSecondsCounts = new WeakMap(); +registerDatetimeNoSecondsObserver(() => { + const evt = getEvent(); + if (!evt) return; + const next = (noSecondsCounts.get(evt) ?? 0) + 1; + noSecondsCounts.set(evt, next); + evt.addMeta(DATETIME_NO_SECONDS_COUNTER, next); +}); diff --git a/src/lib/validations/__tests__/datetime.test.ts b/src/lib/validations/__tests__/datetime.test.ts new file mode 100644 index 000000000..0271ffe88 --- /dev/null +++ b/src/lib/validations/__tests__/datetime.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + DATETIME_NO_SECONDS_MESSAGE, + isoDateTime, + registerDatetimeNoSecondsObserver, +} from "@/lib/validations/datetime"; + +describe("isoDateTime", () => { + it("accepts an instant with seconds, with or without fraction and offset", () => { + const s = isoDateTime(); + for (const v of [ + "2026-10-15T06:15:00Z", + "2026-10-15T06:15:00.000Z", + "2026-10-15T06:15:00.123456Z", + "2026-10-15T06:15:00+02:00", + ]) { + expect(s.safeParse(v).success, v).toBe(true); + } + }); + + it("refuses a minute-precision instant and says seconds are missing", () => { + for (const v of ["2026-10-15T06:15Z", "2026-10-15T06:15+02:00"]) { + const r = isoDateTime().safeParse(v); + expect(r.success, v).toBe(false); + expect(r.error?.issues[0].message).toBe(DATETIME_NO_SECONDS_MESSAGE); + } + }); + + it("keeps the caller's message for every other refusal", () => { + const s = isoDateTime({ offset: false, message: "takenAt is bad" }); + expect(s.safeParse("yesterday").error?.issues[0].message).toBe( + "takenAt is bad", + ); + expect(s.safeParse("2026-10-15T06:15Z").error?.issues[0].message).toBe( + DATETIME_NO_SECONDS_MESSAGE, + ); + }); + + it("offset: false accepts only Z and does not count an offset form", () => { + const s = isoDateTime({ offset: false }); + expect(s.safeParse("2026-10-15T06:15:00Z").success).toBe(true); + expect(s.safeParse("2026-10-15T06:15:00+02:00").success).toBe(false); + expect( + s.safeParse("2026-10-15T06:15+02:00").error?.issues[0].message, + ).not.toBe(DATETIME_NO_SECONDS_MESSAGE); + }); + + it("tells the observer about minute-precision refusals only", () => { + const seen = vi.fn(); + registerDatetimeNoSecondsObserver(seen); + const s = isoDateTime(); + s.safeParse("2026-10-15T06:15Z"); + s.safeParse("not a date"); + s.safeParse("2026-10-15T06:15:00Z"); + expect(seen).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/validations/datetime.ts b/src/lib/validations/datetime.ts new file mode 100644 index 000000000..ef9a752dc --- /dev/null +++ b/src/lib/validations/datetime.ts @@ -0,0 +1,89 @@ +/** + * The one ISO 8601 instant validator every schema uses. + * + * Since v1.43 an instant must carry seconds: `2026-10-15T06:15:00Z`, + * `2026-10-15T06:15:00.000Z` and `2026-10-15T06:15:00+02:00` pass, + * `2026-10-15T06:15Z` is refused. zod 4.5 made that the behaviour of + * `z.iso.datetime()`; routing every call site through this helper keeps the + * rule in one place, gives the refusal a message that says what is missing, + * and counts each refusal so a client that still sends minute-precision + * instants shows up in the logs before its users do. + * + * `src/__tests__/datetime-helper-guard.test.ts` forbids a direct + * `z.iso.datetime(` or `z.string().datetime(` anywhere else under `src/`. + * + * Client-safe: the counter goes through an observer the server logging module + * registers on import (the same pattern as `registerSourcePriorityParseObserver`), + * so a form that validates with these schemas never pulls `node:async_hooks` + * into the browser bundle. + */ +import { z } from "zod/v4"; + +/** Wide-event meta key: how many instants this request refused for lacking seconds. */ +export const DATETIME_NO_SECONDS_COUNTER = "validation.datetime.no_seconds"; + +/** Message for an instant that would be valid if it carried seconds. */ +export const DATETIME_NO_SECONDS_MESSAGE = + "Invalid ISO datetime: seconds are required (YYYY-MM-DDTHH:mm:ssZ)"; + +// Date, hours and minutes, then an optional zone and nothing else: the shape a +// minute-precision encoder produces. Only this shape counts, so the counter +// measures exactly the clients the seconds rule turned away and not every +// malformed string. A field that requires `Z` does not count an offset form, +// which it would refuse with seconds too. +const MINUTE_PRECISION_OFFSET = + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?:Z|[+-]\d{2}:\d{2})$/; +const MINUTE_PRECISION_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z$/; + +type NoSecondsObserver = () => void; +let noSecondsObserver: NoSecondsObserver = () => {}; + +/** + * Server-side opt-in for the refusal counter. Called once from + * `@/lib/logging/context`; the browser keeps the no-op default. + */ +export function registerDatetimeNoSecondsObserver( + observer: NoSecondsObserver, +): void { + noSecondsObserver = observer; +} + +export interface IsoDateTimeOptions { + /** + * Accept a numeric UTC offset (`+02:00`) besides `Z`. Default `true`; + * `false` keeps the handful of fields that have always required `Z`. + */ + offset?: boolean; + /** Message for every other refusal; zod's default when omitted. */ + message?: string; +} + +/** + * An ISO 8601 instant with seconds. Use it for every timestamp a schema + * accepts or publishes; chain `.optional()`, `.nullable()`, `.describe()` and + * `.meta()` as on any zod string. + */ +export function isoDateTime(options: IsoDateTimeOptions = {}) { + const { offset = true, message } = options; + const minutePrecision = offset + ? MINUTE_PRECISION_OFFSET + : MINUTE_PRECISION_UTC; + return ( + z.iso + .datetime({ + offset, + // zod builds issue messages lazily, when a caller first reads them, + // so this callback only words the refusal; it cannot count it. + error: (issue) => + typeof issue.input === "string" && minutePrecision.test(issue.input) + ? DATETIME_NO_SECONDS_MESSAGE + : message, + }) + // The count runs here instead: a refinement runs on every parse of a + // string, even after the format check failed, and adds no issue. + .refine((value) => { + if (minutePrecision.test(value)) noSecondsObserver(); + return true; + }) + ); +}