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.
This commit is contained in:
Ayobami Adegoke
2026-07-19 19:38:48 +02:00
committed by GitHub
parent 3a184bc115
commit 70e0eb43ce
13 changed files with 228 additions and 13 deletions
@@ -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"), {
@@ -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",
});
});
});
@@ -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"), {
@@ -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"), {
@@ -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"), {
@@ -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"), {
@@ -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",
});
});
});
@@ -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"), {
@@ -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",
});
});
});
@@ -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"), {
@@ -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"), {
@@ -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"), {
@@ -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",
});
});
});