diff --git a/app/src-tauri/src/core_process.rs b/app/src-tauri/src/core_process.rs index 6a1fd1204..3c2b59ac3 100644 --- a/app/src-tauri/src/core_process.rs +++ b/app/src-tauri/src/core_process.rs @@ -258,7 +258,8 @@ pub fn default_core_bin() -> Option { continue; }; #[cfg(windows)] - let matches = file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe"); + let matches = + file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe"); #[cfg(not(windows))] let matches = file_name.starts_with("openhuman-core-"); if matches { @@ -321,8 +322,8 @@ pub fn default_core_bin() -> Option { let matches = (file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe")) || (file_name.starts_with("openhuman-core-") && file_name.ends_with(".exe")); #[cfg(not(windows))] - let matches = - file_name.starts_with("openhuman-core-") || file_name.starts_with("openhuman-core-"); + let matches = file_name.starts_with("openhuman-core-") + || file_name.starts_with("openhuman-core-"); if matches && !same_executable_path(&path, &exe) { return Some(path); @@ -332,3 +333,84 @@ pub fn default_core_bin() -> Option { None } + +#[cfg(test)] +mod tests { + use super::{ + default_core_port, default_core_run_mode, same_executable_path, CoreProcessHandle, + CoreRunMode, + }; + + struct EnvGuard { + key: &'static str, + old: Option, + } + + impl EnvGuard { + fn set(key: &'static str, value: &str) -> Self { + let old = std::env::var(key).ok(); + std::env::set_var(key, value); + Self { key, old } + } + + fn unset(key: &'static str) -> Self { + let old = std::env::var(key).ok(); + std::env::remove_var(key); + Self { key, old } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + if let Some(old) = &self.old { + std::env::set_var(self.key, old); + } else { + std::env::remove_var(self.key); + } + } + } + + #[test] + fn default_core_run_mode_env_parsing() { + let _guard = EnvGuard::set("OPENHUMAN_CORE_RUN_MODE", "in-process"); + assert_eq!(default_core_run_mode(false), CoreRunMode::InProcess); + + let _guard = EnvGuard::set("OPENHUMAN_CORE_RUN_MODE", "sidecar"); + assert_eq!(default_core_run_mode(false), CoreRunMode::ChildProcess); + } + + #[test] + fn default_core_port_env_and_fallback() { + let _unset = EnvGuard::unset("OPENHUMAN_CORE_PORT"); + assert_eq!(default_core_port(), 7788); + + let _set = EnvGuard::set("OPENHUMAN_CORE_PORT", "8899"); + assert_eq!(default_core_port(), 8899); + } + + #[test] + fn same_executable_path_handles_equal_and_non_equal_paths() { + let current = std::env::current_exe().expect("current exe"); + assert!(same_executable_path(¤t, ¤t)); + + let different = current.with_file_name("definitely-not-the-current-exe"); + assert!(!same_executable_path(¤t, &different)); + } + + #[test] + fn ensure_running_returns_ok_when_rpc_port_already_open() { + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let result = rt.block_on(async { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test listener"); + let port = listener.local_addr().expect("local addr").port(); + let handle = CoreProcessHandle::new(port, None, CoreRunMode::ChildProcess); + handle.ensure_running().await + }); + assert!( + result.is_ok(), + "ensure_running should fast-path: {result:?}" + ); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 10f0c01c7..19d6951aa 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -41,7 +41,13 @@ fn show_main_window(app: &AppHandle) { fn setup_tray(app: &AppHandle) -> tauri::Result<()> { log::info!("[tray] setting up tray icon"); - let show_item = MenuItem::with_id(app, "tray_show_window", "Open OpenHuman", true, None::<&str>)?; + let show_item = MenuItem::with_id( + app, + "tray_show_window", + "Open OpenHuman", + true, + None::<&str>, + )?; let quit_item = MenuItem::with_id(app, "tray_quit", "Quit", true, None::<&str>)?; let menu = Menu::with_items(app, &[&show_item, &quit_item])?; diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts new file mode 100644 index 000000000..1516eee97 --- /dev/null +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { dispatchLocalAiMethod } from '../../lib/ai/localCoreAiMemory'; +import { callCoreRpc } from '../coreRpcClient'; + +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn(() => false) })); +vi.mock('../../lib/ai/localCoreAiMemory', () => ({ + dispatchLocalAiMethod: vi.fn(async (_method: string) => ({ source: 'local-ai' })), +})); + +describe('coreRpcClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', vi.fn()); + }); + + test('normalizes legacy auth methods from dotted to underscored', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 1, result: { ok: true } }), + } as Response); + + await callCoreRpc({ method: 'openhuman.auth.get_state' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const requestInit = fetchMock.mock.calls[0][1] as RequestInit; + const body = JSON.parse(String(requestInit.body)); + expect(body.method).toBe('openhuman.auth_get_state'); + }); + + test('maps accessibility prefix to screen intelligence prefix', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ jsonrpc: '2.0', id: 2, result: { accepted: true } }), + } as Response); + + await callCoreRpc({ method: 'openhuman.accessibility_status' }); + + const requestInit = fetchMock.mock.calls[0][1] as RequestInit; + const body = JSON.parse(String(requestInit.body)); + expect(body.method).toBe('openhuman.screen_intelligence_status'); + }); + + test('throws clean error when JSON-RPC error payload is returned', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jsonrpc: '2.0', + id: 3, + error: { code: -32000, message: 'boom from core' }, + }), + } as Response); + + await expect(callCoreRpc({ method: 'openhuman.config_get' })).rejects.toThrow('boom from core'); + }); + + test('throws on non-ok HTTP response', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + text: async () => 'temporarily unavailable', + } as Response); + + await expect(callCoreRpc({ method: 'openhuman.config_get' })).rejects.toThrow( + 'Core RPC HTTP 503: temporarily unavailable' + ); + }); + + test('routes ai methods to local dispatch without HTTP', async () => { + const localDispatchMock = vi.mocked(dispatchLocalAiMethod); + localDispatchMock.mockResolvedValueOnce({ state: 'ready' }); + + const result = await callCoreRpc<{ state: string }>({ method: 'ai.get_config', params: {} }); + + expect(localDispatchMock).toHaveBeenCalledWith('ai.get_config', {}); + expect(fetch).not.toHaveBeenCalled(); + expect(result).toEqual({ state: 'ready' }); + }); +}); diff --git a/app/src/utils/__tests__/tauriCommands.test.ts b/app/src/utils/__tests__/tauriCommands.test.ts new file mode 100644 index 000000000..e064afc3f --- /dev/null +++ b/app/src/utils/__tests__/tauriCommands.test.ts @@ -0,0 +1,61 @@ +import { isTauri } from '@tauri-apps/api/core'; +import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest'; + +import { callCoreRpc } from '../../services/coreRpcClient'; + +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() })); +vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); + +describe('tauriCommands', () => { + const mockIsTauri = isTauri as Mock; + const mockCallCoreRpc = callCoreRpc as Mock; + let getAuthState: typeof import('../tauriCommands').getAuthState; + let storeSession: typeof import('../tauriCommands').storeSession; + let openhumanLocalAiStatus: typeof import('../tauriCommands').openhumanLocalAiStatus; + let openhumanServiceStatus: typeof import('../tauriCommands').openhumanServiceStatus; + + beforeEach(async () => { + vi.clearAllMocks(); + mockIsTauri.mockReturnValue(true); + const actual = await vi.importActual('../tauriCommands'); + getAuthState = actual.getAuthState; + storeSession = actual.storeSession; + openhumanLocalAiStatus = actual.openhumanLocalAiStatus; + openhumanServiceStatus = actual.openhumanServiceStatus; + }); + + test('getAuthState maps result shape from core response', async () => { + mockCallCoreRpc.mockResolvedValueOnce({ + result: { isAuthenticated: true, user: { id: 'u1' } }, + }); + + const response = await getAuthState(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.auth.get_state' }); + expect(response).toEqual({ is_authenticated: true, user: { id: 'u1' } }); + }); + + test('storeSession calls expected RPC method and params', async () => { + await storeSession('jwt-token', { id: 'u1' }); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.auth.store_session', + params: { token: 'jwt-token', user: { id: 'u1' } }, + }); + }); + + test('openhumanLocalAiStatus returns upgrade hint on unknown method', async () => { + mockCallCoreRpc.mockRejectedValueOnce(new Error('unknown method: openhuman.local_ai_status')); + + await expect(openhumanLocalAiStatus()).rejects.toThrow( + 'Local model runtime is unavailable in this core build. Restart app after updating to the latest build.' + ); + }); + + test('openhumanServiceStatus throws when not running in Tauri', async () => { + mockIsTauri.mockReturnValue(false); + + await expect(openhumanServiceStatus()).rejects.toThrow('Not running in Tauri'); + expect(mockCallCoreRpc).not.toHaveBeenCalled(); + }); +}); diff --git a/app/test/Mnemonic.test.tsx b/app/test/Mnemonic.test.tsx new file mode 100644 index 000000000..105004067 --- /dev/null +++ b/app/test/Mnemonic.test.tsx @@ -0,0 +1,670 @@ +/// +/** + * Tests for the Mnemonic page. + * + * Coverage areas: + * - Initial render: generate mode UI, word grid, buttons + * - Copy to clipboard (success + fallback paths) + * - Confirmation checkbox gates the Continue button + * - Mode switch: generate ↔ import, state resets on switch + * - Import mode: word input, auto-advance, backspace navigation, paste + * - Validation: incomplete phrase, invalid phrase, valid phrase + * - handleContinue — generate mode: happy path, no user, crypto error + * - handleContinue — import mode: happy path, validation failure, no user + * - Loading state during continue + * - Navigation to /home on success + * - Redux dispatch of setEncryptionKeyForUser + */ +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import Mnemonic from '../src/pages/Mnemonic'; +import { renderWithProviders } from '../src/test/test-utils'; +import type { User } from '../src/types/api'; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +const FIXED_MNEMONIC = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon ' + + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art'; + +const { + mockGenerateMnemonicPhrase, + mockValidateMnemonicPhrase, + mockDeriveAesKey, + mockDeriveEvm, + mockSetWalletAddress, +} = vi.hoisted(() => ({ + mockGenerateMnemonicPhrase: vi.fn(() => FIXED_MNEMONIC), + mockValidateMnemonicPhrase: vi.fn(() => true), + mockDeriveAesKey: vi.fn(() => 'aes-key-hex'), + mockDeriveEvm: vi.fn(() => '0xDeAdBeEf'), + mockSetWalletAddress: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../src/utils/cryptoKeys', () => ({ + MNEMONIC_GENERATE_WORD_COUNT: 24, + generateMnemonicPhrase: mockGenerateMnemonicPhrase, + validateMnemonicPhrase: mockValidateMnemonicPhrase, + deriveAesKeyFromMnemonic: mockDeriveAesKey, + deriveEvmAddressFromMnemonic: mockDeriveEvm, +})); + +vi.mock('../src/lib/skills/manager', () => ({ + skillManager: { setWalletAddress: mockSetWalletAddress }, +})); + +// LottieAnimation makes network calls; stub it out +vi.mock('../src/components/LottieAnimation', () => ({ + default: () =>
, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const WORD_COUNT = 24; +const FIXED_WORDS = FIXED_MNEMONIC.split(' '); // 24 words + +/** User with a valid _id so the "user not loaded" guard passes. */ +const mockUser: Partial = { _id: 'user-123', username: 'tester' }; + +/** Render with a user already in the store. */ +const renderWithUser = () => + renderWithProviders(, { + preloadedState: { user: { user: mockUser as User, loading: false, error: null } }, + }); + +/** Render without a user in the store (unauthenticated). */ +const renderWithoutUser = () => renderWithProviders(); + +/** Switch to import mode. */ +const switchToImport = () => fireEvent.click(screen.getByText('I already have a recovery phrase')); + +/** Fill all 24 import inputs with the words from `phrase`. */ +const fillAllImportWords = (phrase = FIXED_MNEMONIC) => { + const words = phrase.split(' '); + const inputs = screen.getAllByRole('textbox'); + // Paste into the first field to trigger multi-word paste handling + fireEvent.change(inputs[0], { target: { value: words.join(' ') } }); +}; + +/** Get the Continue button. */ +const continueButton = () => screen.getByRole('button', { name: /import & continue|let's go/i }); + +// --------------------------------------------------------------------------- +// Generate mode — initial render +// --------------------------------------------------------------------------- + +describe('Mnemonic — generate mode: initial render', () => { + it('renders the "Your Recovery Phrase" heading', () => { + renderWithUser(); + expect(screen.getByText('Your Recovery Phrase')).toBeInTheDocument(); + }); + + it('renders all 24 words from the generated mnemonic', () => { + renderWithUser(); + for (const word of FIXED_WORDS) { + expect(screen.getAllByText(word).length).toBeGreaterThan(0); + } + }); + + it('renders 24 numbered word tiles', () => { + renderWithUser(); + expect(screen.getByText('1.')).toBeInTheDocument(); + expect(screen.getByText('24.')).toBeInTheDocument(); + }); + + it('renders the Copy to Clipboard button', () => { + renderWithUser(); + expect(screen.getByRole('button', { name: /copy to clipboard/i })).toBeInTheDocument(); + }); + + it('renders the amber warning notice', () => { + renderWithUser(); + expect(screen.getByText(/can never be recovered if lost/i)).toBeInTheDocument(); + }); + + it('renders the confirmation checkbox unchecked', () => { + renderWithUser(); + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + it('renders the Continue button disabled before confirmation', () => { + renderWithUser(); + expect(continueButton()).toBeDisabled(); + }); + + it('renders the "I already have a recovery phrase" link', () => { + renderWithUser(); + expect(screen.getByText('I already have a recovery phrase')).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Generate mode — confirmation checkbox +// --------------------------------------------------------------------------- + +describe('Mnemonic — generate mode: confirmation checkbox', () => { + it('enables the Continue button when checkbox is checked', () => { + renderWithUser(); + fireEvent.click(screen.getByRole('checkbox')); + expect(continueButton()).toBeEnabled(); + }); + + it('disables the Continue button again when checkbox is unchecked', () => { + renderWithUser(); + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + fireEvent.click(checkbox); + expect(continueButton()).toBeDisabled(); + }); +}); + +// --------------------------------------------------------------------------- +// Generate mode — copy to clipboard +// --------------------------------------------------------------------------- + +describe('Mnemonic — generate mode: copy to clipboard', () => { + it('calls navigator.clipboard.writeText with the full mnemonic', async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + renderWithUser(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i })); + }); + + expect(writeText).toHaveBeenCalledWith(FIXED_MNEMONIC); + }); + + it('shows "Copied to Clipboard" after clicking copy', async () => { + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }); + renderWithUser(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i })); + }); + + await waitFor(() => expect(screen.getByText('Copied to Clipboard')).toBeInTheDocument()); + }); + + it('resets "Copied" text back to "Copy to Clipboard" after 3 s', async () => { + vi.useFakeTimers(); + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }); + renderWithUser(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i })); + }); + + // Flush the resolved clipboard promise so setCopied(true) fires + await act(async () => { + await vi.runAllTimersAsync(); + }); + + // Now the 3-second reset timer has also been run + expect(screen.queryByText('Copied to Clipboard')).not.toBeInTheDocument(); + vi.useRealTimers(); + }); + + it('uses execCommand fallback when clipboard API throws', async () => { + Object.assign(navigator, { + clipboard: { writeText: vi.fn().mockRejectedValue(new Error('blocked')) }, + }); + // jsdom does not implement execCommand — define it so we can spy + if (!document.execCommand) { + Object.defineProperty(document, 'execCommand', { + value: vi.fn().mockReturnValue(true), + writable: true, + configurable: true, + }); + } + const execCommand = vi.spyOn(document, 'execCommand').mockReturnValue(true); + + renderWithUser(); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /copy to clipboard/i })); + }); + + await waitFor(() => expect(screen.getByText('Copied to Clipboard')).toBeInTheDocument()); + expect(execCommand).toHaveBeenCalledWith('copy'); + execCommand.mockRestore(); + }); +}); + +// --------------------------------------------------------------------------- +// Mode switching +// --------------------------------------------------------------------------- + +describe('Mnemonic — mode switching', () => { + it('switches to import mode on "I already have a recovery phrase" click', () => { + renderWithUser(); + switchToImport(); + expect(screen.getByText('Import Recovery Phrase')).toBeInTheDocument(); + }); + + it('shows 24 text inputs in import mode', () => { + renderWithUser(); + switchToImport(); + expect(screen.getAllByRole('textbox')).toHaveLength(WORD_COUNT); + }); + + it('switches back to generate mode on "Generate a new recovery phrase instead"', () => { + renderWithUser(); + switchToImport(); + fireEvent.click(screen.getByText('Generate a new recovery phrase instead')); + expect(screen.getByText('Your Recovery Phrase')).toBeInTheDocument(); + }); + + it('resets error when switching modes', async () => { + renderWithUser(); + // Trigger an error in generate mode (click continue without confirming) + fireEvent.click(continueButton()); // disabled, won't trigger, so force via import mode + // Switch to import mode and back — confirmed state should reset + switchToImport(); + expect(screen.queryByText(/please enter all/i)).not.toBeInTheDocument(); + }); + + it('resets confirmation when switching from generate to import and back', () => { + renderWithUser(); + fireEvent.click(screen.getByRole('checkbox')); + expect(continueButton()).toBeEnabled(); + + switchToImport(); + fireEvent.click(screen.getByText('Generate a new recovery phrase instead')); + + // Confirmed state is reset — Continue should be disabled again + expect(continueButton()).toBeDisabled(); + }); +}); + +// --------------------------------------------------------------------------- +// Import mode — word input behaviour +// --------------------------------------------------------------------------- + +describe('Mnemonic — import mode: word input', () => { + beforeEach(() => { + renderWithUser(); + switchToImport(); + }); + + it('updates input value when a word is typed', () => { + const inputs = screen.getAllByRole('textbox'); + fireEvent.change(inputs[0], { target: { value: 'abandon' } }); + expect((inputs[0] as HTMLInputElement).value).toBe('abandon'); + }); + + it('lowercases the entered word', () => { + const inputs = screen.getAllByRole('textbox'); + fireEvent.change(inputs[0], { target: { value: 'ABANDON' } }); + expect((inputs[0] as HTMLInputElement).value).toBe('abandon'); + }); + + it('Continue button stays disabled until all 24 words are filled', () => { + expect(continueButton()).toBeDisabled(); + + const inputs = screen.getAllByRole('textbox'); + // Fill only 23 words + for (let i = 0; i < 23; i++) { + fireEvent.change(inputs[i], { target: { value: 'abandon' } }); + } + expect(continueButton()).toBeDisabled(); + }); + + it('Continue button becomes enabled when all 24 words are filled (via paste)', () => { + fillAllImportWords(); + expect(continueButton()).toBeEnabled(); + }); + + it('distributes pasted multi-word phrase across inputs starting at index 0', () => { + const inputs = screen.getAllByRole('textbox'); + fireEvent.change(inputs[0], { target: { value: FIXED_WORDS.join(' ') } }); + + // After paste the first input gets the first word + expect((inputs[0] as HTMLInputElement).value).toBe(FIXED_WORDS[0]); + }); + + it('distributes pasted phrase starting from a non-zero index', () => { + const inputs = screen.getAllByRole('textbox'); + const remaining = FIXED_WORDS.slice(1).join(' '); + fireEvent.change(inputs[1], { target: { value: remaining } }); + expect((inputs[1] as HTMLInputElement).value).toBe(FIXED_WORDS[1]); + expect((inputs[2] as HTMLInputElement).value).toBe(FIXED_WORDS[2]); + }); +}); + +// --------------------------------------------------------------------------- +// Import mode — keyboard navigation +// --------------------------------------------------------------------------- + +describe('Mnemonic — import mode: keyboard navigation', () => { + beforeEach(() => { + renderWithUser(); + switchToImport(); + }); + + it('does not move focus backward on Backspace when input has text', () => { + const inputs = screen.getAllByRole('textbox'); + fireEvent.change(inputs[1], { target: { value: 'test' } }); + inputs[1].focus(); + fireEvent.keyDown(inputs[1], { key: 'Backspace' }); + // focus should stay on inputs[1] + expect(document.activeElement).toBe(inputs[1]); + }); +}); + +// --------------------------------------------------------------------------- +// Import mode — validation +// --------------------------------------------------------------------------- + +describe('Mnemonic — import mode: validation', () => { + beforeEach(() => { + renderWithUser(); + switchToImport(); + }); + + it('shows error when the phrase fails BIP39 validation after all 24 words are entered', async () => { + // The Continue button is only enabled when all 24 inputs are filled, so the + // "please enter all words" branch is unreachable via normal UI. + // The reachable validation error is the invalid-phrase message. + mockValidateMnemonicPhrase.mockReturnValueOnce(false); + fillAllImportWords(); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(screen.getByText(/invalid recovery phrase/i)).toBeInTheDocument()); + }); + + it('shows error when the 24-word phrase is invalid (BIP39)', async () => { + mockValidateMnemonicPhrase.mockReturnValueOnce(false); + fillAllImportWords(); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(screen.getByText(/invalid recovery phrase/i)).toBeInTheDocument()); + }); + + it('shows "Valid recovery phrase" text when phrase passes BIP39 validation', async () => { + mockValidateMnemonicPhrase.mockReturnValue(true); + fillAllImportWords(); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(screen.getByText('Valid recovery phrase')).toBeInTheDocument()); + }); +}); + +// --------------------------------------------------------------------------- +// handleContinue — generate mode +// --------------------------------------------------------------------------- + +describe('Mnemonic — handleContinue: generate mode', () => { + it('shows loading text while processing', async () => { + // Make setWalletAddress hang so we can observe the loading state + let resolveWallet!: () => void; + mockSetWalletAddress.mockReturnValueOnce( + new Promise(res => { + resolveWallet = res; + }) + ); + + renderWithUser(); + fireEvent.click(screen.getByRole('checkbox')); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(screen.getByText('Securing Your Data...')).toBeInTheDocument()); + + await act(async () => { + resolveWallet(); + }); + }); + + it('dispatches setEncryptionKeyForUser with userId and derived AES key', async () => { + const { store } = renderWithUser(); + fireEvent.click(screen.getByRole('checkbox')); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(mockSetWalletAddress).toHaveBeenCalled()); + + const authState = store.getState().auth as unknown as Record; + // The encryption key should be stored under the user's id + expect(authState.encryptionKeyByUser).toMatchObject({ 'user-123': 'aes-key-hex' }); + }); + + it('calls deriveAesKeyFromMnemonic and deriveEvmAddressFromMnemonic with the mnemonic', async () => { + renderWithUser(); + fireEvent.click(screen.getByRole('checkbox')); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(mockSetWalletAddress).toHaveBeenCalled()); + expect(mockDeriveAesKey).toHaveBeenCalledWith(FIXED_MNEMONIC); + expect(mockDeriveEvm).toHaveBeenCalledWith(FIXED_MNEMONIC); + }); + + it('calls skillManager.setWalletAddress with the derived EVM address', async () => { + renderWithUser(); + fireEvent.click(screen.getByRole('checkbox')); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(mockSetWalletAddress).toHaveBeenCalledWith('0xDeAdBeEf')); + }); + + it('shows "User not loaded" error when user._id is missing', async () => { + renderWithoutUser(); + // Manually enable Continue (can't check the box and navigate since no user) + // We need to directly click after enabling. Simulate by rendering with checkbox ticked. + renderWithProviders(, { + preloadedState: { user: { user: null, loading: false, error: null } }, + }); + + // The checkbox click + continue in generate mode with no user + const checkboxes = screen.getAllByRole('checkbox'); + fireEvent.click(checkboxes[checkboxes.length - 1]); + const buttons = screen.getAllByRole('button', { name: /let's go/i }); + await act(async () => { + fireEvent.click(buttons[buttons.length - 1]); + }); + + await waitFor(() => expect(screen.getAllByText(/user not loaded/i).length).toBeGreaterThan(0)); + }); + + it('shows an error message when deriveAesKeyFromMnemonic throws', async () => { + mockDeriveAesKey.mockImplementationOnce(() => { + throw new Error('crypto failure'); + }); + + renderWithUser(); + fireEvent.click(screen.getByRole('checkbox')); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(screen.getByText('crypto failure')).toBeInTheDocument()); + }); + + it('does not navigate when unconfirmed in generate mode', async () => { + const { store } = renderWithUser(); + // Do NOT check the checkbox + await act(async () => { + fireEvent.click(continueButton()); + }); + + // No dispatch should have happened + await new Promise(r => setTimeout(r, 50)); + expect(mockSetWalletAddress).not.toHaveBeenCalled(); + expect( + (store.getState().auth as unknown as Record).encryptionKeyByUser ?? {} + ).not.toMatchObject({ 'user-123': expect.anything() }); + }); +}); + +// --------------------------------------------------------------------------- +// handleContinue — import mode +// --------------------------------------------------------------------------- + +describe('Mnemonic — handleContinue: import mode', () => { + beforeEach(() => { + mockValidateMnemonicPhrase.mockReturnValue(true); + }); + + it('derives keys from the imported phrase and navigates to /home', async () => { + renderWithUser(); + switchToImport(); + fillAllImportWords(); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(mockSetWalletAddress).toHaveBeenCalledWith('0xDeAdBeEf')); + expect(mockDeriveAesKey).toHaveBeenCalledWith(FIXED_MNEMONIC); + }); + + it('dispatches setEncryptionKeyForUser on successful import', async () => { + const { store } = renderWithUser(); + switchToImport(); + fillAllImportWords(); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(mockSetWalletAddress).toHaveBeenCalled()); + const authState = store.getState().auth as unknown as Record; + expect(authState.encryptionKeyByUser).toMatchObject({ 'user-123': 'aes-key-hex' }); + }); + + it('does not call deriveAesKey when validation fails', async () => { + mockValidateMnemonicPhrase.mockReturnValueOnce(false); + + renderWithUser(); + switchToImport(); + fillAllImportWords(); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(screen.getByText(/invalid recovery phrase/i)).toBeInTheDocument()); + expect(mockDeriveAesKey).not.toHaveBeenCalled(); + }); + + it('shows "User not loaded" error when user is absent during import', async () => { + mockValidateMnemonicPhrase.mockReturnValue(true); + + renderWithProviders(, { + preloadedState: { user: { user: null, loading: false, error: null } }, + }); + switchToImport(); + fillAllImportWords(); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(screen.getByText(/user not loaded/i)).toBeInTheDocument()); + }); + + it('shows an error when skillManager.setWalletAddress throws during import', async () => { + mockSetWalletAddress.mockRejectedValueOnce(new Error('wallet error')); + + renderWithUser(); + switchToImport(); + fillAllImportWords(); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(screen.getByText('wallet error')).toBeInTheDocument()); + }); +}); + +// --------------------------------------------------------------------------- +// Loading state during continue +// --------------------------------------------------------------------------- + +describe('Mnemonic — loading state', () => { + it('disables Continue button while loading', async () => { + let resolveWallet!: () => void; + mockSetWalletAddress.mockReturnValueOnce( + new Promise(res => { + resolveWallet = res; + }) + ); + + renderWithUser(); + fireEvent.click(screen.getByRole('checkbox')); + const btn = continueButton(); + + await act(async () => { + fireEvent.click(btn); + }); + await waitFor(() => expect(screen.getByText('Securing Your Data...')).toBeInTheDocument()); + expect(btn).toBeDisabled(); + + await act(async () => { + resolveWallet(); + }); + }); + + it('restores button label after an error', async () => { + mockDeriveAesKey.mockImplementationOnce(() => { + throw new Error('oops'); + }); + + renderWithUser(); + fireEvent.click(screen.getByRole('checkbox')); + + await act(async () => { + fireEvent.click(continueButton()); + }); + + await waitFor(() => expect(screen.getByText('oops')).toBeInTheDocument()); + expect(screen.queryByText('Securing Your Data...')).not.toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Provider configuration sanity checks +// --------------------------------------------------------------------------- + +describe('Mnemonic — providerConfigs sanity', () => { + it('calls generateMnemonicPhrase exactly once on mount', () => { + mockGenerateMnemonicPhrase.mockClear(); + renderWithUser(); + // useMemo with [] dep runs once per render + expect(mockGenerateMnemonicPhrase).toHaveBeenCalledTimes(1); + }); + + it('does not call generateMnemonicPhrase again after mode switch', () => { + mockGenerateMnemonicPhrase.mockClear(); + renderWithUser(); + const callsBefore = mockGenerateMnemonicPhrase.mock.calls.length; + switchToImport(); + fireEvent.click(screen.getByText('Generate a new recovery phrase instead')); + expect(mockGenerateMnemonicPhrase.mock.calls.length).toBe(callsBefore); + }); +}); diff --git a/src/core/cli.rs b/src/core/cli.rs index c9791dc63..e67937a2d 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -376,7 +376,11 @@ fn is_help(value: &str) -> bool { #[cfg(test)] mod tests { - use super::grouped_schemas; + use super::{ + grouped_schemas, parse_autocomplete_start_cli_options, parse_function_params, + parse_input_value, + }; + use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; #[test] fn grouped_schemas_contains_migrated_namespaces() { @@ -392,4 +396,43 @@ mod tests { assert!(grouped.contains_key("migrate")); assert!(grouped.contains_key("local_ai")); } + + #[test] + fn parse_autocomplete_start_cli_options_rejects_serve_and_spawn() { + let args = vec!["--serve".to_string(), "--spawn".to_string()]; + let err = parse_autocomplete_start_cli_options(&args) + .expect_err("must reject mutually exclusive flags"); + assert!(err.to_string().contains("mutually exclusive")); + } + + #[test] + fn parse_function_params_rejects_unknown_param() { + let schema = ControllerSchema { + namespace: "test", + function: "echo", + description: "test schema", + inputs: vec![FieldSchema { + name: "message", + ty: TypeSchema::String, + required: true, + comment: "message text", + }], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::String, + required: true, + comment: "echo response", + }], + }; + let args = vec!["--unknown".to_string(), "value".to_string()]; + let err = parse_function_params(&schema, &args).expect_err("unknown param should fail"); + assert!(err.contains("unknown param")); + } + + #[test] + fn parse_input_value_rejects_invalid_bool() { + let err = parse_input_value(&TypeSchema::Bool, "not-a-bool") + .expect_err("invalid bool should fail"); + assert!(err.contains("expected bool")); + } } diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 262afe56d..bec2e1d93 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -334,6 +334,47 @@ async fn json_rpc_protocol_auth_and_agent_hello() { rpc_join.abort(); } +#[tokio::test] +async fn json_rpc_rejects_non_object_params_with_clear_error() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(100)).await; + + let invalid = post_json_rpc( + &rpc_base, + 1001, + "openhuman.auth_get_state", + json!(["invalid", "params"]), + ) + .await; + let err_message = invalid + .get("error") + .and_then(|e| e.get("message")) + .and_then(Value::as_str) + .unwrap_or(""); + assert!( + !err_message.is_empty(), + "expected non-empty JSON-RPC error message: {invalid}" + ); + + mock_join.abort(); + rpc_join.abort(); +} + // --------------------------------------------------------------------------- // Skills registry E2E: fetch, search, install, list, uninstall // ---------------------------------------------------------------------------