From f6063fd8a136cc2fbedf8f6c215765a8bdab3b82 Mon Sep 17 00:00:00 2001 From: Rudy Mizrahi Celekli <47457359+rudycelekli@users.noreply.github.com> Date: Mon, 19 Oct 2026 13:59:10 -0400 Subject: [PATCH] fix(jobindex-search): reject a --sort other than score or date instead of sending it to the portal (#647) * fix(jobindex-search): reject a --sort other than score or date instead of sending it to the portal --sort was a free z.string(), and jobindex does not reject an unknown sort: --sort newest came back exit 0 in an order that is neither relevance nor date, so the newest-first sort a caller asked for was silently not applied. Make it an enum of the two documented values, like --format, so any other value fails schema validation before a request is sent. Co-Authored-By: Claude Opus 5.5 * changelog: place the #647 entry under [Unreleased] Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Mads Lorentzen --- .../cli/src/commands/search.ts | 4 +- .../cli/tests/sort-values.test.ts | 76 +++++++++++++++++++ CHANGELOG.md | 7 ++ 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/jobindex-search/cli/tests/sort-values.test.ts diff --git a/.agents/skills/jobindex-search/cli/src/commands/search.ts b/.agents/skills/jobindex-search/cli/src/commands/search.ts index 4e014c6d..b0f842e8 100644 --- a/.agents/skills/jobindex-search/cli/src/commands/search.ts +++ b/.agents/skills/jobindex-search/cli/src/commands/search.ts @@ -16,7 +16,9 @@ export const search = defineCommand({ jobage: option(z.coerce.number().int().min(1).default(9999), { description: "Max age of posting in days: 1, 7, 14, 30, or 9999 (all)", }), - sort: option(z.string().default("score"), { + // An enum, not a free string: jobindex answers an unknown sort with an + // unrelated order and no error, so a typo silently lost the date sort. + sort: option(z.enum(["score", "date"]).default("score"), { description: "Sort order: score (relevance) or date (newest first)", }), limit: option(z.coerce.number().int().min(1).optional(), { diff --git a/.agents/skills/jobindex-search/cli/tests/sort-values.test.ts b/.agents/skills/jobindex-search/cli/tests/sort-values.test.ts new file mode 100644 index 00000000..e56a86c6 --- /dev/null +++ b/.agents/skills/jobindex-search/cli/tests/sort-values.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// --sort was a free z.string(), so any value went to jobindex verbatim. The +// portal does not reject an unknown sort: `--sort newest` came back exit 0 in +// an order that is neither relevance nor date (checked live 2026-10-18), with +// nothing telling the caller the documented `date` sort was not applied. +// The CLI runs against a loopback server so each outbound request is counted. +async function search(args: string[]) { + const requests: URL[] = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + requests.push(new URL(request.url)); + return new Response(''); + }, + }); + const dir = mkdtempSync(join(tmpdir(), "jobindex-sort-")); + const preload = join(dir, "fetch.ts"); + writeFileSync(preload, ` + const originalFetch = globalThis.fetch; + globalThis.fetch = (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.protocol === "data:") return originalFetch(input, init); + if (url.origin !== "https://www.jobindex.dk" || url.pathname !== "/jobsoegning") { + throw new Error("Unexpected search endpoint"); + } + return originalFetch(${JSON.stringify(server.url.origin)} + url.pathname + url.search, init); + }; + `); + try { + const proc = Bun.spawn([ + process.execPath, "run", "--preload", preload, + join(import.meta.dir, "../src/cli.ts"), "search", ...args, + ], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, + ]); + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode, requests }; + } finally { + server.stop(true); + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("--sort values", () => { + for (const value of ["newest", "Date", "relevance"]) { + test(`--sort ${value} is rejected before any request`, async () => { + const result = await search(["--query", "python", "--sort", value]); + 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("sort"); + expect(result.requests).toHaveLength(0); + }); + } + + for (const [args, expected] of [ + [["--sort", "date"], "date"], + [["--sort", "score"], "score"], + [[], "score"], + ] as const) { + test(`${args.join(" ") || "no --sort"} sends sort=${expected}`, async () => { + const result = await search(["--query", "python", ...args]); + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.requests).toHaveLength(1); + expect(result.requests[0].searchParams.get("sort")).toBe(expected); + }); + } +}); diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d3bf66a..5efea887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,13 @@ per-file diff commands. the flag is still a location-only search. The Danish portals (#573) and `freehire-search` (#568) already reject a value-less option. +- **`jobindex-search` rejects a `--sort` other than `score` or `date` instead of sending it to the + portal** (#647, `.agents/skills/jobindex-search/cli/src/commands/search.ts`, + `tests/sort-values.test.ts`) - the option was a free string, and jobindex does not reject an + unknown sort: `--sort newest` returned exit 0 in an order that was neither relevance nor date, + so the requested newest-first sort was silently not applied. The option is now an enum like + `--format`, and any other value exits 1 with the CLI's validation error before any request. + ## [1.7.3] - 2026-10-19 ### Added