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
|
||||
|
||||
- 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
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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 () => {
|
||||
const res = invalid({ email: "bad" });
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
Reference in New Issue
Block a user