diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 4996858..be16ddc 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -8,7 +8,7 @@ import { parseRequestTimeoutFlag, type CommonOptions as FactoryCommonOptions, } from '../lib/client-factory.js'; -import { ApiError, CLIError, InterruptError } from '../lib/errors.js'; +import { ApiError, CLIError, InterruptError, RequestTimeoutError } from '../lib/errors.js'; import { facadeBaseUrl, resolvePortalBase } from '../lib/facade.js'; import type { FetchImpl } from '../lib/http.js'; import { HttpClient } from '../lib/http.js'; @@ -299,6 +299,13 @@ export async function runConfigure( } catch (err) { if (err instanceof InterruptError) throw err; const message = err instanceof Error ? err.message : String(err); + // A client-side timeout says nothing about the key. Rethrow it unchanged so + // it keeps its REQUEST_TIMEOUT code, exit 7 and request id, instead of the + // exit-3 "rejected" wrapper below that sends the user to rotate a good key. + if (err instanceof RequestTimeoutError) { + stderr(`API key check against ${apiUrl} timed out, profile NOT updated: ${message}`); + throw err; + } stderr(`API key rejected by ${apiUrl}: ${message} — profile NOT updated`); // When the verification call returned a typed API error (AUTH_INVALID, // AUTH_FORBIDDEN, etc.), re-throw it directly so `index.ts` renders the @@ -311,7 +318,7 @@ export async function runConfigure( err.message = `API key rejected by ${apiUrl}: ${message} — did you mean to set TESTSPRITE_API_URL?`; throw err; } - // Non-ApiError (truly unexpected throws like a TypeError from a + // Anything else (truly unexpected throws like a TypeError from a // misconfigured fetchImpl). Exit 3 (auth family). throw new CLIError( `API key rejected by ${apiUrl}: ${message} — did you mean to set TESTSPRITE_API_URL?`, diff --git a/src/commands/init.test.ts b/src/commands/init.test.ts index c5a51d2..4dae921 100644 --- a/src/commands/init.test.ts +++ b/src/commands/init.test.ts @@ -16,7 +16,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ApiError, CLIError, InterruptError } from '../lib/errors.js'; +import { ApiError, CLIError, InterruptError, RequestTimeoutError } from '../lib/errors.js'; import { resetDryRunBannerForTesting } from '../lib/client-factory.js'; import { readProfile, writeProfile } from '../lib/credentials.js'; import type { MeResponse } from './auth.js'; @@ -67,6 +67,18 @@ function makeOkFetch(): InitDeps['fetchImpl'] { ) as unknown as InitDeps['fetchImpl']; } +/** Mock fetch that never answers; it rejects only when the request is aborted. */ +function makeHangingFetch(): InitDeps['fetchImpl'] { + return vi.fn( + (_input: string | URL | Request, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { + once: true, + }); + }), + ); +} + /** Mock fetch that returns 401 for any request (simulates bad key). */ function makeAuthFailFetch(): InitDeps['fetchImpl'] { return vi.fn( @@ -506,6 +518,53 @@ describe('runInit — session-only environment credentials', () => { }); }); +describe('runInit: the key check against /me does not answer', () => { + const options = () => + makeBaseOpts({ fromEnv: true, noAgent: true, output: 'json', requestTimeoutMs: 20 }); + + it('setup --from-env exits 7 with REQUEST_TIMEOUT, keeps the request id and saves nothing', async () => { + const { captured, deps } = makeCapture(); + const err = await runInit(options(), { + ...deps, + env: { TESTSPRITE_API_KEY: 'sk-user-valid-key' }, + credentialsPath, + fetchImpl: makeHangingFetch(), + isTTY: false, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(RequestTimeoutError); + expect(err).toMatchObject({ + code: 'REQUEST_TIMEOUT', + exitCode: 7, + requestId: expect.stringMatching(/^cli_/), + }); + expect(readProfile('default', { path: credentialsPath })).toBeUndefined(); + const stderr = captured.stderr.join('\n'); + expect(stderr).toContain('timed out, profile NOT updated'); + expect(stderr).not.toContain('API key rejected'); + expect(captured.stdout).toEqual([]); + }); + + it('a 401 from /me still exits 3 with the typed envelope', async () => { + const { captured, deps } = makeCapture(); + await expect( + runInit(options(), { + ...deps, + env: { TESTSPRITE_API_KEY: 'sk-user-revoked-key' }, + credentialsPath, + fetchImpl: makeAuthFailFetch(), + isTTY: false, + }), + ).rejects.toMatchObject({ + code: 'AUTH_INVALID', + exitCode: 3, + nextAction: 'Provide a valid key.', + requestId: 'r-1', + }); + expect(readProfile('default', { path: credentialsPath })).toBeUndefined(); + expect(captured.stderr.join('\n')).toContain('API key rejected'); + }); +}); + // --------------------------------------------------------------------------- // 1. Happy path — interactive (text + json output) // ---------------------------------------------------------------------------