Skip to content

Commit 3b7ce07

Browse files
authored
fix(cli): reject a malformed --endpoint-url with a clear VALIDATION_ERROR (#19)
A malformed API endpoint produced an opaque or misleading failure instead of a clear config error: --endpoint-url "not a url" -> `Error: Invalid URL` (exit 1, a raw `new URL()` throw with no guidance) --endpoint-url "localhost:3000" (missing scheme, parses as scheme "localhost:") and "ftp://x" (wrong scheme) -> `fetch failed` / Service unavailable, emitted only after a full retry+backoff cycle — looks like a network outage, not a config typo. Add `assertValidEndpointUrl` in client-factory.ts and run it in both the real and dry-run paths of `makeHttpClient`, on the resolved endpoint (so it covers --endpoint-url, TESTSPRITE_API_URL, and the credentials file). A malformed value now throws a typed VALIDATION_ERROR (exit 5) with an actionable message. Crucially, and unlike the `--target-url` SSRF guard, this does NOT reject localhost or private hosts — the API endpoint legitimately points at a self-hosted, local-dev, or mock backend. Only syntactically invalid values (unparseable, or a non-http(s) scheme) are rejected, so existing self-hosted/CI configs and the test suite's localhost mock backend are unaffected. Adds unit coverage for assertValidEndpointUrl and the two makeHttpClient paths, plus subprocess regressions.
1 parent 4dac1d6 commit 3b7ce07

3 files changed

Lines changed: 168 additions & 2 deletions

File tree

src/lib/client-factory.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from 'vitest';
22
import {
33
DRY_RUN_API_KEY,
44
DRY_RUN_BANNER,
5+
assertValidEndpointUrl,
56
emitDryRunBanner,
67
makeHttpClient,
78
resetDryRunBannerForTesting,
@@ -12,6 +13,7 @@ import {
1213
REQUEST_TIMEOUT_MAX_MS,
1314
REQUEST_TIMEOUT_MIN_MS,
1415
} from './http.js';
16+
import { ApiError } from './errors.js';
1517

1618
const NO_CREDS_PATH = '/tmp/testsprite-cli-test-no-such-file-1234.ini';
1719

@@ -284,3 +286,88 @@ describe('makeHttpClient — real path (regression)', () => {
284286
expect(stderrLines).not.toContain(DRY_RUN_BANNER);
285287
});
286288
});
289+
290+
// ---------------------------------------------------------------------------
291+
// assertValidEndpointUrl — endpoint syntax guard (NOT an SSRF guard)
292+
// ---------------------------------------------------------------------------
293+
294+
describe('assertValidEndpointUrl', () => {
295+
it('accepts http(s) URLs, including private / localhost hosts (self-hosted, dev, mock)', () => {
296+
for (const url of [
297+
'https://api.testsprite.com',
298+
'http://localhost:3000',
299+
'http://127.0.0.1:8787',
300+
'https://testsprite.internal.example.com/api/cli/v1',
301+
]) {
302+
expect(() => assertValidEndpointUrl(url)).not.toThrow();
303+
}
304+
});
305+
306+
it('rejects an unparseable URL with a VALIDATION_ERROR (exit 5)', () => {
307+
let caught: unknown;
308+
try {
309+
assertValidEndpointUrl('not a url');
310+
} catch (err) {
311+
caught = err;
312+
}
313+
expect(caught).toBeInstanceOf(ApiError);
314+
const apiErr = caught as ApiError;
315+
expect(apiErr.code).toBe('VALIDATION_ERROR');
316+
expect(apiErr.exitCode).toBe(5);
317+
expect(apiErr.nextAction).toContain('endpoint-url');
318+
});
319+
320+
it('rejects a missing scheme (parses as a bogus scheme) and a non-http(s) scheme', () => {
321+
// `new URL('localhost:3000')` does not throw — it parses with protocol
322+
// `localhost:`. Without the scheme check this would sail through and later
323+
// fail as a retried "fetch failed".
324+
for (const url of ['localhost:3000', 'ftp://example.com', 'file:///etc/hosts']) {
325+
let caught: unknown;
326+
try {
327+
assertValidEndpointUrl(url);
328+
} catch (err) {
329+
caught = err;
330+
}
331+
expect(caught).toBeInstanceOf(ApiError);
332+
expect((caught as ApiError).code).toBe('VALIDATION_ERROR');
333+
expect((caught as ApiError).exitCode).toBe(5);
334+
}
335+
});
336+
});
337+
338+
describe('makeHttpClient — endpoint validation', () => {
339+
afterEach(() => {
340+
resetDryRunBannerForTesting();
341+
});
342+
343+
it('throws a VALIDATION_ERROR (exit 5) on a malformed --endpoint-url under --dry-run', () => {
344+
let caught: unknown;
345+
try {
346+
makeHttpClient(
347+
{ profile: 'default', output: 'json', debug: false, dryRun: true, endpointUrl: 'ftp://x' },
348+
{ env: {} as NodeJS.ProcessEnv, credentialsPath: NO_CREDS_PATH, stderr: () => {} },
349+
);
350+
} catch (err) {
351+
caught = err;
352+
}
353+
expect(caught).toBeInstanceOf(ApiError);
354+
expect((caught as ApiError).exitCode).toBe(5);
355+
});
356+
357+
it('rejects a malformed endpoint before the auth check on the real path', () => {
358+
// No API key is configured, but the malformed endpoint is reported first
359+
// (a deterministic config error) — exit 5, not exit 3.
360+
let caught: unknown;
361+
try {
362+
makeHttpClient(
363+
{ profile: 'default', output: 'json', debug: false, dryRun: false, endpointUrl: 'nope' },
364+
{ env: {} as NodeJS.ProcessEnv, credentialsPath: NO_CREDS_PATH, stderr: () => {} },
365+
);
366+
} catch (err) {
367+
caught = err;
368+
}
369+
expect(caught).toBeInstanceOf(ApiError);
370+
expect((caught as ApiError).code).toBe('VALIDATION_ERROR');
371+
expect((caught as ApiError).exitCode).toBe(5);
372+
});
373+
});

src/lib/client-factory.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
*/
1616
import { loadConfig } from './config.js';
1717
import { defaultCredentialsPath } from './credentials.js';
18-
import { ApiError } from './errors.js';
18+
import { ApiError, localValidationError } from './errors.js';
1919
import { facadeBaseUrl } from './facade.js';
2020
import type { DebugEvent, FetchImpl } from './http.js';
2121
import {
@@ -139,15 +139,61 @@ function clampRequestTimeout(ms: number): number {
139139
return Math.min(Math.max(Math.round(ms), REQUEST_TIMEOUT_MIN_MS), REQUEST_TIMEOUT_MAX_MS);
140140
}
141141

142+
/**
143+
* Validate that the resolved API endpoint is a syntactically valid http(s)
144+
* URL before it is used to build requests.
145+
*
146+
* Unlike the `--target-url` guard in `target-url.ts`, this deliberately does
147+
* NOT reject localhost or private addresses: the API endpoint legitimately
148+
* points at a self-hosted, local-dev, or mock backend on a private host. It
149+
* only catches a malformed value (unparseable, or a non-http(s) scheme) so the
150+
* operator gets a fast, actionable VALIDATION_ERROR (exit 5) instead of an
151+
* opaque `Invalid URL` (exit 1, a raw `new URL()` throw) or a misleading
152+
* `fetch failed / Service temporarily unavailable` emitted only after a full
153+
* retry-and-backoff cycle.
154+
*
155+
* The value can originate from `--endpoint-url`, `TESTSPRITE_API_URL`, or the
156+
* credentials file `api_url`, so the message names all three rather than
157+
* assuming the flag.
158+
*/
159+
export function assertValidEndpointUrl(rawUrl: string): void {
160+
let parsed: URL;
161+
try {
162+
parsed = new URL(rawUrl);
163+
} catch {
164+
throw localValidationError(
165+
'endpoint-url',
166+
`"${rawUrl}" is not a valid URL — provide an http(s) URL (e.g. https://api.testsprite.com) ` +
167+
`via --endpoint-url, TESTSPRITE_API_URL, or the credentials file`,
168+
undefined,
169+
'field',
170+
);
171+
}
172+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
173+
throw localValidationError(
174+
'endpoint-url',
175+
`scheme "${parsed.protocol.replace(/:$/, '')}" is not supported — use http or https ` +
176+
`(e.g. https://api.testsprite.com)`,
177+
undefined,
178+
'field',
179+
);
180+
}
181+
}
182+
142183
export function makeHttpClient(opts: CommonOptions, deps: ClientFactoryDeps = {}): HttpClient {
143184
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
144185
const env = deps.env ?? process.env;
145186
const requestTimeoutMs = resolveRequestTimeoutMs(opts, env);
146187

147188
if (opts.dryRun) {
189+
const dryRunEndpoint = opts.endpointUrl ?? DRY_RUN_DEFAULT_ENDPOINT;
190+
// Validate even under --dry-run so a typo in --endpoint-url is caught
191+
// offline (no creds, no network). Validate BEFORE the banner so a rejected
192+
// endpoint doesn't first announce a "sample response".
193+
assertValidEndpointUrl(dryRunEndpoint);
148194
emitDryRunBanner(stderr);
149195
return new HttpClient({
150-
baseUrl: facadeBaseUrl(opts.endpointUrl ?? DRY_RUN_DEFAULT_ENDPOINT),
196+
baseUrl: facadeBaseUrl(dryRunEndpoint),
151197
apiKey: DRY_RUN_API_KEY,
152198
fetchImpl: deps.fetchImpl ?? createDryRunFetch(),
153199
onDebug: opts.debug ? (event: DebugEvent) => stderr(formatDryRunDebug(event)) : undefined,
@@ -163,6 +209,10 @@ export function makeHttpClient(opts: CommonOptions, deps: ClientFactoryDeps = {}
163209
env,
164210
credentialsPath,
165211
});
212+
// Catch a malformed endpoint (from --endpoint-url / TESTSPRITE_API_URL /
213+
// credentials) before the auth check so a config typo surfaces as a clear
214+
// VALIDATION_ERROR rather than an opaque URL throw or a retried "fetch failed".
215+
assertValidEndpointUrl(config.apiUrl);
166216
if (!config.apiKey) throw ApiError.authRequired();
167217
return new HttpClient({
168218
baseUrl: facadeBaseUrl(config.apiUrl),

test/cli.subprocess.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,35 @@ describe('project list subprocess', () => {
499499
}, 30_000);
500500
});
501501

502+
describe('malformed --endpoint-url is rejected (exit 5), not retried as a network error', () => {
503+
// Previously: a malformed endpoint surfaced either as an opaque `Invalid URL`
504+
// (exit 1) or, for a missing/wrong scheme, as a `fetch failed` UNAVAILABLE
505+
// only after a full retry-and-backoff cycle. Both are misleading config
506+
// errors. Validation throws before any fetch, so no network is hit here even
507+
// though a (dummy) key is configured.
508+
509+
it('an unparseable endpoint exits 5 with a VALIDATION_ERROR naming endpoint-url', async () => {
510+
const result = await runCli(
511+
['--output', 'json', '--endpoint-url', 'not a url', 'project', 'list'],
512+
{ TESTSPRITE_API_KEY: 'sk-subproc' },
513+
);
514+
expect(result.exitCode).toBe(5);
515+
const parsed = JSON.parse(result.stderr) as { error: { code: string; nextAction: string } };
516+
expect(parsed.error.code).toBe('VALIDATION_ERROR');
517+
expect(parsed.error.nextAction).toContain('endpoint-url');
518+
}, 30_000);
519+
520+
it('a non-http(s) scheme exits 5 instead of being retried as a network failure', async () => {
521+
const result = await runCli(
522+
['--output', 'json', '--endpoint-url', 'ftp://example.com', 'project', 'list'],
523+
{ TESTSPRITE_API_KEY: 'sk-subproc' },
524+
);
525+
expect(result.exitCode).toBe(5);
526+
const parsed = JSON.parse(result.stderr) as { error: { code: string } };
527+
expect(parsed.error.code).toBe('VALIDATION_ERROR');
528+
}, 30_000);
529+
});
530+
502531
describe('project get subprocess', () => {
503532
it('--output json returns the §6.1 Project shape', async () => {
504533
const result = await runCli(['--output', 'json', 'project', 'get', 'project_subproc'], {

0 commit comments

Comments
 (0)