Skip to content

Commit 396138a

Browse files
committed
fix(setup): validate endpoint before key check
1 parent e53257d commit 396138a

3 files changed

Lines changed: 95 additions & 1 deletion

File tree

src/commands/auth.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,67 @@ describe('runConfigure', () => {
164164
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
165165
});
166166

167+
it('rejects a malformed endpoint before key validation fetch', async () => {
168+
const { capture, deps } = makeCapture();
169+
const fetchImpl = vi.fn();
170+
171+
await expect(
172+
runConfigure(
173+
{
174+
profile: 'default',
175+
output: 'json',
176+
debug: false,
177+
fromEnv: true,
178+
endpointUrl: 'not-a-url',
179+
},
180+
{
181+
...deps,
182+
env: { TESTSPRITE_API_KEY: 'sk' },
183+
credentialsPath,
184+
fetchImpl: fetchImpl as unknown as AuthDeps['fetchImpl'],
185+
},
186+
),
187+
).rejects.toMatchObject({
188+
code: 'VALIDATION_ERROR',
189+
exitCode: 5,
190+
details: { field: 'endpoint-url' },
191+
});
192+
193+
expect(fetchImpl).not.toHaveBeenCalled();
194+
expect(readProfile('default', { path: credentialsPath })).toBeUndefined();
195+
expect(capture.stderr.join('\n')).not.toContain('API key rejected');
196+
});
197+
198+
it('rejects a non-http endpoint before key validation fetch', async () => {
199+
const { deps } = makeCapture();
200+
const fetchImpl = vi.fn();
201+
202+
await expect(
203+
runConfigure(
204+
{
205+
profile: 'default',
206+
output: 'json',
207+
debug: false,
208+
fromEnv: true,
209+
endpointUrl: 'ftp://example.com',
210+
},
211+
{
212+
...deps,
213+
env: { TESTSPRITE_API_KEY: 'sk' },
214+
credentialsPath,
215+
fetchImpl: fetchImpl as unknown as AuthDeps['fetchImpl'],
216+
},
217+
),
218+
).rejects.toMatchObject({
219+
code: 'VALIDATION_ERROR',
220+
exitCode: 5,
221+
details: { field: 'endpoint-url' },
222+
});
223+
224+
expect(fetchImpl).not.toHaveBeenCalled();
225+
expect(readProfile('default', { path: credentialsPath })).toBeUndefined();
226+
});
227+
167228
it('prompts only for the API key (never the endpoint) and defaults to prod', async () => {
168229
const { capture, deps } = makeCapture();
169230
// Prompt object exposes ONLY `secret`. If runConfigure tried to prompt for

src/commands/auth.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Command } from 'commander';
22
import {
3+
assertValidEndpointUrl,
34
emitDryRunBanner,
45
makeHttpClient,
56
parseRequestTimeoutFlag,
@@ -87,8 +88,9 @@ export async function runConfigure(opts: ConfigureOptions, deps: AuthDeps = {}):
8788
// Print the canned success shape so an agent sees exactly the JSON it
8889
// would get on a real configure (modulo the endpoint string).
8990
if (opts.dryRun) {
90-
emitDryRunBanner(stderr);
9191
const apiUrl = opts.endpointUrl ?? envApiUrl ?? DEFAULT_API_URL;
92+
assertValidEndpointUrl(apiUrl);
93+
emitDryRunBanner(stderr);
9294
stderr(`[dry-run] would write credentials for profile="${opts.profile}" to ${credentialsPath}`);
9395
out.print({ profile: opts.profile, apiUrl, status: 'configured' }, data => {
9496
const d = data as { profile: string; apiUrl: string };
@@ -114,6 +116,7 @@ export async function runConfigure(opts: ConfigureOptions, deps: AuthDeps = {}):
114116
// api_url doesn't silently validate a new key against the default endpoint.
115117
const resolvedFromProfile = existingProfile?.apiUrl;
116118
const apiUrl = opts.endpointUrl ?? envApiUrl ?? resolvedFromProfile ?? DEFAULT_API_URL;
119+
assertValidEndpointUrl(apiUrl);
117120

118121
if (opts.fromEnv) {
119122
apiKey = env.TESTSPRITE_API_KEY?.trim();

src/commands/init.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,36 @@ describe('runInit — codex-review hardening', () => {
542542
expect(fetchImpl).toHaveBeenCalled();
543543
});
544544

545+
it('rejects malformed --endpoint-url before setup key verification', async () => {
546+
const { captured, deps } = makeCapture();
547+
const fetchImpl = makeOkFetch();
548+
549+
await expect(
550+
runInit(
551+
makeBaseOpts({
552+
fromEnv: true,
553+
endpointUrl: 'not-a-url',
554+
noAgent: true,
555+
output: 'json',
556+
}),
557+
{
558+
...deps,
559+
env: { TESTSPRITE_API_KEY: 'sk' },
560+
fetchImpl,
561+
credentialsPath,
562+
isTTY: false,
563+
},
564+
),
565+
).rejects.toMatchObject({
566+
code: 'VALIDATION_ERROR',
567+
exitCode: 5,
568+
details: { field: 'endpoint-url' },
569+
});
570+
571+
expect(fetchImpl).not.toHaveBeenCalled();
572+
expect(captured.stderr.join('\n')).not.toContain('API key rejected');
573+
});
574+
545575
it('whoami banner uses --api-key, not a stale TESTSPRITE_API_KEY in env (E2E 2026-06-09)', async () => {
546576
const { captured, deps } = makeCapture();
547577
// Key-aware fetch: only the real key gets a 200 + identity; the stale env key 401s.

0 commit comments

Comments
 (0)