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:
@@ -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