fix(cli): reject negative and fractional filter flags in Danish portal CLIs (#281)

Follow-up to #191: it tightened page/limit/per-page, but five filter flags still used bare z.coerce.number() and accepted negative and fractional values that were sent raw to the portals (e.g. --jobage=-5, --radius=2.5).

jobindex --jobage, jobnet --radius, jobdanmark --category/--jobtitle-id and jobbank --company now use .int().min(1), mirroring #191. Adds 8 network-free regression tests (a negative and a fractional case per flag) using the same validation-error pattern as the existing cli-flag-validation suites.
This commit is contained in:
Oscar Madera
2026-08-05 06:27:43 +02:00
committed by GitHub
parent fcefb8150f
commit eef9c47461
8 changed files with 64 additions and 9 deletions
@@ -30,7 +30,7 @@ export const search = defineCommand({
"suitable-for": option(z.union([z.string(), z.array(z.string())]).optional(), { "suitable-for": option(z.union([z.string(), z.array(z.string())]).optional(), {
description: "Suitable-for code (andet). Repeatable.", description: "Suitable-for code (andet). Repeatable.",
}), }),
company: option(z.coerce.number().optional(), { company: option(z.coerce.number().int().min(1).optional(), {
description: "Company ID (virk)", description: "Company ID (virk)",
}), }),
remote: option(z.string().optional(), { remote: option(z.string().optional(), {
@@ -4,7 +4,9 @@ import { runCLI } from "./helpers";
// All cases fail schema validation (or the required-flag guard) before any // All cases fail schema validation (or the required-flag guard) before any
// network request, so the suite is network-free. Regression context: a bare // network request, so the suite is network-free. Regression context: a bare
// z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently // z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently
// dropped the last result instead of erroring. // dropped the last result instead of erroring. The --company filter flag
// also accepted negative and fractional values that were sent raw to the
// portal.
function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) { function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) {
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
@@ -33,6 +35,18 @@ describe("Jobbank CLI flag validation", () => {
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer"); expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
}); });
test("search --company=-1 is rejected", async () => {
const result = await runCLI(["search", "--key", "test", "--company=-1"]);
expectValidationError(result, "company");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("search --company=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--key", "test", "--company=1.5"]);
expectValidationError(result, "company");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("valid --limit passes schema validation (proven offline via the required-filter guard)", async () => { test("valid --limit passes schema validation (proven offline via the required-filter guard)", async () => {
const result = await runCLI(["search", "--limit=5"]); const result = await runCLI(["search", "--limit=5"]);
@@ -87,10 +87,10 @@ export const search = defineCommand({
text: option(z.string().optional(), { text: option(z.string().optional(), {
description: "Free-text keyword search (job title, keyword)", description: "Free-text keyword search (job title, keyword)",
}), }),
category: option(z.coerce.number().optional(), { category: option(z.coerce.number().int().min(1).optional(), {
description: "Category ID", description: "Category ID",
}), }),
"jobtitle-id": option(z.coerce.number().optional(), { "jobtitle-id": option(z.coerce.number().int().min(1).optional(), {
description: "Job title ID from autocomplete results", description: "Job title ID from autocomplete results",
}), }),
municipality: option(z.string().optional(), { municipality: option(z.string().optional(), {
@@ -4,7 +4,9 @@ import { runCLI } from "./helpers";
// All cases fail schema validation (or the required-flag guard) before any // All cases fail schema validation (or the required-flag guard) before any
// network request, so the suite is network-free. Regression context: a bare // network request, so the suite is network-free. Regression context: a bare
// z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently // z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently
// dropped the last result instead of erroring. // dropped the last result instead of erroring. Filter flags (--category,
// --jobtitle-id) also accepted negative and fractional values that were
// sent raw to the portal.
function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) { function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) {
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
@@ -27,6 +29,18 @@ describe("Jobdanmark CLI flag validation", () => {
expectValidationError(result, "page"); expectValidationError(result, "page");
}); });
test("search --category=-1 is rejected", async () => {
const result = await runCLI(["search", "--category=-1"]);
expectValidationError(result, "category");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("search --jobtitle-id=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--jobtitle-id=1.5"]);
expectValidationError(result, "jobtitle-id");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("search --limit=1.5 is rejected as non-integer", async () => { test("search --limit=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--limit=1.5"]); const result = await runCLI(["search", "--limit=1.5"]);
expectValidationError(result, "limit"); expectValidationError(result, "limit");
@@ -13,7 +13,7 @@ export const search = defineCommand({
page: option(z.coerce.number().int().min(1).default(1), { page: option(z.coerce.number().int().min(1).default(1), {
description: "Page number (1-indexed)", description: "Page number (1-indexed)",
}), }),
jobage: option(z.coerce.number().default(9999), { jobage: option(z.coerce.number().int().min(1).default(9999), {
description: "Max age of posting in days: 1, 7, 14, 30, or 9999 (all)", description: "Max age of posting in days: 1, 7, 14, 30, or 9999 (all)",
}), }),
sort: option(z.string().default("score"), { sort: option(z.string().default("score"), {
@@ -4,7 +4,8 @@ import { runCLI } from "./helpers";
// All cases fail schema validation (or the required-flag guard) before any // All cases fail schema validation (or the required-flag guard) before any
// network request, so the suite is network-free. Regression context: a bare // network request, so the suite is network-free. Regression context: a bare
// z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently // z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently
// dropped the last result instead of erroring. // dropped the last result instead of erroring. Filter flags (--jobage) also
// accepted negative and fractional values that were sent raw to the portal.
function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) { function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) {
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
@@ -38,6 +39,18 @@ describe("Jobindex CLI flag validation", () => {
expectValidationError(result, "page"); expectValidationError(result, "page");
}); });
test("--jobage=-5 is rejected", async () => {
const result = await runCLI(["search", "--query", "test", "--jobage=-5"]);
expectValidationError(result, "jobage");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("--jobage=1.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--query", "test", "--jobage=1.5"]);
expectValidationError(result, "jobage");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("valid numeric flags pass schema validation (proven offline via the required-flag guard)", async () => { test("valid numeric flags pass schema validation (proven offline via the required-flag guard)", async () => {
const result = await runCLI(["search", "--page=2", "--limit=5"]); const result = await runCLI(["search", "--page=2", "--limit=5"]);
@@ -155,7 +155,7 @@ export const search = defineCommand({
"postal-code": option(z.string().optional(), { "postal-code": option(z.string().optional(), {
description: "Postal code for radius search", description: "Postal code for radius search",
}), }),
radius: option(z.coerce.number().default(50), { radius: option(z.coerce.number().int().min(1).default(50), {
description: "Radius in km from postal code", description: "Radius in km from postal code",
}), }),
"occupation-area": option(z.string().optional(), { "occupation-area": option(z.string().optional(), {
@@ -4,7 +4,9 @@ import { runCLI } from "./helpers";
// All cases fail schema validation (or the required-flag guard) before any // All cases fail schema validation (or the required-flag guard) before any
// network request, so the suite is network-free. Regression context: a bare // network request, so the suite is network-free. Regression context: a bare
// z.coerce.number() accepted --limit=-1 / --per-page=-1, and slice(0, -1) // z.coerce.number() accepted --limit=-1 / --per-page=-1, and slice(0, -1)
// then silently dropped the last result instead of erroring. // then silently dropped the last result instead of erroring. The --radius
// filter flag also accepted negative and fractional values that were sent
// raw to the portal.
function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) { function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) {
expect(result.exitCode).toBe(1); expect(result.exitCode).toBe(1);
@@ -38,6 +40,18 @@ describe("Jobnet CLI flag validation", () => {
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer"); expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
}); });
test("search --radius=-10 is rejected", async () => {
const result = await runCLI(["search", "--radius=-10"]);
expectValidationError(result, "radius");
expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1");
});
test("search --radius=2.5 is rejected as non-integer", async () => {
const result = await runCLI(["search", "--radius=2.5"]);
expectValidationError(result, "radius");
expect(JSON.parse(result.stderr).error.message).toContain("Expected integer");
});
test("occupations --per-page=-1 is rejected", async () => { test("occupations --per-page=-1 is rejected", async () => {
const result = await runCLI(["occupations", "--per-page=-1"]); const result = await runCLI(["occupations", "--per-page=-1"]);
expectValidationError(result, "per-page"); expectValidationError(result, "per-page");