test(portal-clis): isolate valid flag controls from live services (#581)

* test(portal-clis): isolate valid flag controls from live services

The LinkedIn and Freehire positive controls spawned live search CLIs and only checked for the absence of BAD_ARG, so attempted portal requests still left a green suite. Use loopback responses and assert successful output plus the constructed freshness, pagination and result-limit contract.

LinkedIn cards are verbatim excerpts from portapulito's actual captured response in PR #153; fixture provenance and original hashes are retained. Four Danish validation suites already stop at required-argument guards and need no change. This isolates these controls rather than claiming a whole-CI egress policy.

* docs(changelog): record fix for #581
This commit is contained in:
Rudy Mizrahi Celekli authored and GitHub committed 2026-10-18 06:57:43 +02:00
1 parent 23876bfa95
commit 2017521d2e
6 files changed
+104 -16

No files matched your search

@@ -1,9 +1,8 @@
import { describe, test, expect } from "bun:test"; import { describe, test, expect } from "bun:test";
import { runCLI } from "./helpers"; import { runCLI } from "./helpers";
// These assert on validation error codes that are emitted BEFORE any network // Invalid flags fail before any request. Valid-flag controls run against a
// call (or independently of it), so the suite is network-free: a valid-flag case // loopback fixture and assert the requested filters, not just an absent error.
// still runs offline because it only checks the ABSENCE of a validation error.
function parsedStderr(stderr: string): { error?: string; code?: string } { function parsedStderr(stderr: string): { error?: string; code?: string } {
try { try {
@@ -51,9 +50,30 @@ describe("freehire CLI flag validation", () => {
expect(parsedStderr(result.stderr).code).toBe("BAD_ARG"); expect(parsedStderr(result.stderr).code).toBe("BAD_ARG");
}); });
test("valid integers produce no BAD_ARG", async () => { test("valid integers construct the requested search offline", async () => {
const result = await runCLI(["search", "--jobage", "7", "--page", "1", "--limit", "1"]); const requests: URL[] = [];
expect(parsedStderr(result.stderr).code).not.toBe("BAD_ARG"); const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
requests.push(new URL(request.url));
return Response.json({ data: [], meta: { total: 0 } });
},
});
try {
const result = await runCLI(["search", "--jobage", "7", "--page", "1", "--limit", "1"],
{ FREEHIRE_API_URL: server.url.toString() });
expect(result.exitCode).toBe(0);
expect(result.stderr).toBe("");
expect(requests).toHaveLength(1);
expect(requests[0].pathname).toBe("/api/v1/agent/jobs/search");
expect(requests[0].searchParams.get("posted_within_days")).toBe("7");
expect(requests[0].searchParams.get("limit")).toBe("1");
expect(requests[0].searchParams.get("offset")).toBe("0");
expect(JSON.parse(result.stdout)).toEqual({ meta: { count: 0, page: 1, total: 0 }, results: [] });
} finally {
server.stop(true);
}
}); });
}); });
@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test"; import { describe, test, expect } from "bun:test";
import { runCLI } from "./helpers"; import { runCLI, runSearchFixture } from "./helpers";
const LOCATION = "Copenhagen, Denmark"; const LOCATION = "Copenhagen, Denmark";
@@ -28,9 +28,15 @@ describe("LinkedIn CLI flag validation", () => {
}); });
test("valid integer passes validation", async () => { test("valid integer passes validation", async () => {
const result = await runCLI(["search", "-l", LOCATION, "--jobage", "7", "--limit", "1"]); const { result, requests } = await runSearchFixture(["search", "-l", LOCATION, "--jobage", "7", "--limit", "1"]);
const err = parsedStderr(result.stderr); expect(result.exitCode).toBe(0);
expect(err.code).not.toBe("BAD_ARG"); expect(result.stderr).toBe("");
expect(requests).toHaveLength(1);
expect(requests[0].searchParams.get("location")).toBe(LOCATION);
expect(requests[0].searchParams.get("f_TPR")).toBe("r604800");
const output = JSON.parse(result.stdout);
expect(output.meta.count).toBe(1);
expect(output.results.map((job: { id: string }) => job.id)).toEqual(["4425498329"]);
}); });
// Fractional values must be rejected, not truncated: parseInt("0.5") is 0, // Fractional values must be rejected, not truncated: parseInt("0.5") is 0,
@@ -126,12 +132,18 @@ describe("LinkedIn CLI flag validation", () => {
expect(err.code).toBe("NO_LOCATION"); expect(err.code).toBe("NO_LOCATION");
}); });
test("all valid flags produce no BAD_ARG", async () => { test("all valid flags construct the requested search offline", async () => {
const result = await runCLI([ const { result, requests } = await runSearchFixture([
"search", "-l", LOCATION, "--jobage", "7", "--page", "1", "--limit", "5", "search", "-l", LOCATION, "--jobage", "7", "--page", "1", "--limit", "5",
]); ]);
const err = parsedStderr(result.stderr); expect(result.exitCode).toBe(0);
expect(err.code).not.toBe("BAD_ARG"); expect(result.stderr).toBe("");
expect(requests).toHaveLength(1);
expect(requests[0].searchParams.get("f_TPR")).toBe("r604800");
expect(requests[0].searchParams.get("start")).toBe("0");
const output = JSON.parse(result.stdout);
expect(output.meta).toEqual({ count: 2, page: 1 });
expect(output.results.map((job: { id: string }) => job.id)).toEqual(["4425498329", "4425494976"]);
}); });
}); });
@@ -0,0 +1 @@
"<li>\n \n \n\n \n \n \n <div class=\"base-card relative w-full hover:no-underline focus:no-underline\n base-card--link\n base-search-card base-search-card--link job-search-card\" data-entity-urn=\"urn:li:jobPosting:4425498329\" data-impression-id=\"jobs-search-result-0\" data-reference-id=\"rgFs/22WxICKVQ1aeDSZxA==\" data-tracking-id=\"xlkXMyr4ByKPoDagDz77pg==\" data-column=\"1\" data-row=\"1\">\n \n\n <a class=\"base-card__full-link absolute top-0 right-0 bottom-0 left-0 p-0 z-[2] outline-offset-[4px]\" href=\"https://it.linkedin.com/jobs/view/artificial-intelligence-engineer-at-humans-tech-4425498329?position=1&amp;pageNum=0&amp;refId=rgFs%2F22WxICKVQ1aeDSZxA%3D%3D&amp;trackingId=xlkXMyr4ByKPoDagDz77pg%3D%3D\" data-tracking-control-name=\"public_jobs_jserp-result_search-card\" data-tracking-client-ingraph data-tracking-will-navigate>\n \n <span class=\"sr-only\">\n \n \n Artificial Intelligence Engineer\n \n \n </span>\n </a>\n\n \n \n <div class=\"search-entity-media\">\n \n <img class=\"artdeco-entity-image artdeco-entity-image--square-4\n \" data-delayed-url=\"https://media.licdn.com/dms/image/v2/D4D0BAQGzAG6Xx3nmcA/company-logo_100_100/company-logo_100_100/0/1703243057096/wearekromin_logo?e=2147483647&amp;v=beta&amp;t=zaP3tbQvW4WbshE93SaJi-v-NuDmtSlfvCz7lr6xXCE\" data-ghost-classes=\"artdeco-entity-image--ghost\" data-ghost-url=\"https://static.licdn.com/aero-v1/sc/h/6puxblwmhnodu6fjircz4dn4h\" alt>\n \n </div>\n \n\n <div class=\"base-search-card__info\">\n <h3 class=\"base-search-card__title\">\n \n Artificial Intelligence Engineer\n \n </h3>\n\n <h4 class=\"base-search-card__subtitle\">\n \n <a class=\"hidden-nested-link\" data-tracking-client-ingraph data-tracking-control-name=\"public_jobs_jserp-result_job-search-card-subtitle\" data-tracking-will-navigate href=\"https://it.linkedin.com/company/wearehumanstech?trk=public_jobs_jserp-result_job-search-card-subtitle\">\n Humans.tech\n </a>\n \n </h4>\n\n<!---->\n <div class=\"base-search-card__metadata\">\n \n <span class=\"job-search-card__location\">\n Italy\n </span>\n\n \n \n \n \n \n\n <div class=\"job-posting-benefits text-sm\">\n <icon class=\"job-posting-benefits__icon\" data-delayed-url=\"https://static.licdn.com/aero-v1/sc/h/3p1v0uhy7uq0cm5zdvzp4eo18\" data-svg-class-name=\"job-posting-benefits__icon-svg\"></icon>\n <span class=\"job-posting-benefits__text\">\n Actively Hiring\n<!----> </span>\n </div>\n \n\n <time class=\"job-search-card__listdate\" datetime=\"2026-07-10\">\n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\n 4 days ago\n \n </time>\n\n<!----> \n </div>\n </div>\n<!----> \n \n </div>\n \n \n \n \n </li>\n<li>\n \n \n\n \n \n \n <div class=\"base-card relative w-full hover:no-underline focus:no-underline\n base-card--link\n base-search-card base-search-card--link job-search-card\" data-entity-urn=\"urn:li:jobPosting:4425494976\" data-impression-id=\"jobs-search-result-1\" data-reference-id=\"rgFs/22WxICKVQ1aeDSZxA==\" data-tracking-id=\"qBQxhsuHhbnEPiON5x8+yQ==\" data-column=\"1\" data-row=\"2\">\n \n\n <a class=\"base-card__full-link absolute top-0 right-0 bottom-0 left-0 p-0 z-[2] outline-offset-[4px]\" href=\"https://it.linkedin.com/jobs/view/machine-learning-engineer-at-humans-tech-4425494976?position=2&amp;pageNum=0&amp;refId=rgFs%2F22WxICKVQ1aeDSZxA%3D%3D&amp;trackingId=qBQxhsuHhbnEPiON5x8%2ByQ%3D%3D\" data-tracking-control-name=\"public_jobs_jserp-result_search-card\" data-tracking-client-ingraph data-tracking-will-navigate>\n \n <span class=\"sr-only\">\n \n \n Machine Learning Engineer\n \n \n </span>\n </a>\n\n \n \n <div class=\"search-entity-media\">\n \n <img class=\"artdeco-entity-image artdeco-entity-image--square-4\n \" data-delayed-url=\"https://media.licdn.com/dms/image/v2/D4D0BAQGzAG6Xx3nmcA/company-logo_100_100/company-logo_100_100/0/1703243057096/wearekromin_logo?e=2147483647&amp;v=beta&amp;t=zaP3tbQvW4WbshE93SaJi-v-NuDmtSlfvCz7lr6xXCE\" data-ghost-classes=\"artdeco-entity-image--ghost\" data-ghost-url=\"https://static.licdn.com/aero-v1/sc/h/6puxblwmhnodu6fjircz4dn4h\" alt>\n \n </div>\n \n\n <div class=\"base-search-card__info\">\n <h3 class=\"base-search-card__title\">\n \n Machine Learning Engineer\n Line truncated
@@ -0,0 +1,20 @@
# Offline search cards
`search-cards.json` stores the HTML string for the first two verbatim `<li>` cards from a real
LinkedIn guest search response captured by **portapulito** through the CLI's
`htmlFetch` on 2026-07-14 and published in [PR #153](https://github.com/MadsLorentzen/ai-job-search/pull/153).
That PR was closed as an accidental personalized-fork submission, not merged.
Source: `.agents/skills/linkedin-search/cli/test/__fixtures__/search-list.html`
at commit `5bc3afb94d19b670ecf09287c20d0c95e5d13ba0`.
Original response SHA-256: `ff5fb9009f26c8f602b1ac4b018f471e76784300723b22db64d9d19c2b7f557c`.
Two-card excerpt SHA-256: `1ab95e6ccb38b50d922e4d714ff95bb58219c012489a3a94aca5e0adb7b552fb`.
The JSON string encoding preserves the original HTML whitespace without adding
trailing whitespace to source lines. The excerpt hash above is of the decoded HTML.
The valid-flag controls serve these saved cards over loopback HTTP. Their child
CLI preload accepts only the expected search endpoint and redirects that request
to loopback. Tests assert the actual URL parameters, successful output, IDs and
client-side result limits. No fixture regeneration or portal request occurs.
This isolates these controls; it is not an OS-level egress policy for the entire CI.
@@ -1,4 +1,6 @@
import { join } from "path"; import { join } from "path";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
const CLI_PATH = join(import.meta.dir, "../src/cli.ts"); const CLI_PATH = join(import.meta.dir, "../src/cli.ts");
@@ -8,8 +10,8 @@ export interface CLIResult {
exitCode: number; exitCode: number;
} }
export async function runCLI(args: string[]): Promise<CLIResult> { export async function runCLI(args: string[], preload?: string): Promise<CLIResult> {
const proc = Bun.spawn(["bun", "run", CLI_PATH, ...args], { const proc = Bun.spawn(["bun", "run", ...(preload ? ["--preload", preload] : []), CLI_PATH, ...args], {
stdout: "pipe", stdout: "pipe",
stderr: "pipe", stderr: "pipe",
}); });
@@ -23,6 +25,37 @@ export async function runCLI(args: string[]): Promise<CLIResult> {
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
} }
/** Run valid search controls through the real CLI with a saved, offline response. */
export async function runSearchFixture(args: string[]): Promise<{ result: CLIResult; requests: URL[] }> {
const requests: URL[] = [];
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
requests.push(new URL(request.url));
return new Response(await Bun.file(join(import.meta.dir, "fixtures/search-cards.json")).json());
},
});
const dir = mkdtempSync(join(tmpdir(), "linkedin-valid-flags-"));
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.origin !== "https://www.linkedin.com" || url.pathname !== "/jobs-guest/jobs/api/seeMoreJobPostings/search") {
throw new Error("Unexpected search fixture request");
}
return originalFetch(${JSON.stringify(server.url.origin)} + url.pathname + url.search, init);
};
`);
try {
return { result: await runCLI(args, preload), requests };
} finally {
server.stop(true);
rmSync(dir, { recursive: true, force: true });
}
}
export function parseJSON<T = unknown>(result: CLIResult): T { export function parseJSON<T = unknown>(result: CLIResult): T {
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Error( throw new Error(
+2
View File
@@ -324,6 +324,8 @@ per-file diff commands.
unchanged, an absent one stays `""`. The JSON-LD test that pinned the raw `<p>...</p>` now unchanged, an absent one stays `""`. The JSON-LD test that pinned the raw `<p>...</p>` now
expects the text, plus two new cases; the HTML case fails on master. expects the text, plus two new cases; the HTML case fails on master.
- **Valid portal flag tests stay offline** (#581) - success controls use local fixtures instead of depending on live job portals or their credentials.
- **`job_key.py` no longer gives two postings one key when a title or company is partly - **`job_key.py` no longer gives two postings one key when a title or company is partly
non-Latin** (`tools/job_key.py`, `tests/test_job_key.py`) - the hash fallbacks from #487 non-Latin** (`tools/job_key.py`, `tests/test_job_key.py`) - the hash fallbacks from #487
and #502 fired only when the slug was completely empty, so a mixed-script name kept its and #502 fired only when the slug was completely empty, so a mixed-script name kept its