Skip to content
Merged
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
51 changes: 50 additions & 1 deletion src/commands/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Expand All @@ -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')
Expand Down
68 changes: 47 additions & 21 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -46,50 +56,66 @@ 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
}
}

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) => {
Expand All @@ -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()
Expand Down
37 changes: 37 additions & 0 deletions src/lib/__tests__/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
5 changes: 5 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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] ?? ''
Expand Down Expand Up @@ -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([
Expand Down
Loading