From 70e0eb43ce1de2dfd47865a5412d53987028e8b0 Mon Sep 17 00:00:00 2001 From: Ayobami Adegoke Date: Sun, 19 Jul 2026 18:38:48 +0100 Subject: [PATCH] fix(cli): reject negative and fractional count/pagination flags in Danish portal CLIs (#191) Bare z.coerce.number() accepted negative and fractional values for count/pagination flags, and slice(0, limit) with a negative limit silently dropped trailing results instead of erroring. Tightens the schemas to .int().min(1) across all four Danish portal CLIs (including jobnet occupations --per-page) with network-free validation tests. By @ayobamiseun. --- .../jobbank-search/cli/src/commands/search.ts | 2 +- .../cli/tests/cli-flag-validation.test.ts | 45 ++++++++++++++ .../cli/src/commands/autocomplete.ts | 2 +- .../cli/src/commands/categories.ts | 2 +- .../cli/src/commands/locations.ts | 2 +- .../cli/src/commands/search.ts | 4 +- .../cli/tests/cli-flag-validation.test.ts | 60 +++++++++++++++++++ .../cli/src/commands/search.ts | 4 +- .../cli/tests/cli-flag-validation.test.ts | 50 ++++++++++++++++ .../cli/src/commands/occupations.ts | 2 +- .../jobnet-search/cli/src/commands/search.ts | 6 +- .../cli/src/commands/suggestions.ts | 2 +- .../cli/tests/cli-flag-validation.test.ts | 60 +++++++++++++++++++ 13 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 .agents/skills/jobbank-search/cli/tests/cli-flag-validation.test.ts create mode 100644 .agents/skills/jobdanmark-search/cli/tests/cli-flag-validation.test.ts create mode 100644 .agents/skills/jobindex-search/cli/tests/cli-flag-validation.test.ts create mode 100644 .agents/skills/jobnet-search/cli/tests/cli-flag-validation.test.ts diff --git a/.agents/skills/jobbank-search/cli/src/commands/search.ts b/.agents/skills/jobbank-search/cli/src/commands/search.ts index fdd3ec1..f6b7ed1 100644 --- a/.agents/skills/jobbank-search/cli/src/commands/search.ts +++ b/.agents/skills/jobbank-search/cli/src/commands/search.ts @@ -39,7 +39,7 @@ export const search = defineCommand({ since: option(z.string().optional(), { description: "Posted on or after date, format YYYY-MM-DD (oprettet)", }), - limit: option(z.coerce.number().optional(), { + limit: option(z.coerce.number().int().min(1).optional(), { description: "Cap total results returned by CLI (client-side)", }), format: option(z.enum(["json", "table", "plain"]).default("json"), { diff --git a/.agents/skills/jobbank-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/jobbank-search/cli/tests/cli-flag-validation.test.ts new file mode 100644 index 0000000..30b68b6 --- /dev/null +++ b/.agents/skills/jobbank-search/cli/tests/cli-flag-validation.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { runCLI } from "./helpers"; + +// All cases fail schema validation (or the required-flag guard) before any +// network request, so the suite is network-free. Regression context: a bare +// z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently +// dropped the last result instead of erroring. + +function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) { + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const error = JSON.parse(result.stderr); + expect(error.ok).toBe(false); + expect(error.error.kind).toBe("validation"); + expect(error.error.option).toBe(option); +} + +describe("Jobbank CLI flag validation", () => { + test("search --limit=-1 is rejected instead of silently dropping the last result", async () => { + const result = await runCLI(["search", "--key", "test", "--limit=-1"]); + expectValidationError(result, "limit"); + expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1"); + }); + + test("search --limit=0 is rejected", async () => { + const result = await runCLI(["search", "--key", "test", "--limit=0"]); + expectValidationError(result, "limit"); + }); + + test("search --limit=1.5 is rejected as non-integer", async () => { + const result = await runCLI(["search", "--key", "test", "--limit=1.5"]); + expectValidationError(result, "limit"); + expect(JSON.parse(result.stderr).error.message).toContain("Expected integer"); + }); + + test("valid --limit passes schema validation (proven offline via the required-filter guard)", async () => { + const result = await runCLI(["search", "--limit=5"]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stderr)).toEqual({ + error: "--key or at least one filter is required", + code: "MISSING_REQUIRED", + }); + }); +}); diff --git a/.agents/skills/jobdanmark-search/cli/src/commands/autocomplete.ts b/.agents/skills/jobdanmark-search/cli/src/commands/autocomplete.ts index 698f78c..156f66a 100644 --- a/.agents/skills/jobdanmark-search/cli/src/commands/autocomplete.ts +++ b/.agents/skills/jobdanmark-search/cli/src/commands/autocomplete.ts @@ -22,7 +22,7 @@ export const autocomplete = defineCommand({ query: option(z.string().optional(), { description: "Search text to autocomplete (required)", }), - limit: option(z.coerce.number().optional(), { + limit: option(z.coerce.number().int().min(1).optional(), { description: "Cap total suggestions returned", }), format: option(z.enum(["json", "table", "plain"]).default("json"), { diff --git a/.agents/skills/jobdanmark-search/cli/src/commands/categories.ts b/.agents/skills/jobdanmark-search/cli/src/commands/categories.ts index f91fb9c..c90efc1 100644 --- a/.agents/skills/jobdanmark-search/cli/src/commands/categories.ts +++ b/.agents/skills/jobdanmark-search/cli/src/commands/categories.ts @@ -13,7 +13,7 @@ export const categories = defineCommand({ name: "categories", description: "List all job categories with live counts", options: { - limit: option(z.coerce.number().optional(), { + limit: option(z.coerce.number().int().min(1).optional(), { description: "Cap number of categories returned", }), format: option(z.enum(["json", "table", "plain"]).default("json"), { diff --git a/.agents/skills/jobdanmark-search/cli/src/commands/locations.ts b/.agents/skills/jobdanmark-search/cli/src/commands/locations.ts index b671965..d336c30 100644 --- a/.agents/skills/jobdanmark-search/cli/src/commands/locations.ts +++ b/.agents/skills/jobdanmark-search/cli/src/commands/locations.ts @@ -22,7 +22,7 @@ export const locations = defineCommand({ query: option(z.string().optional(), { description: "Location text to search (city, zip code, region) (required)", }), - limit: option(z.coerce.number().optional(), { + limit: option(z.coerce.number().int().min(1).optional(), { description: "Cap total suggestions returned", }), format: option(z.enum(["json", "table", "plain"]).default("json"), { diff --git a/.agents/skills/jobdanmark-search/cli/src/commands/search.ts b/.agents/skills/jobdanmark-search/cli/src/commands/search.ts index 88df0be..7cec7f4 100644 --- a/.agents/skills/jobdanmark-search/cli/src/commands/search.ts +++ b/.agents/skills/jobdanmark-search/cli/src/commands/search.ts @@ -105,10 +105,10 @@ export const search = defineCommand({ "job-type": option(z.string().optional(), { description: "Comma-separated job types: fuldtid,deltid,fleksjob,elev,studiejob,praktik", }), - page: option(z.coerce.number().default(1), { + page: option(z.coerce.number().int().min(1).default(1), { description: "Page number (30 items per page, server-enforced)", }), - limit: option(z.coerce.number().optional(), { + limit: option(z.coerce.number().int().min(1).optional(), { description: "Cap total results returned by CLI (client-side)", }), format: option(z.enum(["json", "table", "plain"]).default("json"), { diff --git a/.agents/skills/jobdanmark-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/jobdanmark-search/cli/tests/cli-flag-validation.test.ts new file mode 100644 index 0000000..b3b8816 --- /dev/null +++ b/.agents/skills/jobdanmark-search/cli/tests/cli-flag-validation.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { runCLI } from "./helpers"; + +// All cases fail schema validation (or the required-flag guard) before any +// network request, so the suite is network-free. Regression context: a bare +// z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently +// dropped the last result instead of erroring. + +function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) { + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const error = JSON.parse(result.stderr); + expect(error.ok).toBe(false); + expect(error.error.kind).toBe("validation"); + expect(error.error.option).toBe(option); +} + +describe("Jobdanmark CLI flag validation", () => { + test("search --limit=-1 is rejected instead of silently dropping the last result", async () => { + const result = await runCLI(["search", "--limit=-1"]); + expectValidationError(result, "limit"); + expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1"); + }); + + test("search --page=0 is rejected on the 1-indexed portal", async () => { + const result = await runCLI(["search", "--page=0"]); + expectValidationError(result, "page"); + }); + + test("search --limit=1.5 is rejected as non-integer", async () => { + const result = await runCLI(["search", "--limit=1.5"]); + expectValidationError(result, "limit"); + expect(JSON.parse(result.stderr).error.message).toContain("Expected integer"); + }); + + test("categories --limit=-1 is rejected", async () => { + const result = await runCLI(["categories", "--limit=-1"]); + expectValidationError(result, "limit"); + }); + + test("locations --limit=-1 is rejected", async () => { + const result = await runCLI(["locations", "--query", "test", "--limit=-1"]); + expectValidationError(result, "limit"); + }); + + test("autocomplete --limit=-1 is rejected", async () => { + const result = await runCLI(["autocomplete", "--query", "test", "--limit=-1"]); + expectValidationError(result, "limit"); + }); + + test("valid numeric flags pass schema validation (proven offline via the required-flag guard)", async () => { + const result = await runCLI(["autocomplete", "--limit=3"]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stderr)).toEqual({ + error: "--query is required", + code: "MISSING_REQUIRED", + }); + }); +}); diff --git a/.agents/skills/jobindex-search/cli/src/commands/search.ts b/.agents/skills/jobindex-search/cli/src/commands/search.ts index 8929d1d..534bdbe 100644 --- a/.agents/skills/jobindex-search/cli/src/commands/search.ts +++ b/.agents/skills/jobindex-search/cli/src/commands/search.ts @@ -10,7 +10,7 @@ export const search = defineCommand({ short: "q", description: "Keyword search query (e.g. python, grafisk designer)", }), - page: option(z.coerce.number().default(1), { + page: option(z.coerce.number().int().min(1).default(1), { description: "Page number (1-indexed)", }), jobage: option(z.coerce.number().default(9999), { @@ -19,7 +19,7 @@ export const search = defineCommand({ sort: option(z.string().default("score"), { description: "Sort order: score (relevance) or date (newest first)", }), - limit: option(z.coerce.number().optional(), { + limit: option(z.coerce.number().int().min(1).optional(), { description: "Cap total results returned by the CLI (client-side)", }), format: option(z.enum(["json", "table", "plain"]).default("json"), { diff --git a/.agents/skills/jobindex-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/jobindex-search/cli/tests/cli-flag-validation.test.ts new file mode 100644 index 0000000..9d251fe --- /dev/null +++ b/.agents/skills/jobindex-search/cli/tests/cli-flag-validation.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test"; +import { runCLI } from "./helpers"; + +// All cases fail schema validation (or the required-flag guard) before any +// network request, so the suite is network-free. Regression context: a bare +// z.coerce.number() accepted --limit=-1, and slice(0, -1) then silently +// dropped the last result instead of erroring. + +function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) { + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const error = JSON.parse(result.stderr); + expect(error.ok).toBe(false); + expect(error.error.kind).toBe("validation"); + expect(error.error.option).toBe(option); +} + +describe("Jobindex CLI flag validation", () => { + test("--limit=-1 is rejected instead of silently dropping the last result", async () => { + const result = await runCLI(["search", "--query", "test", "--limit=-1"]); + expectValidationError(result, "limit"); + expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1"); + }); + + test("--limit=0 is rejected", async () => { + const result = await runCLI(["search", "--query", "test", "--limit=0"]); + expectValidationError(result, "limit"); + }); + + test("--limit=1.5 is rejected as non-integer", async () => { + const result = await runCLI(["search", "--query", "test", "--limit=1.5"]); + expectValidationError(result, "limit"); + expect(JSON.parse(result.stderr).error.message).toContain("Expected integer"); + }); + + test("--page=0 is rejected on the 1-indexed portal", async () => { + const result = await runCLI(["search", "--query", "test", "--page=0"]); + expectValidationError(result, "page"); + }); + + test("valid numeric flags pass schema validation (proven offline via the required-flag guard)", async () => { + const result = await runCLI(["search", "--page=2", "--limit=5"]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stderr)).toEqual({ + error: "--query is required", + code: "MISSING_REQUIRED", + }); + }); +}); diff --git a/.agents/skills/jobnet-search/cli/src/commands/occupations.ts b/.agents/skills/jobnet-search/cli/src/commands/occupations.ts index 7b16bf6..3f8676e 100644 --- a/.agents/skills/jobnet-search/cli/src/commands/occupations.ts +++ b/.agents/skills/jobnet-search/cli/src/commands/occupations.ts @@ -21,7 +21,7 @@ export const occupations = defineCommand({ "search-string": option(z.string().optional(), { description: "Search term for occupation, e.g. sygeplejerske", }), - "per-page": option(z.coerce.number().default(10), { + "per-page": option(z.coerce.number().int().min(1).default(10), { description: "Max results to return", }), format: option(z.enum(["json", "table", "plain"]).default("json"), { diff --git a/.agents/skills/jobnet-search/cli/src/commands/search.ts b/.agents/skills/jobnet-search/cli/src/commands/search.ts index 449f44a..36a072b 100644 --- a/.agents/skills/jobnet-search/cli/src/commands/search.ts +++ b/.agents/skills/jobnet-search/cli/src/commands/search.ts @@ -131,10 +131,10 @@ export const search = defineCommand({ "search-string": option(z.string().optional(), { description: "Free-text keyword search (job title, skills, employer)", }), - page: option(z.coerce.number().default(1), { + page: option(z.coerce.number().int().min(1).default(1), { description: "Page number (1-indexed)", }), - "per-page": option(z.coerce.number().default(10), { + "per-page": option(z.coerce.number().int().min(1).default(10), { description: "Results per page", }), order: option(z.string().default("PublicationDate"), { @@ -164,7 +164,7 @@ export const search = defineCommand({ "occupation-group": option(z.string().optional(), { description: "Occupation group identifier, e.g. 10060", }), - limit: option(z.coerce.number().optional(), { + limit: option(z.coerce.number().int().min(1).optional(), { description: "Cap total results returned by CLI", }), format: option(z.enum(["json", "table", "plain"]).default("json"), { diff --git a/.agents/skills/jobnet-search/cli/src/commands/suggestions.ts b/.agents/skills/jobnet-search/cli/src/commands/suggestions.ts index dd6c187..8c48a73 100644 --- a/.agents/skills/jobnet-search/cli/src/commands/suggestions.ts +++ b/.agents/skills/jobnet-search/cli/src/commands/suggestions.ts @@ -9,7 +9,7 @@ export const suggestions = defineCommand({ query: option(z.string().optional(), { description: "Partial search string to complete", }), - limit: option(z.coerce.number().optional(), { + limit: option(z.coerce.number().int().min(1).optional(), { description: "Cap number of suggestions returned", }), format: option(z.enum(["json", "table", "plain"]).default("json"), { diff --git a/.agents/skills/jobnet-search/cli/tests/cli-flag-validation.test.ts b/.agents/skills/jobnet-search/cli/tests/cli-flag-validation.test.ts new file mode 100644 index 0000000..6b2faad --- /dev/null +++ b/.agents/skills/jobnet-search/cli/tests/cli-flag-validation.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test"; +import { runCLI } from "./helpers"; + +// All cases fail schema validation (or the required-flag guard) before any +// 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) +// then silently dropped the last result instead of erroring. + +function expectValidationError(result: { exitCode: number; stdout: string; stderr: string }, option: string) { + expect(result.exitCode).toBe(1); + expect(result.stdout).toBe(""); + const error = JSON.parse(result.stderr); + expect(error.ok).toBe(false); + expect(error.error.kind).toBe("validation"); + expect(error.error.option).toBe(option); +} + +describe("Jobnet CLI flag validation", () => { + test("search --limit=-1 is rejected instead of silently dropping the last result", async () => { + const result = await runCLI(["search", "--limit=-1"]); + expectValidationError(result, "limit"); + expect(JSON.parse(result.stderr).error.message).toContain("greater than or equal to 1"); + }); + + test("search --page=0 is rejected on the 1-indexed API", async () => { + const result = await runCLI(["search", "--page=0"]); + expectValidationError(result, "page"); + }); + + test("search --per-page=-5 is rejected", async () => { + const result = await runCLI(["search", "--per-page=-5"]); + expectValidationError(result, "per-page"); + }); + + test("search --limit=1.5 is rejected as non-integer", async () => { + const result = await runCLI(["search", "--limit=1.5"]); + expectValidationError(result, "limit"); + expect(JSON.parse(result.stderr).error.message).toContain("Expected integer"); + }); + + test("occupations --per-page=-1 is rejected", async () => { + const result = await runCLI(["occupations", "--per-page=-1"]); + expectValidationError(result, "per-page"); + }); + + test("suggestions --limit=-1 is rejected", async () => { + const result = await runCLI(["suggestions", "--limit=-1"]); + expectValidationError(result, "limit"); + }); + + test("valid numeric flags pass schema validation (proven offline via the required-flag guard)", async () => { + const result = await runCLI(["occupations", "--per-page=5"]); + + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stderr)).toEqual({ + error: "--search-string is required", + code: "MISSING_REQUIRED", + }); + }); +});