From 281615a4b079b1568bffe73c31d70e44bc46804a Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 19 Aug 2026 21:26:20 +0530 Subject: [PATCH] fix(validation): stop unrecognised boolean strings coercing to a silent false checkField in packages/validation/src/index.ts (and its browser mirror in runtime.ts) treated any string other than "true"/"on" as false with no error, so typos like "treu" or values like "yes"/"1"/"TRUE" silently passed as false. Now: - true/false booleans pass through unchanged - recognised true strings (case-insensitive, trimmed): true, on, 1, yes - recognised false strings: false, off, 0, no - numeric 1/0 coerce (JSON payloads) - undefined/null/"" still coerce to false (unchecked-checkbox semantics) - anything else is now a type error (desc.typeMessage or "Must be true or false") instead of a silent false Locked-in behaviours preserved: a required boolean given false still errors, and parseEnv DEBUG: "true" coercion still works. Added coverage for recognised strings, numeric 1/0, the type-error regression guard, absent/empty handling, the required+false case, and a client/server parity test driving both checkField and the browser runtime through the same inputs. Blast radius: searched packages/, examples/, services/ for v.boolean() usage; all existing call sites (auth consent/rememberDevice, db 'active' default, example consent checkboxes) feed true/false/'on'/absent values, none of which change behavior. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 15 +++ packages/validation/src/index.ts | 23 +++- packages/validation/src/runtime.ts | 23 +++- packages/validation/test/validation.test.ts | 134 ++++++++++++++++++++ 4 files changed, 193 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c84b1cf8..53e4aa30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased +- Fixed `v.boolean()` coercion in `@wrnexus/validation` (`checkField` in both `src/index.ts` + and the browser mirror in `src/runtime.ts`): previously any string other than `"true"` or + `"on"` silently coerced to `false` with no error, so typos and unrecognised values (e.g. + `"yes"`, `"1"`, `"TRUE"`, `"treu"`) passed validation as a silent, wrong `false`. Now + recognised true strings (`"true"`, `"on"`, `"1"`, `"yes"`, case-insensitive and trimmed) and + false strings (`"false"`, `"off"`, `"0"`, `"no"`) coerce as expected, numeric `1`/`0` coerce + (for JSON payloads), and absent/empty input (`undefined`/`null`/`""`) still coerces to + `false` exactly as before (unchanged HTML-checkbox semantics). **Behavior change for + downstream apps:** any other value — an unrecognised string, an object, an array — is now a + type error (`desc.typeMessage` or "Must be true or false") instead of a silent `false`. A + required boolean field given `false` still errors, as before (checkbox-required semantics + are unchanged). A repo-wide search of `packages/`, `examples/`, and `services/` found no + existing `v.boolean()` usage that feeds an unrecognised value, so no call sites are expected + to start failing. + - Fixed `defineEndpoint` (`@wrnexus/core`) so routes invoked through the real HTTP router (which calls handlers as `handler(ctx)`, with no second argument) actually receive their request input: it now parses query parameters for GET/HEAD and the JSON body otherwise diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 7133c35b..ed6c7a6f 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -114,7 +114,28 @@ export function checkField( } if (desc.type === "boolean") { - const value = raw === true || raw === "true" || raw === "on"; + const empty = raw === undefined || raw === null || raw === ""; + let value: boolean; + if (typeof raw === "boolean") { + value = raw; + } else if (empty) { + value = false; + } else if (raw === 1) { + value = true; + } else if (raw === 0) { + value = false; + } else if (typeof raw === "string") { + const norm = raw.trim().toLowerCase(); + if (norm === "true" || norm === "on" || norm === "1" || norm === "yes") { + value = true; + } else if (norm === "false" || norm === "off" || norm === "0" || norm === "no") { + value = false; + } else { + return { value: raw, error: desc.typeMessage ?? "Must be true or false" }; + } + } else { + return { value: raw, error: desc.typeMessage ?? "Must be true or false" }; + } if (!desc.optional && !value) { return { value, error: desc.requiredMessage || "Required" }; } diff --git a/packages/validation/src/runtime.ts b/packages/validation/src/runtime.ts index 6c4d0904..2fc1bf5f 100644 --- a/packages/validation/src/runtime.ts +++ b/packages/validation/src/runtime.ts @@ -45,7 +45,28 @@ export const VALIDATE_RUNTIME = String.raw` return (missing && !desc.optional) ? (desc.requiredMessage || "Required") : null; } if (desc.type === "boolean") { - var b = raw === true || raw === "true" || raw === "on"; + var bEmpty = raw === undefined || raw === null || raw === ""; + var b; + if (typeof raw === "boolean") { + b = raw; + } else if (bEmpty) { + b = false; + } else if (raw === 1) { + b = true; + } else if (raw === 0) { + b = false; + } else if (typeof raw === "string") { + var bNorm = raw.trim().toLowerCase(); + if (bNorm === "true" || bNorm === "on" || bNorm === "1" || bNorm === "yes") { + b = true; + } else if (bNorm === "false" || bNorm === "off" || bNorm === "0" || bNorm === "no") { + b = false; + } else { + return desc.typeMessage || "Must be true or false"; + } + } else { + return desc.typeMessage || "Must be true or false"; + } return (!desc.optional && !b) ? (desc.requiredMessage || "Required") : null; } var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw; diff --git a/packages/validation/test/validation.test.ts b/packages/validation/test/validation.test.ts index ea94bc21..9a407fcf 100644 --- a/packages/validation/test/validation.test.ts +++ b/packages/validation/test/validation.test.ts @@ -114,6 +114,140 @@ test("checkField coerces and applies rules", () => { ).toBe("Email is required"); }); +test("checkField coerces recognised true/false boolean strings, case-insensitively and trimmed", () => { + const desc = { type: "boolean" as const, optional: true, rules: [] }; + for (const raw of [ + "true", + "TRUE", + " True ", + "on", + "ON", + " on ", + "1", + " 1 ", + "yes", + "YES", + " Yes ", + ]) { + expect(checkField(desc, raw)).toEqual({ value: true, error: null }); + } + for (const raw of [ + "false", + "FALSE", + " False ", + "off", + "OFF", + " off ", + "0", + " 0 ", + "no", + "NO", + " No ", + ]) { + expect(checkField(desc, raw)).toEqual({ value: false, error: null }); + } +}); + +test("checkField coerces numeric 1/0 booleans (common in JSON payloads)", () => { + const desc = { type: "boolean" as const, optional: true, rules: [] }; + expect(checkField(desc, 1)).toEqual({ value: true, error: null }); + expect(checkField(desc, 0)).toEqual({ value: false, error: null }); + expect(checkField(desc, true)).toEqual({ value: true, error: null }); + expect(checkField(desc, false)).toEqual({ value: false, error: null }); +}); + +test("checkField rejects unrecognised boolean strings/values as a type error, not a silent false", () => { + const desc = { type: "boolean" as const, optional: true, rules: [] }; + for (const raw of ["yes please", "maybe", "treu", "TRUE!", "2", { a: 1 }, [1, 2]]) { + const result = checkField(desc, raw); + expect(result.error).toBe("Must be true or false"); + } + const custom = checkField( + { type: "boolean" as const, optional: true, typeMessage: "Pick yes or no", rules: [] }, + "maybe", + ); + expect(custom.error).toBe("Pick yes or no"); +}); + +test("checkField treats absent/empty boolean input as false, erroring only when required", () => { + const optionalDesc = { type: "boolean" as const, optional: true, rules: [] }; + expect(checkField(optionalDesc, undefined)).toEqual({ value: false, error: null }); + expect(checkField(optionalDesc, null)).toEqual({ value: false, error: null }); + expect(checkField(optionalDesc, "")).toEqual({ value: false, error: null }); + + const requiredDesc = { + type: "boolean" as const, + requiredMessage: "Required", + rules: [], + }; + expect(checkField(requiredDesc, undefined).error).toBe("Required"); + expect(checkField(requiredDesc, "").error).toBe("Required"); +}); + +test("checkField still errors when a required boolean is explicitly false (checkbox semantics locked in)", () => { + expect( + checkField( + { type: "boolean" as const, requiredMessage: "Accept the terms to continue", rules: [] }, + false, + ), + ).toEqual({ value: false, error: "Accept the terms to continue" }); +}); + +test("client/server boolean parity: checkField and the browser runtime agree on every case", () => { + const win = new Window() as unknown as Window & Record; + win.document.body.innerHTML = ` +
+ + +
`; + const schema = v.object({ field: v.boolean().optional() }).describe(); + win.__wrnSchemas = { parity: schema }; + (globalThis as Record).window = win; + (globalThis as Record).document = win.document; + + try { + (0, eval)(VALIDATE_RUNTIME); + const runtime = win.__wrnValidate as { init(root: Document): void }; + runtime.init(win.document as unknown as Document); + const input = win.document.querySelector("input") as HappyDOMHTMLInputElement; + const error = win.document.querySelector("[data-error=field]") as HappyDOMHTMLElement; + + const cases = [ + "true", + "TRUE", + " True ", + "on", + "ON", + "1", + " 1 ", + "yes", + "YES", + "false", + "FALSE", + "off", + "OFF", + "0", + "no", + "NO", + "", + "yes please", + "maybe", + "treu", + ]; + + for (const raw of cases) { + const serverResult = checkField(schema.fields.field, raw); + input.value = raw; + input.dispatchEvent(windowEvent(win, "blur", { bubbles: true })); + const clientRejected = error.textContent !== ""; + expect(clientRejected).toBe(serverResult.error !== null); + } + } finally { + delete (globalThis as Record).window; + delete (globalThis as Record).document; + } +}); + test("invalid() returns a 400 with errors", async () => { const res = invalid({ email: "bad" }); expect(res.status).toBe(400);