Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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?`,
Expand Down
61 changes: 60 additions & 1 deletion src/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<Response>((_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(
Expand Down Expand Up @@ -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)
// ---------------------------------------------------------------------------
Expand Down
Loading