fix(freehire-search): reject fractional numeric flags instead of silently truncating (#373) (#374)

parseIntFlag used bare parseInt, so --jobage 0.5 truncated to 0, failed
the jobage > 0 guard in search.ts, and posted_within_days was silently
omitted from the outbound request while the CLI exited 0. Numeric flags
now accept whole numbers >= 1 only, mirroring the Danish CLIs'
z.coerce.number().int().min(1) contract, and reject everything else with
the stderr-JSON BAD_ARG error. Five new validation cases, each verified
to fail on the unfixed code.
This commit is contained in:
Ayobami Adegoke
2026-08-27 19:04:59 +02:00
committed by GitHub
parent 75c15eeecc
commit 79cd383e58
3 changed files with 50 additions and 3 deletions
+10 -3
View File
@@ -112,9 +112,16 @@ best-effort, no SLA. Override with FREEHIRE_API_URL to use a self-hosted backend
`
function parseIntFlag(name: string, raw: string | boolean | string[]): number | null {
const val = parseInt(raw as string, 10)
if (isNaN(val)) {
process.stderr.write(JSON.stringify({ error: `--${name} must be a number, got "${raw}"`, code: "BAD_ARG" }) + "\n")
// Number(), not parseInt(): parseInt truncates, so "--jobage 0.5" became 0,
// which fails search.ts's `jobage > 0` guard and silently drops
// posted_within_days from the outbound request while exiting 0 (#373).
// Whole numbers >= 1 only — the Danish CLIs' z.coerce.number().int().min(1)
// contract; 0 is rejected rather than kept as a "no filter" alias.
const val = typeof raw === "string" ? Number(raw.trim()) : NaN
if (!Number.isInteger(val) || val < 1) {
process.stderr.write(
JSON.stringify({ error: `--${name} must be a whole number of at least 1, got "${raw}"`, code: "BAD_ARG" }) + "\n",
)
return null
}
return val
@@ -25,6 +25,32 @@ describe("freehire CLI flag validation", () => {
});
}
// Fractional values must be rejected, not truncated: parseInt("0.5") is 0,
// and jobage 0 fails search.ts's `> 0` guard, so posted_within_days is
// silently omitted from the outbound request while the CLI exits 0 —
// the discarded-filter failure the UNKNOWN_FLAG guard exists to prevent (#373).
for (const name of ["jobage", "page", "limit"]) {
test(`--${name} fractional exits 1 with BAD_ARG instead of truncating`, async () => {
const result = await runCLI(["search", `--${name}`, "1.5"]);
expect(result.exitCode).not.toBe(0);
const err = parsedStderr(result.stderr);
expect(err.code).toBe("BAD_ARG");
expect(err.error).toMatch(new RegExp(name));
});
}
test("--jobage 0.5 (truncates to 0 on master, dropping the freshness filter) exits 1 with BAD_ARG", async () => {
const result = await runCLI(["search", "--jobage", "0.5"]);
expect(result.exitCode).not.toBe(0);
expect(parsedStderr(result.stderr).code).toBe("BAD_ARG");
});
test("--jobage 0 exits 1 with BAD_ARG (0 silently disables the filter, like the Danish CLIs' min(1))", async () => {
const result = await runCLI(["search", "--jobage", "0"]);
expect(result.exitCode).not.toBe(0);
expect(parsedStderr(result.stderr).code).toBe("BAD_ARG");
});
test("valid integers produce no BAD_ARG", async () => {
const result = await runCLI(["search", "--jobage", "7", "--page", "1", "--limit", "1"]);
expect(parsedStderr(result.stderr).code).not.toBe("BAD_ARG");