mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
feat(validation): add isoDateTime with a refusal counter for instants without seconds
This commit is contained in:
1 parent
b1c19dcfa4
commit
92b361c3c6
5 files changed
+293
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<WideEventBuilder>();
|
||||
@@ -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<WideEventBuilder, number>();
|
||||
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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
})
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user