diff --git a/src/tests/unit/utils/argument-parser.test.ts b/src/tests/unit/utils/argument-parser.test.ts new file mode 100644 index 0000000..09c88ab --- /dev/null +++ b/src/tests/unit/utils/argument-parser.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect } from 'vitest' +import { + parseOwnersArgument, + parseJsonArgument, + parseAddressArgument, + parseFunctionCall, + parseNumericArgument, + parseChainArgument, +} from '../../../utils/argument-parser.js' +import { TEST_ADDRESSES } from '../../fixtures/index.js' +import { writeFileSync, unlinkSync } from 'fs' +import { resolve } from 'path' + +describe('argument-parser', () => { + describe('parseOwnersArgument', () => { + it('should parse JSON array of addresses', () => { + const input = `["${TEST_ADDRESSES.owner1}", "${TEST_ADDRESSES.owner2}"]` + const result = parseOwnersArgument(input) + expect(result).toEqual([TEST_ADDRESSES.owner1, TEST_ADDRESSES.owner2]) + }) + + it('should parse comma-separated addresses', () => { + const input = `${TEST_ADDRESSES.owner1},${TEST_ADDRESSES.owner2}` + const result = parseOwnersArgument(input) + expect(result).toEqual([TEST_ADDRESSES.owner1, TEST_ADDRESSES.owner2]) + }) + + it('should parse comma-separated addresses with spaces', () => { + const input = `${TEST_ADDRESSES.owner1} , ${TEST_ADDRESSES.owner2}` + const result = parseOwnersArgument(input) + expect(result).toEqual([TEST_ADDRESSES.owner1, TEST_ADDRESSES.owner2]) + }) + + it('should throw on invalid JSON', () => { + expect(() => parseOwnersArgument('[invalid json')).toThrow('Invalid JSON array') + }) + + it('should throw on non-array JSON', () => { + expect(() => parseOwnersArgument('{"not": "array"}')).toThrow('Invalid address') + }) + + it('should throw on invalid address in JSON array', () => { + expect(() => parseOwnersArgument('["0xinvalid"]')).toThrow('Invalid address') + }) + + it('should throw on invalid address in comma-separated list', () => { + expect(() => parseOwnersArgument('0xinvalid,0xalsobad')).toThrow('Invalid address') + }) + + it('should throw on empty string', () => { + expect(() => parseOwnersArgument('')).toThrow('No owners provided') + }) + + it('should filter out empty addresses from comma-separated list', () => { + const input = `${TEST_ADDRESSES.owner1},,${TEST_ADDRESSES.owner2}` + const result = parseOwnersArgument(input) + expect(result).toEqual([TEST_ADDRESSES.owner1, TEST_ADDRESSES.owner2]) + }) + }) + + describe('parseJsonArgument', () => { + it('should parse JSON string', () => { + const input = '{"key": "value", "number": 42}' + const result = parseJsonArgument(input) + expect(result).toEqual({ key: 'value', number: 42 }) + }) + + it('should parse JSON array', () => { + const input = '[1, 2, 3]' + const result = parseJsonArgument(input) + expect(result).toEqual([1, 2, 3]) + }) + + it('should throw on invalid JSON', () => { + expect(() => parseJsonArgument('{invalid}')).toThrow('Invalid JSON') + }) + + it('should read JSON from file with @ prefix', () => { + const testFile = resolve('/tmp/test-json.json') + const testData = { test: 'data', value: 123 } + writeFileSync(testFile, JSON.stringify(testData)) + + try { + const result = parseJsonArgument(`@${testFile}`) + expect(result).toEqual(testData) + } finally { + unlinkSync(testFile) + } + }) + + it('should throw on non-existent file', () => { + expect(() => parseJsonArgument('@/nonexistent/file.json')).toThrow('Failed to read JSON') + }) + + it('should throw on invalid JSON in file', () => { + const testFile = resolve('/tmp/test-invalid.json') + writeFileSync(testFile, '{invalid json}') + + try { + expect(() => parseJsonArgument(`@${testFile}`)).toThrow('Failed to read JSON') + } finally { + unlinkSync(testFile) + } + }) + }) + + describe('parseAddressArgument', () => { + it('should parse plain address', () => { + const result = parseAddressArgument(TEST_ADDRESSES.owner1) + expect(result).toEqual({ + address: TEST_ADDRESSES.owner1, + }) + }) + + it('should parse EIP-3770 format address', () => { + const input = `eth:${TEST_ADDRESSES.owner1}` + const result = parseAddressArgument(input) + expect(result).toEqual({ + shortName: 'eth', + address: TEST_ADDRESSES.owner1, + }) + }) + + it('should parse EIP-3770 format with spaces', () => { + const input = `eth :${TEST_ADDRESSES.owner1}` + const result = parseAddressArgument(input) + expect(result).toEqual({ + shortName: 'eth', + address: TEST_ADDRESSES.owner1, + }) + }) + + it('should throw on invalid plain address', () => { + expect(() => parseAddressArgument('0xinvalid')).toThrow('Invalid address') + }) + + it('should throw on invalid EIP-3770 address', () => { + expect(() => parseAddressArgument('eth:0xinvalid')).toThrow('Invalid address') + }) + }) + + describe('parseFunctionCall', () => { + it('should parse function signature with arguments', () => { + const signature = 'transfer(address,uint256)' + const args = '["0x1234567890123456789012345678901234567890", "1000000000000000000"]' + const result = parseFunctionCall(signature, args) + expect(result).toEqual({ + signature, + args: ['0x1234567890123456789012345678901234567890', '1000000000000000000'], + }) + }) + + it('should parse function signature without arguments', () => { + const signature = 'balanceOf(address)' + const result = parseFunctionCall(signature) + expect(result).toEqual({ + signature, + args: [], + }) + }) + + it('should parse function signature with empty args string', () => { + const signature = 'transfer(address,uint256)' + const result = parseFunctionCall(signature, undefined) + expect(result).toEqual({ + signature, + args: [], + }) + }) + + it('should throw on invalid JSON args', () => { + const signature = 'transfer(address,uint256)' + expect(() => parseFunctionCall(signature, '{invalid}')).toThrow('Invalid function arguments') + }) + + it('should throw on non-array args', () => { + const signature = 'transfer(address,uint256)' + expect(() => parseFunctionCall(signature, '{"not": "array"}')).toThrow( + 'Function arguments must be an array' + ) + }) + }) + + describe('parseNumericArgument', () => { + it('should parse integer string', () => { + const result = parseNumericArgument('1234567890') + expect(result).toBe(1234567890n) + }) + + it('should parse zero', () => { + const result = parseNumericArgument('0') + expect(result).toBe(0n) + }) + + it('should parse large number', () => { + const result = parseNumericArgument('1000000000000000000') + expect(result).toBe(1000000000000000000n) + }) + + it('should throw on decimal when not allowed', () => { + expect(() => parseNumericArgument('123.45', false)).toThrow('Decimal values not allowed') + }) + + it('should parse decimal when allowed', () => { + const result = parseNumericArgument('123.45', true) + expect(result).toBe(123450000000000000000n) + }) + + it('should parse decimal with trailing zeros', () => { + const result = parseNumericArgument('1.5', true) + expect(result).toBe(1500000000000000000n) + }) + + it('should handle decimal with many digits', () => { + const result = parseNumericArgument('1.123456789012345678', true) + expect(result).toBe(1123456789012345678n) + }) + + it('should truncate decimals beyond 18 digits', () => { + const result = parseNumericArgument('1.123456789012345678999', true) + expect(result).toBe(1123456789012345678n) + }) + + it('should throw on invalid numeric value', () => { + expect(() => parseNumericArgument('abc')).toThrow('Invalid numeric value') + }) + + it('should handle whitespace', () => { + const result = parseNumericArgument(' 123 ') + expect(result).toBe(123n) + }) + }) + + describe('parseChainArgument', () => { + it('should parse numeric chain ID', () => { + const result = parseChainArgument('1') + expect(result).toBe('1') + }) + + it('should parse large numeric chain ID', () => { + const result = parseChainArgument('11155111') + expect(result).toBe('11155111') + }) + + it('should pass through chain short name', () => { + const result = parseChainArgument('eth') + expect(result).toBe('eth') + }) + + it('should pass through chain short name with hyphen', () => { + const result = parseChainArgument('arbitrum-one') + expect(result).toBe('arbitrum-one') + }) + + it('should pass through alphanumeric short names', () => { + const result = parseChainArgument('base2') + expect(result).toBe('base2') + }) + }) +}) diff --git a/src/tests/unit/utils/command-context.test.ts b/src/tests/unit/utils/command-context.test.ts new file mode 100644 index 0000000..addf05d --- /dev/null +++ b/src/tests/unit/utils/command-context.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createCommandContext } from '../../../utils/command-context.js' + +// Mock storage modules +vi.mock('../../../storage/config-store.js', () => ({ + getConfigStore: vi.fn(() => ({ + getAllChains: vi.fn(() => ({ + '1': { chainId: '1', name: 'Ethereum' }, + '11155111': { chainId: '11155111', name: 'Sepolia' }, + })), + getChain: vi.fn(), + })), +})) + +vi.mock('../../../storage/safe-store.js', () => ({ + getSafeStorage: vi.fn(() => ({ + getAllSafes: vi.fn(() => []), + getSafe: vi.fn(), + })), +})) + +vi.mock('../../../storage/wallet-store.js', () => ({ + getWalletStorage: vi.fn(() => ({ + getActiveWallet: vi.fn(), + getAllWallets: vi.fn(() => []), + })), +})) + +vi.mock('../../../storage/transaction-store.js', () => ({ + getTransactionStore: vi.fn(() => ({ + getAllTransactions: vi.fn(() => []), + getTransaction: vi.fn(), + })), +})) + +vi.mock('../../../services/validation-service.js', () => ({ + getValidationService: vi.fn(() => ({ + validateAddress: vi.fn(), + validatePrivateKey: vi.fn(), + })), +})) + +describe('command-context', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('createCommandContext', () => { + it('should create context with all required services', () => { + const context = createCommandContext() + + expect(context).toHaveProperty('configStore') + expect(context).toHaveProperty('safeStorage') + expect(context).toHaveProperty('walletStorage') + expect(context).toHaveProperty('transactionStore') + expect(context).toHaveProperty('validator') + expect(context).toHaveProperty('chains') + }) + + it('should initialize configStore', () => { + const context = createCommandContext() + + expect(context.configStore).toBeDefined() + expect(context.configStore.getAllChains).toBeDefined() + }) + + it('should initialize safeStorage', () => { + const context = createCommandContext() + + expect(context.safeStorage).toBeDefined() + expect(context.safeStorage.getAllSafes).toBeDefined() + }) + + it('should initialize walletStorage', () => { + const context = createCommandContext() + + expect(context.walletStorage).toBeDefined() + expect(context.walletStorage.getActiveWallet).toBeDefined() + }) + + it('should initialize transactionStore', () => { + const context = createCommandContext() + + expect(context.transactionStore).toBeDefined() + expect(context.transactionStore.getAllTransactions).toBeDefined() + }) + + it('should initialize validator', () => { + const context = createCommandContext() + + expect(context.validator).toBeDefined() + expect(context.validator.validateAddress).toBeDefined() + }) + + it('should populate chains from configStore', () => { + const context = createCommandContext() + + expect(context.chains).toBeDefined() + expect(context.chains).toHaveProperty('1') + expect(context.chains).toHaveProperty('11155111') + expect(context.chains['1'].name).toBe('Ethereum') + expect(context.chains['11155111'].name).toBe('Sepolia') + }) + + it('should call getAllChains on configStore', async () => { + const { getConfigStore } = await import('../../../storage/config-store.js') + const mockConfigStore = { + getAllChains: vi.fn(() => ({ '1': { chainId: '1' } })), + } + vi.mocked(getConfigStore).mockReturnValue(mockConfigStore as any) + + createCommandContext() + + expect(mockConfigStore.getAllChains).toHaveBeenCalled() + }) + + it('should return new instance each time', () => { + const context1 = createCommandContext() + const context2 = createCommandContext() + + // While the underlying singleton stores are the same, + // the context object itself should be different + expect(context1).not.toBe(context2) + }) + }) +}) diff --git a/src/tests/unit/utils/command-helpers.test.ts b/src/tests/unit/utils/command-helpers.test.ts new file mode 100644 index 0000000..43e98b8 --- /dev/null +++ b/src/tests/unit/utils/command-helpers.test.ts @@ -0,0 +1,370 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + ensureActiveWallet, + ensureChainConfigured, + handleCommandError, + checkCancelled, + promptPassword, + output, + outputSuccess, + outputError, + isNonInteractiveMode, +} from '../../../utils/command-helpers.js' +import { SafeCLIError } from '../../../utils/errors.js' +import * as p from '@clack/prompts' +import type { WalletStorageService } from '../../../storage/wallet-store.js' +import type { ConfigStore } from '../../../storage/config-store.js' +import type { Wallet } from '../../../types/wallet.js' +import type { ChainConfig } from '../../../types/config.js' +import { TEST_ADDRESSES } from '../../fixtures/index.js' + +// Mock modules +vi.mock('@clack/prompts') +vi.mock('../../../services/validation-service.js', () => ({ + getValidationService: () => ({ + validatePassword: (value: string, minLength: number) => { + if (value.length < minLength) { + return `Password must be at least ${minLength} characters` + } + return undefined + }, + validatePasswordConfirmation: (value: string, password: string) => { + if (value !== password) { + return 'Passwords do not match' + } + return undefined + }, + }), +})) +vi.mock('../../../types/global-options.js', () => ({ + isJsonMode: vi.fn(() => false), + isQuietMode: vi.fn(() => false), +})) + +describe('command-helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('ensureActiveWallet', () => { + it('should return wallet when active wallet exists', () => { + const mockWallet: Wallet = { + name: 'test-wallet', + address: TEST_ADDRESSES.owner1, + encryptedPrivateKey: 'encrypted', + type: 'private-key', + } + const mockStorage = { + getActiveWallet: vi.fn(() => mockWallet), + } as unknown as WalletStorageService + + const result = ensureActiveWallet(mockStorage) + expect(result).toEqual(mockWallet) + expect(mockStorage.getActiveWallet).toHaveBeenCalled() + }) + + it('should return null and log error when no active wallet', () => { + const mockStorage = { + getActiveWallet: vi.fn(() => null), + } as unknown as WalletStorageService + + vi.mocked(p.log.error).mockImplementation(() => {}) + vi.mocked(p.outro).mockImplementation(() => {}) + + const result = ensureActiveWallet(mockStorage) + expect(result).toBeNull() + expect(p.log.error).toHaveBeenCalledWith(expect.stringContaining('No active wallet')) + expect(p.outro).toHaveBeenCalledWith('Setup required') + }) + }) + + describe('ensureChainConfigured', () => { + it('should return chain config when chain exists', () => { + const mockChain: ChainConfig = { + name: 'Ethereum', + shortName: 'eth', + chainId: '1', + rpc: 'https://eth.llamarpc.com', + explorer: 'https://etherscan.io', + color: 'blue', + } + const mockConfigStore = { + getChain: vi.fn(() => mockChain), + } as unknown as ConfigStore + + const result = ensureChainConfigured('1', mockConfigStore) + expect(result).toEqual(mockChain) + expect(mockConfigStore.getChain).toHaveBeenCalledWith('1') + }) + + it('should return null and log error when chain not found', () => { + const mockConfigStore = { + getChain: vi.fn(() => null), + } as unknown as ConfigStore + + vi.mocked(p.log.error).mockImplementation(() => {}) + vi.mocked(p.outro).mockImplementation(() => {}) + + const result = ensureChainConfigured('999', mockConfigStore) + expect(result).toBeNull() + expect(p.log.error).toHaveBeenCalledWith('Chain 999 not found in configuration') + expect(p.outro).toHaveBeenCalledWith('Failed') + }) + }) + + describe('handleCommandError', () => { + it('should handle SafeCLIError', () => { + const error = new SafeCLIError('Test error message') + vi.mocked(p.log.error).mockImplementation(() => {}) + vi.mocked(p.outro).mockImplementation(() => {}) + + handleCommandError(error) + expect(p.log.error).toHaveBeenCalledWith('Test error message') + expect(p.outro).toHaveBeenCalledWith('Failed') + }) + + it('should handle generic Error', () => { + const error = new Error('Generic error') + vi.mocked(p.log.error).mockImplementation(() => {}) + vi.mocked(p.outro).mockImplementation(() => {}) + + handleCommandError(error) + expect(p.log.error).toHaveBeenCalledWith('Unexpected error: Generic error') + expect(p.outro).toHaveBeenCalledWith('Failed') + }) + + it('should handle unknown error type', () => { + vi.mocked(p.log.error).mockImplementation(() => {}) + vi.mocked(p.outro).mockImplementation(() => {}) + + handleCommandError('string error') + expect(p.log.error).toHaveBeenCalledWith('Unexpected error: Unknown error') + expect(p.outro).toHaveBeenCalledWith('Failed') + }) + + it('should use custom outro message', () => { + const error = new SafeCLIError('Test error') + vi.mocked(p.log.error).mockImplementation(() => {}) + vi.mocked(p.outro).mockImplementation(() => {}) + + handleCommandError(error, 'Custom outro') + expect(p.outro).toHaveBeenCalledWith('Custom outro') + }) + }) + + describe('checkCancelled', () => { + it('should return true for non-symbol values', () => { + expect(checkCancelled('string')).toBe(true) + expect(checkCancelled(123)).toBe(true) + expect(checkCancelled({ key: 'value' })).toBe(true) + }) + + it('should return false for cancelled symbol and call p.cancel', () => { + const cancelSymbol = Symbol('cancel') + vi.mocked(p.isCancel).mockReturnValue(true) + vi.mocked(p.cancel).mockImplementation(() => {}) + + const result = checkCancelled(cancelSymbol) + expect(result).toBe(false) + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled') + }) + }) + + describe('promptPassword', () => { + it('should prompt for password without confirmation when not creating', async () => { + vi.mocked(p.password).mockResolvedValue('password123') + vi.mocked(p.isCancel).mockReturnValue(false) + + const result = await promptPassword(false) + expect(result).toBe('password123') + expect(p.password).toHaveBeenCalledTimes(1) + }) + + it('should prompt for password with confirmation when creating', async () => { + vi.mocked(p.password) + .mockResolvedValueOnce('password123') + .mockResolvedValueOnce('password123') + vi.mocked(p.isCancel).mockReturnValue(false) + + const result = await promptPassword(true) + expect(result).toBe('password123') + expect(p.password).toHaveBeenCalledTimes(2) + }) + + it('should return null if password prompt is cancelled', async () => { + const cancelSymbol = Symbol('cancel') + vi.mocked(p.password).mockResolvedValue(cancelSymbol) + vi.mocked(p.isCancel).mockReturnValue(true) + vi.mocked(p.cancel).mockImplementation(() => {}) + + const result = await promptPassword(false) + expect(result).toBeNull() + }) + + it('should return null if confirmation prompt is cancelled', async () => { + const cancelSymbol = Symbol('cancel') + vi.mocked(p.password).mockResolvedValueOnce('password123').mockResolvedValueOnce(cancelSymbol) + vi.mocked(p.isCancel).mockReturnValueOnce(false).mockReturnValueOnce(true) + vi.mocked(p.cancel).mockImplementation(() => {}) + + const result = await promptPassword(true) + expect(result).toBeNull() + }) + + it('should use custom message', async () => { + vi.mocked(p.password).mockResolvedValue('password123') + vi.mocked(p.isCancel).mockReturnValue(false) + + await promptPassword(false, 'Custom message', 10) + expect(p.password).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Custom message', + }) + ) + }) + }) + + describe('output', () => { + let consoleLogSpy: ReturnType + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + }) + + it('should output JSON in JSON mode', async () => { + const { isJsonMode } = await import('../../../types/global-options.js') + vi.mocked(isJsonMode).mockReturnValue(true) + + const data = { key: 'value' } + output(data) + expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2)) + }) + + it('should use text formatter when provided and not in JSON mode', async () => { + const { isJsonMode } = await import('../../../types/global-options.js') + vi.mocked(isJsonMode).mockReturnValue(false) + + const data = { key: 'value' } + const formatter = (d: unknown) => `Formatted: ${JSON.stringify(d)}` + output(data, formatter) + expect(consoleLogSpy).toHaveBeenCalledWith('Formatted: {"key":"value"}') + }) + + it('should output JSON when no formatter provided and not in JSON mode', async () => { + const { isJsonMode } = await import('../../../types/global-options.js') + vi.mocked(isJsonMode).mockReturnValue(false) + + const data = { key: 'value' } + output(data) + expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2)) + }) + }) + + describe('outputSuccess', () => { + let consoleLogSpy: ReturnType + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + vi.mocked(p.outro).mockImplementation(() => {}) + }) + + it('should output JSON in JSON mode', async () => { + const { isJsonMode, isQuietMode } = await import('../../../types/global-options.js') + vi.mocked(isJsonMode).mockReturnValue(true) + vi.mocked(isQuietMode).mockReturnValue(false) + + outputSuccess('Success message', { extra: 'data' }) + expect(consoleLogSpy).toHaveBeenCalledWith( + JSON.stringify({ success: true, message: 'Success message', extra: 'data' }, null, 2) + ) + }) + + it('should use p.outro in text mode', async () => { + const { isJsonMode, isQuietMode } = await import('../../../types/global-options.js') + vi.mocked(isJsonMode).mockReturnValue(false) + vi.mocked(isQuietMode).mockReturnValue(false) + + outputSuccess('Success message') + expect(p.outro).toHaveBeenCalledWith('Success message') + }) + + it('should not output in quiet mode', async () => { + const { isJsonMode, isQuietMode } = await import('../../../types/global-options.js') + vi.mocked(isJsonMode).mockReturnValue(false) + vi.mocked(isQuietMode).mockReturnValue(true) + + outputSuccess('Success message') + expect(p.outro).not.toHaveBeenCalled() + }) + }) + + describe('outputError', () => { + let consoleLogSpy: ReturnType + let processExitSpy: ReturnType + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + vi.mocked(p.log.error).mockImplementation(() => {}) + vi.mocked(p.outro).mockImplementation(() => {}) + }) + + it('should output JSON in JSON mode and exit', async () => { + const { isJsonMode } = await import('../../../types/global-options.js') + vi.mocked(isJsonMode).mockReturnValue(true) + + outputError('Error message', 1) + expect(consoleLogSpy).toHaveBeenCalledWith( + JSON.stringify({ success: false, error: 'Error message', exitCode: 1 }, null, 2) + ) + expect(processExitSpy).toHaveBeenCalledWith(1) + }) + + it('should use p.log.error in text mode and exit', async () => { + const { isJsonMode, isQuietMode } = await import('../../../types/global-options.js') + vi.mocked(isJsonMode).mockReturnValue(false) + vi.mocked(isQuietMode).mockReturnValue(false) + + outputError('Error message', 2) + expect(p.log.error).toHaveBeenCalledWith('Error message') + expect(p.outro).toHaveBeenCalledWith('Failed') + expect(processExitSpy).toHaveBeenCalledWith(2) + }) + + it('should not call p.outro in quiet mode', async () => { + const { isJsonMode, isQuietMode } = await import('../../../types/global-options.js') + vi.mocked(isJsonMode).mockReturnValue(false) + vi.mocked(isQuietMode).mockReturnValue(true) + + outputError('Error message') + expect(p.outro).not.toHaveBeenCalled() + expect(processExitSpy).toHaveBeenCalled() + }) + }) + + describe('isNonInteractiveMode', () => { + it('should return true when in JSON mode', async () => { + const { isJsonMode, isQuietMode } = await import('../../../types/global-options.js') + vi.mocked(isJsonMode).mockReturnValue(true) + vi.mocked(isQuietMode).mockReturnValue(false) + + expect(isNonInteractiveMode()).toBe(true) + }) + + it('should return true when in quiet mode', async () => { + const { isJsonMode, isQuietMode } = await import('../../../types/global-options.js') + vi.mocked(isJsonMode).mockReturnValue(false) + vi.mocked(isQuietMode).mockReturnValue(true) + + expect(isNonInteractiveMode()).toBe(true) + }) + + it('should return false in interactive mode', async () => { + const { isJsonMode, isQuietMode } = await import('../../../types/global-options.js') + vi.mocked(isJsonMode).mockReturnValue(false) + vi.mocked(isQuietMode).mockReturnValue(false) + + expect(isNonInteractiveMode()).toBe(false) + }) + }) +}) diff --git a/src/tests/unit/utils/password-handler.test.ts b/src/tests/unit/utils/password-handler.test.ts new file mode 100644 index 0000000..e3f8572 --- /dev/null +++ b/src/tests/unit/utils/password-handler.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { getPassword, validatePasswordSecurity, ENV_VARS } from '../../../utils/password-handler.js' +import * as p from '@clack/prompts' +import { writeFileSync, unlinkSync } from 'fs' +import { resolve } from 'path' + +// Mock modules +vi.mock('@clack/prompts') +vi.mock('../../../utils/command-helpers.js', () => ({ + isNonInteractiveMode: vi.fn(() => false), +})) + +describe('password-handler', () => { + let originalArgv: string[] + let originalEnv: NodeJS.ProcessEnv + + beforeEach(() => { + vi.clearAllMocks() + originalArgv = process.argv + originalEnv = { ...process.env } + }) + + afterEach(() => { + process.argv = originalArgv + process.env = originalEnv + }) + + describe('getPassword', () => { + it('should prioritize environment variable password', async () => { + process.env.TEST_PASSWORD = 'env-password' + + const result = await getPassword({ + passwordEnv: 'TEST_PASSWORD', + password: 'cli-password', + }) + + expect(result).toBe('env-password') + }) + + it('should fall back to prompt when env var not found', async () => { + vi.mocked(p.log.warn).mockImplementation(() => {}) + vi.mocked(p.password).mockResolvedValue('prompt-password') + vi.mocked(p.isCancel).mockReturnValue(false) + + const result = await getPassword({ + passwordEnv: 'NON_EXISTENT_VAR', + }) + + expect(result).toBe('prompt-password') + expect(p.log.warn).toHaveBeenCalledWith( + expect.stringContaining('Environment variable NON_EXISTENT_VAR not found') + ) + }) + + it('should read password from file', async () => { + const testFile = resolve('/tmp/test-password.txt') + writeFileSync(testFile, 'file-password\n') + + try { + const result = await getPassword({ + passwordFile: testFile, + }) + + expect(result).toBe('file-password') + } finally { + unlinkSync(testFile) + } + }) + + it('should trim whitespace from file password', async () => { + const testFile = resolve('/tmp/test-password-whitespace.txt') + writeFileSync(testFile, ' file-password \n\n') + + try { + const result = await getPassword({ + passwordFile: testFile, + }) + + expect(result).toBe('file-password') + } finally { + unlinkSync(testFile) + } + }) + + it('should return null on file read error', async () => { + vi.mocked(p.log.error).mockImplementation(() => {}) + + const result = await getPassword({ + passwordFile: '/nonexistent/file.txt', + }) + + expect(result).toBeNull() + expect(p.log.error).toHaveBeenCalledWith(expect.stringContaining('Failed to read password')) + }) + + it('should use CLI flag password and show warning', async () => { + process.argv = ['node', 'script.js', '--password', 'cli-password'] + vi.mocked(p.log.warn).mockImplementation(() => {}) + + const result = await getPassword({ + password: 'cli-password', + }) + + expect(result).toBe('cli-password') + expect(p.log.warn).toHaveBeenCalledWith( + expect.stringContaining('Password provided via CLI argument') + ) + }) + + it('should use CLI flag password without warning when not in argv', async () => { + process.argv = ['node', 'script.js'] + vi.mocked(p.log.warn).mockImplementation(() => {}) + + const result = await getPassword({ + password: 'cli-password', + }) + + expect(result).toBe('cli-password') + expect(p.log.warn).not.toHaveBeenCalled() + }) + + it('should prompt for password in interactive mode', async () => { + const { isNonInteractiveMode } = await import('../../../utils/command-helpers.js') + vi.mocked(isNonInteractiveMode).mockReturnValue(false) + vi.mocked(p.password).mockResolvedValue('prompt-password') + vi.mocked(p.isCancel).mockReturnValue(false) + + const result = await getPassword({}) + + expect(result).toBe('prompt-password') + expect(p.password).toHaveBeenCalledWith({ + message: 'Enter wallet password', + }) + }) + + it('should use custom prompt message', async () => { + const { isNonInteractiveMode } = await import('../../../utils/command-helpers.js') + vi.mocked(isNonInteractiveMode).mockReturnValue(false) + vi.mocked(p.password).mockResolvedValue('password') + vi.mocked(p.isCancel).mockReturnValue(false) + + await getPassword({}, 'Custom password prompt') + + expect(p.password).toHaveBeenCalledWith({ + message: 'Custom password prompt', + }) + }) + + it('should return null when prompt is cancelled', async () => { + const { isNonInteractiveMode } = await import('../../../utils/command-helpers.js') + vi.mocked(isNonInteractiveMode).mockReturnValue(false) + const cancelSymbol = Symbol('cancel') + vi.mocked(p.password).mockResolvedValue(cancelSymbol) + vi.mocked(p.isCancel).mockReturnValue(true) + + const result = await getPassword({}) + + expect(result).toBeNull() + }) + + it('should return null in non-interactive mode without prompt', async () => { + const { isNonInteractiveMode } = await import('../../../utils/command-helpers.js') + vi.mocked(isNonInteractiveMode).mockReturnValue(true) + + const result = await getPassword({}) + + expect(result).toBeNull() + expect(p.password).not.toHaveBeenCalled() + }) + + it('should prioritize env over file over CLI', async () => { + process.env.TEST_PASSWORD = 'env-password' + const testFile = resolve('/tmp/test-priority.txt') + writeFileSync(testFile, 'file-password') + + try { + const result = await getPassword({ + passwordEnv: 'TEST_PASSWORD', + passwordFile: testFile, + password: 'cli-password', + }) + + expect(result).toBe('env-password') + } finally { + unlinkSync(testFile) + } + }) + + it('should prioritize file over CLI when env not set', async () => { + const testFile = resolve('/tmp/test-priority2.txt') + writeFileSync(testFile, 'file-password') + + try { + const result = await getPassword({ + passwordFile: testFile, + password: 'cli-password', + }) + + expect(result).toBe('file-password') + } finally { + unlinkSync(testFile) + } + }) + }) + + describe('validatePasswordSecurity', () => { + it('should show warning when password in CLI args with --password flag', () => { + process.argv = ['node', 'script.js', '--password', 'test'] + vi.mocked(p.log.warn).mockImplementation(() => {}) + + validatePasswordSecurity({ password: 'test' }) + + expect(p.log.warn).toHaveBeenCalledWith(expect.stringContaining('SECURITY WARNING')) + expect(p.log.warn).toHaveBeenCalledWith( + expect.stringContaining('Password provided via CLI argument') + ) + }) + + it('should show warning when password in CLI args with -p flag', () => { + process.argv = ['node', 'script.js', '-p', 'test'] + vi.mocked(p.log.warn).mockImplementation(() => {}) + + validatePasswordSecurity({ password: 'test' }) + + expect(p.log.warn).toHaveBeenCalledWith(expect.stringContaining('SECURITY WARNING')) + }) + + it('should not show warning when password not in CLI args', () => { + process.argv = ['node', 'script.js'] + vi.mocked(p.log.warn).mockImplementation(() => {}) + + validatePasswordSecurity({ password: 'test' }) + + expect(p.log.warn).not.toHaveBeenCalled() + }) + + it('should not show warning when no password provided', () => { + process.argv = ['node', 'script.js', '--password', 'test'] + vi.mocked(p.log.warn).mockImplementation(() => {}) + + validatePasswordSecurity({}) + + expect(p.log.warn).not.toHaveBeenCalled() + }) + }) + + describe('ENV_VARS', () => { + it('should export standard environment variable names', () => { + expect(ENV_VARS.WALLET_PASSWORD).toBe('SAFE_WALLET_PASSWORD') + expect(ENV_VARS.ACTIVE_WALLET).toBe('SAFE_ACTIVE_WALLET') + expect(ENV_VARS.CONFIG_DIR).toBe('SAFE_CONFIG_DIR') + expect(ENV_VARS.OUTPUT_FORMAT).toBe('SAFE_OUTPUT_FORMAT') + expect(ENV_VARS.SAFE_API_KEY).toBe('SAFE_API_KEY') + expect(ENV_VARS.ETHERSCAN_API_KEY).toBe('ETHERSCAN_API_KEY') + }) + }) +}) diff --git a/src/tests/unit/utils/safe-helpers.test.ts b/src/tests/unit/utils/safe-helpers.test.ts new file mode 100644 index 0000000..71c3337 --- /dev/null +++ b/src/tests/unit/utils/safe-helpers.test.ts @@ -0,0 +1,468 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + selectDeployedSafe, + fetchSafeOwnersAndThreshold, + ensureWalletIsOwner, + parseAddressInput, + selectTransaction, +} from '../../../utils/safe-helpers.js' +import * as p from '@clack/prompts' +import type { SafeAccountStorage } from '../../../storage/safe-store.js' +import type { ConfigStore } from '../../../storage/config-store.js' +import type { TransactionStore } from '../../../storage/transaction-store.js' +import type { SafeAccount } from '../../../types/safe.js' +import type { ChainConfig } from '../../../types/config.js' +import type { Wallet } from '../../../types/wallet.js' +import type { StoredTransaction } from '../../../types/transaction.js' +import { TEST_ADDRESSES, TEST_CHAINS } from '../../fixtures/index.js' +import type { Address } from 'viem' + +// Mock modules +vi.mock('@clack/prompts') +vi.mock('../../../services/transaction-service.js') + +describe('safe-helpers', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('selectDeployedSafe', () => { + it('should return selected safe when available', async () => { + const mockSafe: SafeAccount = { + name: 'My Safe', + address: TEST_ADDRESSES.safe1, + chainId: '1', + deployed: true, + threshold: 1, + owners: [TEST_ADDRESSES.owner1], + } + + const mockStorage = { + getAllSafes: vi.fn(() => [mockSafe]), + } as unknown as SafeAccountStorage + + const mockConfigStore = { + getChain: vi.fn(() => TEST_CHAINS.ethereum), + } as unknown as ConfigStore + + const chains = { '1': TEST_CHAINS.ethereum } + + vi.mocked(p.select).mockResolvedValue(`1:${TEST_ADDRESSES.safe1}`) + vi.mocked(p.isCancel).mockReturnValue(false) + + const result = await selectDeployedSafe(mockStorage, mockConfigStore, chains) + expect(result).toEqual({ + chainId: '1', + address: TEST_ADDRESSES.safe1, + }) + expect(p.select).toHaveBeenCalled() + }) + + it('should return null when no deployed safes found', async () => { + const mockStorage = { + getAllSafes: vi.fn(() => []), + } as unknown as SafeAccountStorage + + const mockConfigStore = {} as ConfigStore + const chains = {} + + vi.mocked(p.log.error).mockImplementation(() => {}) + vi.mocked(p.cancel).mockImplementation(() => {}) + + const result = await selectDeployedSafe(mockStorage, mockConfigStore, chains) + expect(result).toBeNull() + expect(p.log.error).toHaveBeenCalledWith('No deployed Safes found') + expect(p.cancel).toHaveBeenCalled() + }) + + it('should return null when selection is cancelled', async () => { + const mockSafe: SafeAccount = { + name: 'My Safe', + address: TEST_ADDRESSES.safe1, + chainId: '1', + deployed: true, + threshold: 1, + owners: [TEST_ADDRESSES.owner1], + } + + const mockStorage = { + getAllSafes: vi.fn(() => [mockSafe]), + } as unknown as SafeAccountStorage + + const mockConfigStore = { + getChain: vi.fn(() => TEST_CHAINS.ethereum), + } as unknown as ConfigStore + + const chains = { '1': TEST_CHAINS.ethereum } + + const cancelSymbol = Symbol('cancel') + vi.mocked(p.select).mockResolvedValue(cancelSymbol) + vi.mocked(p.isCancel).mockReturnValue(true) + vi.mocked(p.cancel).mockImplementation(() => {}) + + const result = await selectDeployedSafe(mockStorage, mockConfigStore, chains) + expect(result).toBeNull() + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled') + }) + + it('should filter out non-deployed safes', async () => { + const deployedSafe: SafeAccount = { + name: 'Deployed Safe', + address: TEST_ADDRESSES.safe1, + chainId: '1', + deployed: true, + threshold: 1, + owners: [TEST_ADDRESSES.owner1], + } + + const nonDeployedSafe: SafeAccount = { + name: 'Not Deployed', + address: TEST_ADDRESSES.owner2, + chainId: '1', + deployed: false, + threshold: 1, + owners: [TEST_ADDRESSES.owner1], + } + + const mockStorage = { + getAllSafes: vi.fn(() => [deployedSafe, nonDeployedSafe]), + } as unknown as SafeAccountStorage + + const mockConfigStore = { + getChain: vi.fn(() => TEST_CHAINS.ethereum), + } as unknown as ConfigStore + + const chains = { '1': TEST_CHAINS.ethereum } + + vi.mocked(p.select).mockResolvedValue(`1:${TEST_ADDRESSES.safe1}`) + vi.mocked(p.isCancel).mockReturnValue(false) + + await selectDeployedSafe(mockStorage, mockConfigStore, chains) + + // Verify only deployed safe is in options + const selectCall = vi.mocked(p.select).mock.calls[0][0] + expect(selectCall.options).toHaveLength(1) + expect(selectCall.options[0].label).toContain('Deployed Safe') + }) + }) + + describe('fetchSafeOwnersAndThreshold', () => { + it('should return owners and threshold on success', async () => { + const mockChain: ChainConfig = TEST_CHAINS.ethereum + const mockOwners = [TEST_ADDRESSES.owner1, TEST_ADDRESSES.owner2] + const mockThreshold = 2 + + const mockSpinner = { + start: vi.fn(), + stop: vi.fn(), + } + vi.mocked(p.spinner).mockReturnValue(mockSpinner as any) + + const { TransactionService } = await import('../../../services/transaction-service.js') + vi.mocked(TransactionService).mockImplementation( + () => + ({ + getOwners: vi.fn().mockResolvedValue(mockOwners), + getThreshold: vi.fn().mockResolvedValue(mockThreshold), + }) as any + ) + + const result = await fetchSafeOwnersAndThreshold(mockChain, TEST_ADDRESSES.safe1) + expect(result).toEqual({ owners: mockOwners, threshold: mockThreshold }) + expect(mockSpinner.start).toHaveBeenCalled() + expect(mockSpinner.stop).toHaveBeenCalledWith('Safe information fetched') + }) + + it('should return null and log error on failure', async () => { + const mockChain: ChainConfig = TEST_CHAINS.ethereum + + const mockSpinner = { + start: vi.fn(), + stop: vi.fn(), + } + vi.mocked(p.spinner).mockReturnValue(mockSpinner as any) + vi.mocked(p.log.error).mockImplementation(() => {}) + vi.mocked(p.outro).mockImplementation(() => {}) + + const { TransactionService } = await import('../../../services/transaction-service.js') + vi.mocked(TransactionService).mockImplementation( + () => + ({ + getOwners: vi.fn().mockRejectedValue(new Error('Network error')), + getThreshold: vi.fn().mockResolvedValue(1), + }) as any + ) + + const result = await fetchSafeOwnersAndThreshold(mockChain, TEST_ADDRESSES.safe1) + expect(result).toBeNull() + expect(mockSpinner.stop).toHaveBeenCalledWith('Failed to fetch Safe information') + expect(p.log.error).toHaveBeenCalled() + expect(p.outro).toHaveBeenCalledWith('Failed') + }) + }) + + describe('ensureWalletIsOwner', () => { + it('should return true when wallet is an owner', () => { + const wallet: Wallet = { + name: 'test-wallet', + address: TEST_ADDRESSES.owner1, + encryptedPrivateKey: 'encrypted', + type: 'private-key', + } + const owners = [TEST_ADDRESSES.owner1, TEST_ADDRESSES.owner2] + + const result = ensureWalletIsOwner(wallet, owners) + expect(result).toBe(true) + }) + + it('should handle case-insensitive address comparison', () => { + const wallet: Wallet = { + name: 'test-wallet', + address: TEST_ADDRESSES.owner1.toUpperCase() as Address, + encryptedPrivateKey: 'encrypted', + type: 'private-key', + } + const owners = [TEST_ADDRESSES.owner1.toLowerCase() as Address, TEST_ADDRESSES.owner2] + + const result = ensureWalletIsOwner(wallet, owners) + expect(result).toBe(true) + }) + + it('should return false and log error when wallet is not an owner', () => { + const wallet: Wallet = { + name: 'test-wallet', + address: TEST_ADDRESSES.safe1, + encryptedPrivateKey: 'encrypted', + type: 'private-key', + } + const owners = [TEST_ADDRESSES.owner1, TEST_ADDRESSES.owner2] + + vi.mocked(p.log.error).mockImplementation(() => {}) + vi.mocked(p.outro).mockImplementation(() => {}) + + const result = ensureWalletIsOwner(wallet, owners) + expect(result).toBe(false) + expect(p.log.error).toHaveBeenCalledWith('Active wallet is not an owner of this Safe') + expect(p.outro).toHaveBeenCalledWith('Failed') + }) + }) + + describe('parseAddressInput', () => { + it('should parse valid EIP-3770 address', () => { + const chains = { '1': TEST_CHAINS.ethereum } + const input = `eth:${TEST_ADDRESSES.safe1}` + + const result = parseAddressInput(input, chains) + expect(result).toEqual({ + chainId: '1', + address: TEST_ADDRESSES.safe1, + }) + }) + + it('should return null and log error on invalid address', () => { + const chains = { '1': TEST_CHAINS.ethereum } + const input = 'invalid:0xinvalid' + + vi.mocked(p.log.error).mockImplementation(() => {}) + vi.mocked(p.cancel).mockImplementation(() => {}) + + const result = parseAddressInput(input, chains) + expect(result).toBeNull() + expect(p.log.error).toHaveBeenCalled() + expect(p.cancel).toHaveBeenCalledWith('Operation cancelled') + }) + }) + + describe('selectTransaction', () => { + it('should return selected transaction hash', async () => { + const mockTx: StoredTransaction = { + safeTxHash: '0xabc123', + chainId: '1', + safeAddress: TEST_ADDRESSES.safe1, + status: 'pending', + metadata: { + to: TEST_ADDRESSES.owner1, + value: '0', + data: '0x', + operation: 0, + nonce: 0, + }, + signatures: [], + createdAt: Date.now(), + updatedAt: Date.now(), + } + + const mockTxStore = { + getAllTransactions: vi.fn(() => [mockTx]), + } as unknown as TransactionStore + + const mockSafeStorage = { + getSafe: vi.fn(() => ({ + name: 'My Safe', + address: TEST_ADDRESSES.safe1, + chainId: '1', + })), + } as unknown as SafeAccountStorage + + const mockConfigStore = { + getAllChains: vi.fn(() => ({ '1': TEST_CHAINS.ethereum })), + } as unknown as ConfigStore + + vi.mocked(p.select).mockResolvedValue('0xabc123') + vi.mocked(p.isCancel).mockReturnValue(false) + + const result = await selectTransaction(mockTxStore, mockSafeStorage, mockConfigStore) + expect(result).toBe('0xabc123') + }) + + it('should filter transactions by status', async () => { + const pendingTx: StoredTransaction = { + safeTxHash: '0xpending', + chainId: '1', + safeAddress: TEST_ADDRESSES.safe1, + status: 'pending', + metadata: { + to: TEST_ADDRESSES.owner1, + value: '0', + data: '0x', + operation: 0, + nonce: 0, + }, + signatures: [], + createdAt: Date.now(), + updatedAt: Date.now(), + } + + const executedTx: StoredTransaction = { + ...pendingTx, + safeTxHash: '0xexecuted', + status: 'executed', + } + + const mockTxStore = { + getAllTransactions: vi.fn(() => [pendingTx, executedTx]), + } as unknown as TransactionStore + + const mockSafeStorage = { + getSafe: vi.fn(() => ({ + name: 'My Safe', + address: TEST_ADDRESSES.safe1, + chainId: '1', + })), + } as unknown as SafeAccountStorage + + const mockConfigStore = { + getAllChains: vi.fn(() => ({ '1': TEST_CHAINS.ethereum })), + } as unknown as ConfigStore + + vi.mocked(p.select).mockResolvedValue('0xpending') + vi.mocked(p.isCancel).mockReturnValue(false) + + await selectTransaction(mockTxStore, mockSafeStorage, mockConfigStore, ['pending']) + + const selectCall = vi.mocked(p.select).mock.calls[0][0] + expect(selectCall.options).toHaveLength(1) + expect(selectCall.options[0].value).toBe('0xpending') + }) + + it('should return null when no transactions found', async () => { + const mockTxStore = { + getAllTransactions: vi.fn(() => []), + } as unknown as TransactionStore + + const mockSafeStorage = {} as SafeAccountStorage + const mockConfigStore = {} as ConfigStore + + vi.mocked(p.log.error).mockImplementation(() => {}) + vi.mocked(p.outro).mockImplementation(() => {}) + + const result = await selectTransaction(mockTxStore, mockSafeStorage, mockConfigStore) + expect(result).toBeNull() + expect(p.log.error).toHaveBeenCalledWith('No transactions found') + }) + + it('should return null when selection is cancelled', async () => { + const mockTx: StoredTransaction = { + safeTxHash: '0xabc123', + chainId: '1', + safeAddress: TEST_ADDRESSES.safe1, + status: 'pending', + metadata: { + to: TEST_ADDRESSES.owner1, + value: '0', + data: '0x', + operation: 0, + nonce: 0, + }, + signatures: [], + createdAt: Date.now(), + updatedAt: Date.now(), + } + + const mockTxStore = { + getAllTransactions: vi.fn(() => [mockTx]), + } as unknown as TransactionStore + + const mockSafeStorage = { + getSafe: vi.fn(() => ({ name: 'My Safe' })), + } as unknown as SafeAccountStorage + + const mockConfigStore = { + getAllChains: vi.fn(() => ({ '1': TEST_CHAINS.ethereum })), + } as unknown as ConfigStore + + const cancelSymbol = Symbol('cancel') + vi.mocked(p.select).mockResolvedValue(cancelSymbol) + vi.mocked(p.isCancel).mockReturnValue(true) + vi.mocked(p.cancel).mockImplementation(() => {}) + + const result = await selectTransaction(mockTxStore, mockSafeStorage, mockConfigStore) + expect(result).toBeNull() + }) + + it('should use custom message', async () => { + const mockTx: StoredTransaction = { + safeTxHash: '0xabc123', + chainId: '1', + safeAddress: TEST_ADDRESSES.safe1, + status: 'pending', + metadata: { + to: TEST_ADDRESSES.owner1, + value: '0', + data: '0x', + operation: 0, + nonce: 0, + }, + signatures: [], + createdAt: Date.now(), + updatedAt: Date.now(), + } + + const mockTxStore = { + getAllTransactions: vi.fn(() => [mockTx]), + } as unknown as TransactionStore + + const mockSafeStorage = { + getSafe: vi.fn(() => ({ name: 'My Safe' })), + } as unknown as SafeAccountStorage + + const mockConfigStore = { + getAllChains: vi.fn(() => ({ '1': TEST_CHAINS.ethereum })), + } as unknown as ConfigStore + + vi.mocked(p.select).mockResolvedValue('0xabc123') + vi.mocked(p.isCancel).mockReturnValue(false) + + await selectTransaction( + mockTxStore, + mockSafeStorage, + mockConfigStore, + undefined, + 'Custom select message' + ) + + const selectCall = vi.mocked(p.select).mock.calls[0][0] + expect(selectCall.message).toBe('Custom select message') + }) + }) +})