diff --git a/src/commands/__tests__/doctor.test.ts b/src/commands/__tests__/doctor.test.ts index 886e043..b5d29da 100644 --- a/src/commands/__tests__/doctor.test.ts +++ b/src/commands/__tests__/doctor.test.ts @@ -123,7 +123,7 @@ describe('doctor command', () => { await program.parseAsync(['doctor'], { from: 'user' }) expect(error).toHaveBeenCalledWith(expect.stringContaining('API key')) - expect(hint).toHaveBeenCalledWith(expect.stringContaining('skipped')) + expect(info).toHaveBeenCalledWith(expect.stringContaining('skipped')) expect(whoami).not.toHaveBeenCalled() }) }) @@ -141,6 +141,55 @@ describe('doctor command', () => { }) }) + describe('when the IP is not allow-listed', () => { + beforeEach(() => { + const err = new Error( + 'authentication_error: allowed_cidr_blocks_does_not_contain_ip\n' + + 'IP is not in allowed CIDR blocks. Please check that your allowed CIDR blocks contains your IP.', + ) + Object.defineProperty(err, 'constructor', { value: { name: 'AuthenticationError' } }) + vi.mocked(whoami).mockRejectedValue(err) + }) + + test('reports the API as reachable but the IP as blocked', async () => { + const program = createProgram() + await program.parseAsync(['doctor'], { from: 'user' }) + + expect(error).toHaveBeenCalledWith( + expect.stringContaining('reachable, but your IP is not allow-listed'), + ) + expect(error).not.toHaveBeenCalledWith(expect.stringContaining('could not reach')) + }) + + test('points to the dashboard to fix the allow list', async () => { + const program = createProgram() + await program.parseAsync(['doctor'], { from: 'user' }) + + expect(hint).toHaveBeenCalledWith(expect.stringContaining('allowed CIDR blocks')) + expect(hint).toHaveBeenCalledWith( + expect.stringContaining('https://dashboard.fintoc.com/api-keys'), + ) + }) + }) + + describe('when the API key is rejected', () => { + beforeEach(() => { + const err = new Error('authentication_error\nInvalid API key') + Object.defineProperty(err, 'constructor', { value: { name: 'AuthenticationError' } }) + vi.mocked(whoami).mockRejectedValue(err) + }) + + test('reports the API as reachable but the key as rejected', async () => { + const program = createProgram() + await program.parseAsync(['doctor'], { from: 'user' }) + + expect(error).toHaveBeenCalledWith( + expect.stringContaining('reachable, but the API key was rejected'), + ) + expect(error).not.toHaveBeenCalledWith(expect.stringContaining('could not reach')) + }) + }) + describe('when CLI version is outdated', () => { beforeEach(() => { vi.mocked(execSync).mockReturnValue('0.2.0\n') diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index e3bf159..806ba0a 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -6,12 +6,22 @@ import { CONFIG_PATH, readConfig } from '../lib/config.js' import { API_HOST, CONFIG_FILE_PERMISSIONS, + DASHBOARD_API_KEYS_URL, + IP_ALLOWLIST_ERROR_CODES, NPM_CHECK_TIMEOUT_MS, NPM_PACKAGE_NAME, } from '../lib/constants.js' +import { parseFintocError } from '../lib/errors.js' import { error, hint, info, success, warn } from '../lib/output.js' import { getCliVersion } from '../lib/version.js' +const LABEL_WIDTH = 20 +const DETAIL_INDENT = 2 + LABEL_WIDTH + +const row = (label: string, value: string) => `${label.padEnd(LABEL_WIDTH)}${value}` + +const detail = (text: string) => hint(`${' '.repeat(DETAIL_INDENT)}${text}`) + const checkCliVersion = () => { const currentVersion = getCliVersion() try { @@ -22,22 +32,22 @@ const checkCliVersion = () => { }).trim() if (latest === currentVersion) { - success(`CLI version ${currentVersion} (latest)`) + success(row('CLI version', `${currentVersion} (latest)`)) } else { - warn(`CLI version ${currentVersion} (latest: ${latest})`) + warn(row('CLI version', `${currentVersion} (latest: ${latest})`)) } } catch { - success(`CLI version ${currentVersion}`) - hint(' (could not check for updates)') + success(row('CLI version', currentVersion)) + detail('(could not check for updates)') } } const checkConfigFile = (authSource?: string) => { if (!existsSync(CONFIG_PATH)) { if (authSource) { - warn(`Config file ${CONFIG_PATH} not found (using ${authSource})`) + warn(row('Config file', `${CONFIG_PATH} not found (using ${authSource})`)) } else { - error(`Config file ${CONFIG_PATH} not found`) + error(row('Config file', `${CONFIG_PATH} not found`)) } return } @@ -46,22 +56,22 @@ const checkConfigFile = (authSource?: string) => { const mode = stats.mode & 0o777 if (mode !== CONFIG_FILE_PERMISSIONS) { warn( - `Config file ${CONFIG_PATH} found (permissions: ${mode.toString(8)}, expected: 600)`, + row('Config file', `${CONFIG_PATH} found (permissions: ${mode.toString(8)}, expected: 600)`), ) return } - success(`Config file ${CONFIG_PATH} found`) + success(row('Config file', `${CONFIG_PATH} found`)) } const checkApiKey = (options?: { apiKey?: string }) => { try { const auth = resolveAuth(options) - success(`API key ${maskKey(auth.secretKey)} (source: ${auth.source})`) + success(row('API key', `${maskKey(auth.secretKey)} (source: ${auth.source})`)) return auth } catch { - error('API key not configured') - hint(' Run `fintoc login` or set FINTOC_API_KEY') + error(row('API key', 'not configured')) + detail('Run `fintoc login` or set FINTOC_API_KEY') return null } } @@ -69,27 +79,43 @@ const checkApiKey = (options?: { apiKey?: string }) => { const checkConnectivity = async (secretKey: string) => { try { const info = await whoami(secretKey) - success(`Connectivity ${API_HOST} reachable`) - success(`Organization ${info.organizationName} (${info.mode} mode)`) - } catch { - error(`Connectivity could not reach ${API_HOST}`) - hint(' Check your internet connection and API key') + success(row('Connectivity', `${API_HOST} reachable`)) + success(row('Organization', `${info.organizationName} (${info.mode} mode)`)) + } catch (err) { + const fields = parseFintocError(err) + + if (fields?.code && IP_ALLOWLIST_ERROR_CODES.has(fields.code)) { + error(row('Connectivity', `${API_HOST} reachable, but your IP is not allow-listed`)) + detail(fields.message ?? 'Your IP is not in the allowed CIDR blocks.') + detail('Add your IP to the allow list in the dashboard:') + detail(DASHBOARD_API_KEYS_URL) + return + } + + if (fields?.type === 'authentication_error') { + error(row('Connectivity', `${API_HOST} reachable, but the API key was rejected`)) + detail('Check your API key is valid for this environment.') + return + } + + error(row('Connectivity', `could not reach ${API_HOST}`)) + detail('Check your internet connection and API key') } } const checkJwsKey = () => { const config = readConfig() if (!config.jws_private_key) { - info('JWS private key not configured (only needed for transfers create)') + info(row('JWS private key', 'not configured (only needed for transfers create)')) return } if (!existsSync(config.jws_private_key)) { - error(`JWS private key file not found: ${config.jws_private_key}`) + error(row('JWS private key', `file not found: ${config.jws_private_key}`)) return } - success(`JWS private key ${config.jws_private_key}`) + success(row('JWS private key', config.jws_private_key)) } export const doctorCommand = (program: Command) => { @@ -106,8 +132,8 @@ export const doctorCommand = (program: Command) => { if (auth) { await checkConnectivity(auth.secretKey) } else { - hint(' ⏭ Connectivity skipped (no API key)') - hint(' ⏭ Organization skipped (no API key)') + info(row('Connectivity', 'skipped (no API key)')) + info(row('Organization', 'skipped (no API key)')) } checkJwsKey() diff --git a/src/lib/__tests__/errors.test.ts b/src/lib/__tests__/errors.test.ts index ab132fc..e479225 100644 --- a/src/lib/__tests__/errors.test.ts +++ b/src/lib/__tests__/errors.test.ts @@ -182,6 +182,43 @@ describe('handleError', () => { }) }) + describe('IP not allow-listed', () => { + test('formats allowed_cidr_blocks_does_not_contain_ip with IP allow list next steps', () => { + const err = createFintocError('AuthenticationError', { + type: 'authentication_error', + code: 'allowed_cidr_blocks_does_not_contain_ip', + message: + 'IP is not in allowed CIDR blocks. Please check that your allowed CIDR blocks contains your IP.', + }) + + expect(() => handleError(err)).toThrow('process.exit') + + expect(error).toHaveBeenCalledWith( + 'IP is not in allowed CIDR blocks. Please check that your allowed CIDR blocks contains your IP.', + ) + expect(hint).toHaveBeenCalledWith(' Your organization restricts API access by IP address.') + expect(hint).toHaveBeenCalledWith( + ' Add your IP to the allow list at: https://dashboard.fintoc.com/api-keys', + ) + expect(hint).not.toHaveBeenCalledWith(expect.stringContaining('Check your API key')) + }) + + test('formats missing_allowed_cidr_block as an IP allow list error', () => { + const err = createFintocError('AuthenticationError', { + type: 'authentication_error', + code: 'missing_allowed_cidr_block', + message: + 'IP Restriction is enabled but no allowed CIDR blocks were found. Please add some allowed CIDR blocks or turn off IP Restriction from the dashboard.', + }) + + expect(() => handleError(err)).toThrow('process.exit') + + expect(hint).toHaveBeenCalledWith( + ' Add your IP to the allow list at: https://dashboard.fintoc.com/api-keys', + ) + }) + }) + describe('authentication error', () => { test('formats AuthenticationError with next steps', () => { const err = createFintocError('AuthenticationError', { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index b575d32..eb6047e 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,5 +1,10 @@ export const API_HOST = 'api.fintoc.com' export const NPM_PACKAGE_NAME = '@fintoc/cli' + +export const IP_ALLOWLIST_ERROR_CODES = new Set([ + 'allowed_cidr_blocks_does_not_contain_ip', + 'missing_allowed_cidr_block', +]) export const DEFAULT_LIST_LIMIT = 10 export const NPM_CHECK_TIMEOUT_MS = 10_000 export const CONFIG_FILE_PERMISSIONS = 0o600 diff --git a/src/lib/errors.ts b/src/lib/errors.ts index f921a86..95817f9 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -3,6 +3,7 @@ import { DASHBOARD_API_KEYS_URL, DOCS_LINKS_URL, DOCS_TRANSFERS_URL, + IP_ALLOWLIST_ERROR_CODES, } from './constants.js' import { error, hint } from './output.js' @@ -43,7 +44,7 @@ const FINTOC_ERROR_CLASSES = new Set([ const isFintocError = (err: unknown): err is Error => err instanceof Error && FINTOC_ERROR_CLASSES.has(err.constructor.name) -const parseFintocError = (err: unknown): FintocErrorFields | undefined => { +export const parseFintocError = (err: unknown): FintocErrorFields | undefined => { if (isFintocError(err)) { const lines = err.message.split('\n') const firstLine = lines[0] ?? '' @@ -166,6 +167,15 @@ export const handleError = (err: unknown, context?: ErrorContext): never => { return process.exit(1) } + if (fields.code && IP_ALLOWLIST_ERROR_CODES.has(fields.code)) { + error(fields.message ?? 'Your IP is not in the allowed CIDR blocks.') + printNextSteps([ + 'Your organization restricts API access by IP address.', + `Add your IP to the allow list at: ${DASHBOARD_API_KEYS_URL}`, + ]) + return process.exit(1) + } + if (fields.type === 'authentication_error') { error(`Authentication failed: ${fields.message ?? 'Invalid API key'}`) printNextSteps([