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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
- 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
|
(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
|
request input: it now parses query parameters for GET/HEAD and the JSON body otherwise
|
||||||
|
|||||||
@@ -114,7 +114,28 @@ export function checkField(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (desc.type === "boolean") {
|
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) {
|
if (!desc.optional && !value) {
|
||||||
return { value, error: desc.requiredMessage || "Required" };
|
return { value, error: desc.requiredMessage || "Required" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,28 @@ export const VALIDATE_RUNTIME = String.raw`
|
|||||||
return (missing && !desc.optional) ? (desc.requiredMessage || "Required") : null;
|
return (missing && !desc.optional) ? (desc.requiredMessage || "Required") : null;
|
||||||
}
|
}
|
||||||
if (desc.type === "boolean") {
|
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;
|
return (!desc.optional && !b) ? (desc.requiredMessage || "Required") : null;
|
||||||
}
|
}
|
||||||
var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
|
var pre = desc.trim && typeof raw === "string" ? raw.trim() : raw;
|
||||||
|
|||||||
@@ -114,6 +114,140 @@ test("checkField coerces and applies rules", () => {
|
|||||||
).toBe("Email is required");
|
).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<string, unknown>;
|
||||||
|
win.document.body.innerHTML = `
|
||||||
|
<form data-schema="parity">
|
||||||
|
<input name="field">
|
||||||
|
<span data-error="field"></span>
|
||||||
|
</form>`;
|
||||||
|
const schema = v.object({ field: v.boolean().optional() }).describe();
|
||||||
|
win.__wrnSchemas = { parity: schema };
|
||||||
|
(globalThis as Record<string, unknown>).window = win;
|
||||||
|
(globalThis as Record<string, unknown>).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<string, unknown>).window;
|
||||||
|
delete (globalThis as Record<string, unknown>).document;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("invalid() returns a 400 with errors", async () => {
|
test("invalid() returns a 400 with errors", async () => {
|
||||||
const res = invalid({ email: "bad" });
|
const res = invalid({ email: "bad" });
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
|
|||||||
Reference in New Issue
Block a user