diff --git a/ui/pages/create-account/connect-hardware/__snapshots__/index.test.tsx.snap b/ui/pages/create-account/connect-hardware/__snapshots__/index.test.tsx.snap index 0ec258838664..7c84c5bbac17 100644 --- a/ui/pages/create-account/connect-hardware/__snapshots__/index.test.tsx.snap +++ b/ui/pages/create-account/connect-hardware/__snapshots__/index.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ConnectHardwareForm should match snapshot 1`] = ` +exports[`ConnectHardwareForm matchs snapshot 1`] = `
{ // If we have < 5 accounts, it's restricted by BIP-44 if (this.props.accounts.length === 5) { - this.props.getPage(this.props.device, 1, this.props.selectedPath); + this.props.getPage(this.props.device, 1, this.props.selectedPath, false); } else { this.props.onAccountRestriction(); } }; goToPreviousPage = () => { - this.props.getPage(this.props.device, -1, this.props.selectedPath); + this.props.getPage(this.props.device, -1, this.props.selectedPath, false); }; setPath(pathValue) { diff --git a/ui/pages/create-account/connect-hardware/index.js b/ui/pages/create-account/connect-hardware/index.js index 6c95c99ae8d8..76b226062415 100644 --- a/ui/pages/create-account/connect-hardware/index.js +++ b/ui/pages/create-account/connect-hardware/index.js @@ -149,7 +149,7 @@ class ConnectHardwareForm extends Component { } // Default values - this.getPage(device, 0, this.props.defaultHdPaths[device]); + this.getPage(device, 0, this.props.defaultHdPaths[device], true); }; onPathChange = (path) => { @@ -185,9 +185,9 @@ class ConnectHardwareForm extends Component { }, SECOND * 5); } - getPage = (device, page, hdPath) => { + getPage = (device, page, hdPath, loadHid) => { this.props - .connectHardware(device, page, hdPath, this.context.t) + .connectHardware(device, page, hdPath, loadHid, this.context.t) .then((accounts) => { if (accounts.length) { // If we just loaded the accounts for the first time @@ -475,8 +475,10 @@ const mapDispatchToProps = (dispatch) => { setHardwareWalletDefaultHdPath: ({ device, path }) => { return dispatch(actions.setHardwareWalletDefaultHdPath({ device, path })); }, - connectHardware: (deviceName, page, hdPath, t) => { - return dispatch(actions.connectHardware(deviceName, page, hdPath, t)); + connectHardware: (deviceName, page, hdPath, loadHid, t) => { + return dispatch( + actions.connectHardware(deviceName, page, hdPath, loadHid, t), + ); }, checkHardwareStatus: (deviceName, hdPath) => { return dispatch(actions.checkHardwareStatus(deviceName, hdPath)); diff --git a/ui/pages/create-account/connect-hardware/index.test.tsx b/ui/pages/create-account/connect-hardware/index.test.tsx index 1fc2a8ca3437..54c31aed8964 100644 --- a/ui/pages/create-account/connect-hardware/index.test.tsx +++ b/ui/pages/create-account/connect-hardware/index.test.tsx @@ -1,7 +1,8 @@ -import configureMockStore from 'redux-mock-store'; import { fireEvent, waitFor } from '@testing-library/react'; import thunk from 'redux-thunk'; import React from 'react'; +import configureMockStore from 'redux-mock-store'; + import { renderWithProvider } from '../../../../test/lib/render-helpers'; import { LedgerTransportTypes, @@ -50,6 +51,7 @@ const mockProps = { hideAlert: () => jest.fn(), unlockHardwareWalletAccount: () => jest.fn(), setHardwareWalletDefaultHdPath: () => jest.fn(), + connectHardware: () => mockConnectHardware, history: { push: mockHistoryPush, }, @@ -95,7 +97,7 @@ const mockState = { describe('ConnectHardwareForm', () => { const mockStore = configureMockStore([thunk])(mockState); - it('should match snapshot', () => { + it('matchs snapshot', () => { const { container } = renderWithProvider( , mockStore, @@ -104,7 +106,7 @@ describe('ConnectHardwareForm', () => { expect(container).toMatchSnapshot(); }); - it('should close the form when close button is clicked', () => { + it('closes the form when close button is clicked', () => { const { getByTestId } = renderWithProvider( , mockStore, @@ -116,7 +118,7 @@ describe('ConnectHardwareForm', () => { }); describe('U2F Error', () => { - it('should render a U2F error', async () => { + it('renders a U2F error', async () => { mockConnectHardware.mockRejectedValue(new Error('U2F Error')); const mockStateWithU2F = Object.assign(mockState, {}); mockStateWithU2F.appState.ledgerTransportType = LedgerTransportTypes.u2f; @@ -146,7 +148,7 @@ describe('ConnectHardwareForm', () => { }); }); - it('should render a different U2F error for firefox', async () => { + it('renders a different U2F error for firefox', async () => { jest .spyOn(window.navigator, 'userAgent', 'get') .mockReturnValue( @@ -180,7 +182,7 @@ describe('ConnectHardwareForm', () => { }); describe('QR Hardware Wallet Steps', () => { - it('should render the QR hardware wallet steps', async () => { + it('renders the QR hardware wallet steps', async () => { const { getByText, getByLabelText } = renderWithProvider( , mockStore, @@ -203,7 +205,7 @@ describe('ConnectHardwareForm', () => { }); describe('Select Hardware', () => { - it('should check link buttons for Ngrave Zero brand', async () => { + it('checks link buttons for Ngrave Zero brand', async () => { window.open = jest.fn(); const { getByLabelText, getByTestId } = renderWithProvider( @@ -226,4 +228,86 @@ describe('ConnectHardwareForm', () => { expect(window.open).toHaveBeenCalled(); }); }); + + describe('getPage method', () => { + beforeEach(() => { + mockConnectHardware.mockReset(); + }); + + it('calls connectHardware with loadHid=true', async () => { + mockConnectHardware.mockReset(); + + const mockAccounts = [ + { address: '0xAddress1', balance: null, index: 0 }, + { address: '0xAddress2', balance: null, index: 1 }, + ]; + mockConnectHardware.mockResolvedValue(mockAccounts); + + renderWithProvider(, mockStore); + + const hdPath = "m/44'/60'/0'/0"; + const deviceName = 'ledger'; + const pageIndex = 0; + const loadHidValue = true; + + await mockConnectHardware( + deviceName, + pageIndex, + hdPath, + loadHidValue, + jest.fn(), + ); + + expect(mockConnectHardware).toHaveBeenCalledWith( + deviceName, + pageIndex, + hdPath, + loadHidValue, + expect.any(Function), + ); + }); + + it('calls connectHardware with loadHid=false', async () => { + mockConnectHardware.mockReset(); + + const mockAccounts = [ + { address: '0xAddress1', balance: null, index: 0 }, + { address: '0xAddress2', balance: null, index: 1 }, + ]; + mockConnectHardware.mockResolvedValue(mockAccounts); + + renderWithProvider(, mockStore); + + const hdPath = "m/44'/60'/0'/0"; + const deviceName = 'ledger'; + const pageIndex = 0; + const loadHidValue = false; + + await mockConnectHardware( + deviceName, + pageIndex, + hdPath, + loadHidValue, + jest.fn(), + ); + + expect(mockConnectHardware).toHaveBeenCalledWith( + deviceName, + pageIndex, + hdPath, + loadHidValue, + expect.any(Function), + ); + }); + + it('handles errors when connectHardware fails', async () => { + const testError = new Error('Test Error'); + mockConnectHardware.mockReset(); + mockConnectHardware.mockRejectedValue(testError); + + renderWithProvider(, mockStore); + + await expect(mockConnectHardware()).rejects.toThrow('Test Error'); + }); + }); }); diff --git a/ui/store/actions.test.js b/ui/store/actions.test.js index 3e82adfc3d98..25a5dcd6bb09 100644 --- a/ui/store/actions.test.js +++ b/ui/store/actions.test.js @@ -85,7 +85,12 @@ describe('Actions', () => { const currentChainId = '0x5'; + let originalNavigator; + beforeEach(async () => { + // Save original navigator for restoring after tests + originalNavigator = global.navigator; + background = sinon.createStubInstance(MetaMaskController, { getState: sinon.stub().callsFake((cb) => cb(null, [])), }); @@ -100,6 +105,27 @@ describe('Actions', () => { background.requestAccountsAndChainPermissionsWithId = sinon.stub(); background.grantPermissions = sinon.stub(); background.grantPermissionsIncremental = sinon.stub(); + + // Make sure navigator.hid is defined for WebHID tests + if (!global.navigator) { + global.navigator = {}; + } + + if (!global.navigator.hid) { + global.navigator.hid = { + requestDevice: sinon.stub(), + }; + } + }); + + afterEach(() => { + // Restore original window.navigator after each test + Object.defineProperty(window, 'navigator', { + value: originalNavigator, + writable: true, + }); + + sinon.restore(); }); describe('#tryUnlockMetamask', () => { @@ -622,6 +648,264 @@ describe('Actions', () => { expect(store.getActions()).toStrictEqual(expectedActions); }); + + it('handles WebHID connection when loadHid=true for Ledger devices', async () => { + const store = mockStore({ + ...defaultState, + metamask: { + ...defaultState.metamask, + ledgerTransportType: 'webhid', + }, + }); + + const mockHidDevice = { vendorId: 11415 }; + const mockRequestDevice = sinon.stub().resolves([mockHidDevice]); + + Object.defineProperty(window, 'navigator', { + value: { + ...window.navigator, + hid: { + requestDevice: mockRequestDevice, + }, + }, + writable: true, + }); + + const connectHardware = background.connectHardware.callsFake( + (_, __, ___, cb) => cb(null, [{ address: '0xLedgerAddress' }]), + ); + + setBackgroundConnection(background); + + const expectedActions = [ + { + type: 'SHOW_LOADING_INDICATION', + payload: 'Looking for your Ledger...', + }, + { type: 'HIDE_LOADING_INDICATION' }, + ]; + + const accounts = await store.dispatch( + actions.connectHardware( + HardwareDeviceNames.ledger, + 0, + `m/44'/60'/0'/0`, + true, + (key) => `translated_${key}`, + ), + ); + + expect(connectHardware.callCount).toStrictEqual(1); + expect(mockRequestDevice.callCount).toStrictEqual(1); + expect(accounts).toStrictEqual([{ address: '0xLedgerAddress' }]); + expect(store.getActions()).toStrictEqual(expectedActions); + }); + + it('throws a specific error when user denies WebHID permissions with loadHid=true', async () => { + const store = mockStore({ + ...defaultState, + metamask: { + ...defaultState.metamask, + ledgerTransportType: 'webhid', + }, + }); + + const mockRequestDevice = sinon.stub(); + mockRequestDevice.resolves([]); + Object.defineProperty(window, 'navigator', { + value: { + ...window.navigator, + hid: { + requestDevice: mockRequestDevice, + }, + }, + writable: true, + }); + + setBackgroundConnection(background); + + const expectedActions = [ + { + type: 'SHOW_LOADING_INDICATION', + payload: 'Looking for your Ledger...', + }, + { + type: 'DISPLAY_WARNING', + payload: 'translated_ledgerWebHIDNotConnectedErrorMessage', + }, + { type: 'HIDE_LOADING_INDICATION' }, + ]; + + const mockTranslation = (key) => `translated_${key}`; + + await expect( + store.dispatch( + actions.connectHardware( + HardwareDeviceNames.ledger, + 0, + `m/44'/60'/0'/0`, + true, + mockTranslation, + ), + ), + ).rejects.toThrow('translated_ledgerWebHIDNotConnectedErrorMessage'); + + expect(mockRequestDevice.callCount).toStrictEqual(1); + expect(store.getActions()).toStrictEqual(expectedActions); + }); + + it('handles loadHid=false and skips WebHID request process', async () => { + const store = mockStore({ + ...defaultState, + metamask: { + ...defaultState.metamask, + ledgerTransportType: 'webhid', + }, + }); + + const mockRequestDevice = sinon.spy(); + Object.defineProperty(window, 'navigator', { + value: { + ...window.navigator, + hid: { + requestDevice: mockRequestDevice, + }, + }, + writable: true, + }); + + const connectHardware = background.connectHardware.callsFake( + (_, __, ___, cb) => cb(null, [{ address: '0xLedgerAddress' }]), + ); + + setBackgroundConnection(background); + + const expectedActions = [ + { + type: 'SHOW_LOADING_INDICATION', + payload: 'Looking for your Ledger...', + }, + { type: 'HIDE_LOADING_INDICATION' }, + ]; + + const accounts = await store.dispatch( + actions.connectHardware( + HardwareDeviceNames.ledger, + 0, + `m/44'/60'/0'/0`, + false, + (key) => `translated_${key}`, + ), + ); + + expect(connectHardware.callCount).toStrictEqual(1); + expect(mockRequestDevice.callCount).toStrictEqual(0); + expect(accounts).toStrictEqual([{ address: '0xLedgerAddress' }]); + expect(store.getActions()).toStrictEqual(expectedActions); + }); + + it('handles specific Ledger WebHID device open failure error', async () => { + const store = mockStore({ + ...defaultState, + metamask: { + ...defaultState.metamask, + ledgerTransportType: 'webhid', + }, + }); + + const mockHidDevice = { vendorId: 11415 }; + const mockRequestDevice = sinon.stub(); + mockRequestDevice.resolves([mockHidDevice]); + Object.defineProperty(window, 'navigator', { + value: { + ...window.navigator, + hid: { + requestDevice: mockRequestDevice, + }, + }, + writable: true, + }); + + const deviceOpenError = new Error('Failed to open the device'); + background.connectHardware.callsFake((_, __, ___, cb) => + cb(deviceOpenError), + ); + + setBackgroundConnection(background); + + const expectedActions = [ + { + type: 'SHOW_LOADING_INDICATION', + payload: 'Looking for your Ledger...', + }, + { + type: 'DISPLAY_WARNING', + payload: 'translated_ledgerDeviceOpenFailureMessage', + }, + { type: 'HIDE_LOADING_INDICATION' }, + ]; + + const mockTranslation = (key) => `translated_${key}`; + + await expect( + store.dispatch( + actions.connectHardware( + HardwareDeviceNames.ledger, + 0, + `m/44'/60'/0'/0`, + true, + mockTranslation, + ), + ), + ).rejects.toThrow('translated_ledgerDeviceOpenFailureMessage'); + + expect(mockRequestDevice.callCount).toStrictEqual(1); + expect(store.getActions()).toStrictEqual(expectedActions); + }); + + it('handles non-Ledger hardware devices', async () => { + const store = mockStore(); + + const mockRequestDevice = sinon.spy(); + Object.defineProperty(window, 'navigator', { + value: { + ...window.navigator, + hid: { + requestDevice: mockRequestDevice, + }, + }, + writable: true, + }); + + const connectHardware = background.connectHardware.callsFake( + (_, __, ___, cb) => cb(null, [{ address: '0xTrezorAddress' }]), + ); + + setBackgroundConnection(background); + + const expectedActions = [ + { + type: 'SHOW_LOADING_INDICATION', + payload: 'Looking for your Trezor...', + }, + { type: 'HIDE_LOADING_INDICATION' }, + ]; + + const accounts = await store.dispatch( + actions.connectHardware( + HardwareDeviceNames.trezor, + 0, + `m/44'/60'/0'/0`, + true, + (key) => `translated_${key}`, + ), + ); + + expect(connectHardware.callCount).toStrictEqual(1); + expect(mockRequestDevice.callCount).toStrictEqual(0); + expect(accounts).toStrictEqual([{ address: '0xTrezorAddress' }]); + expect(store.getActions()).toStrictEqual(expectedActions); + }); }); describe('#unlockHardwareWalletAccount', () => { diff --git a/ui/store/actions.ts b/ui/store/actions.ts index 93a0155cab33..527c4eebe796 100644 --- a/ui/store/actions.ts +++ b/ui/store/actions.ts @@ -612,6 +612,7 @@ export function connectHardware( deviceName: HardwareDeviceNames, page: string, hdPath: string, + loadHid: boolean, t: (key: string) => string, ): ThunkAction< Promise<{ address: string }[]>, @@ -630,6 +631,7 @@ export function connectHardware( let accounts: { address: string }[]; try { if ( + loadHid && deviceName === HardwareDeviceNames.ledger && ledgerTransportType === LedgerTransportTypes.webhid ) {