fix(validation): stop unrecognised boolean strings coercing to a silent false
Quality / quality (ubuntu-latest) (push) Failing after 22s
Quality / quality (windows-latest) (push) Canceled after 0s

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:
2026-08-19 21:26:20 +05:30
co-authored by Claude Opus 5
parent 3ae5d7cf97
commit 281615a4b0
4 changed files with 193 additions and 2 deletions
+22 -1
View File
@@ -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" };
}
+22 -1
View File
@@ -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;
+134
View File
@@ -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);