diff --git a/app/scripts/controllers/app-state.js b/app/scripts/controllers/app-state.js index f7669e055..3360cd4f9 100644 --- a/app/scripts/controllers/app-state.js +++ b/app/scripts/controllers/app-state.js @@ -1,5 +1,7 @@ import EventEmitter from 'events'; import { ObservableStore } from '@metamask/obs-store'; +import { v4 as uuid } from 'uuid'; +import log from 'loglevel'; import { METAMASK_CONTROLLER_EVENTS } from '../metamask-controller'; import { MINUTE } from '../../../shared/constants/time'; import { AUTO_LOCK_TIMEOUT_ALARM } from '../../../shared/constants/alarms'; @@ -8,8 +10,11 @@ import { isBeta } from '../../../ui/helpers/utils/build-types'; import { ENVIRONMENT_TYPE_BACKGROUND, POLLING_TOKEN_ENVIRONMENT_TYPES, + ORIGIN_METAMASK, } from '../../../shared/constants/app'; +const APPROVAL_REQUEST_TYPE = 'unlock'; + export default class AppStateController extends EventEmitter { /** * @param {object} opts @@ -20,9 +25,9 @@ export default class AppStateController extends EventEmitter { isUnlocked, initState, onInactiveTimeout, - showUnlockRequest, preferencesStore, qrHardwareStore, + messenger, } = opts; super(); @@ -59,8 +64,6 @@ export default class AppStateController extends EventEmitter { this.waitingForUnlock = []; addUnlockListener(this.handleUnlock.bind(this)); - this._showUnlockRequest = showUnlockRequest; - preferencesStore.subscribe(({ preferences }) => { const currentState = this.store.getState(); if (currentState.timeoutMinutes !== preferences.autoLockTimeLimit) { @@ -74,6 +77,9 @@ export default class AppStateController extends EventEmitter { const { preferences } = preferencesStore.getState(); this._setInactiveTimeout(preferences.autoLockTimeLimit); + + this.messagingSystem = messenger; + this._approvalRequestId = null; } /** @@ -108,7 +114,7 @@ export default class AppStateController extends EventEmitter { this.waitingForUnlock.push({ resolve }); this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); if (shouldShowUnlockRequest) { - this._showUnlockRequest(); + this._requestApproval(); } } @@ -122,6 +128,8 @@ export default class AppStateController extends EventEmitter { } this.emit(METAMASK_CONTROLLER_EVENTS.UPDATE_BADGE); } + + this._acceptApproval(); } /** @@ -369,4 +377,39 @@ export default class AppStateController extends EventEmitter { serviceWorkerLastActiveTime, }); } + + _requestApproval() { + this._approvalRequestId = uuid(); + + this.messagingSystem + .call( + 'ApprovalController:addRequest', + { + id: this._approvalRequestId, + origin: ORIGIN_METAMASK, + type: APPROVAL_REQUEST_TYPE, + }, + true, + ) + .catch(() => { + // Intentionally ignored as promise not currently used + }); + } + + _acceptApproval() { + if (!this._approvalRequestId) { + log.error('Attempted to accept missing unlock approval request'); + return; + } + try { + this.messagingSystem.call( + 'ApprovalController:acceptRequest', + this._approvalRequestId, + ); + } catch (error) { + log.error('Failed to accept transaction approval request', error); + } + + this._approvalRequestId = null; + } } diff --git a/app/scripts/controllers/app-state.test.js b/app/scripts/controllers/app-state.test.js index 7aa27e44b..02d3cda3c 100644 --- a/app/scripts/controllers/app-state.test.js +++ b/app/scripts/controllers/app-state.test.js @@ -1,12 +1,158 @@ +import { ObservableStore } from '@metamask/obs-store'; +import log from 'loglevel'; +import { ORIGIN_METAMASK } from '../../../shared/constants/app'; import AppStateController from './app-state'; +jest.mock('loglevel'); + +let appStateController, mockStore; + describe('AppStateController', () => { + mockStore = new ObservableStore(); + const createAppStateController = (initState = {}) => { + return new AppStateController({ + addUnlockListener: jest.fn(), + isUnlocked: jest.fn(() => true), + initState, + onInactiveTimeout: jest.fn(), + showUnlockRequest: jest.fn(), + preferencesStore: { + subscribe: jest.fn(), + getState: jest.fn(() => ({ + preferences: { + autoLockTimeLimit: 0, + }, + })), + }, + qrHardwareStore: { + subscribe: jest.fn(), + }, + messenger: { + call: jest.fn(() => ({ + catch: jest.fn(), + })), + }, + }); + }; + + beforeEach(() => { + appStateController = createAppStateController({ store: mockStore }); + }); + describe('setOutdatedBrowserWarningLastShown', () => { - it('should set the last shown time', () => { - const appStateController = new AppStateController({ + it('sets the last shown time', () => { + appStateController = createAppStateController(); + const date = new Date(); + + appStateController.setOutdatedBrowserWarningLastShown(date); + + expect( + appStateController.store.getState().outdatedBrowserWarningLastShown, + ).toStrictEqual(date); + }); + + it('sets outdated browser warning last shown timestamp', () => { + const lastShownTimestamp = Date.now(); + appStateController = createAppStateController(); + const updateStateSpy = jest.spyOn( + appStateController.store, + 'updateState', + ); + + appStateController.setOutdatedBrowserWarningLastShown(lastShownTimestamp); + + expect(updateStateSpy).toHaveBeenCalledTimes(1); + expect(updateStateSpy).toHaveBeenCalledWith({ + outdatedBrowserWarningLastShown: lastShownTimestamp, + }); + + updateStateSpy.mockRestore(); + }); + }); + + describe('getUnlockPromise', () => { + it('waits for unlock if the extension is locked', async () => { + appStateController = createAppStateController(); + const isUnlockedMock = jest + .spyOn(appStateController, 'isUnlocked') + .mockReturnValue(false); + const waitForUnlockSpy = jest.spyOn(appStateController, 'waitForUnlock'); + + appStateController.getUnlockPromise(true); + expect(isUnlockedMock).toHaveBeenCalled(); + expect(waitForUnlockSpy).toHaveBeenCalledWith(expect.any(Function), true); + }); + + it('resolves immediately if the extension is already unlocked', async () => { + appStateController = createAppStateController(); + const isUnlockedMock = jest + .spyOn(appStateController, 'isUnlocked') + .mockReturnValue(true); + + await expect( + appStateController.getUnlockPromise(false), + ).resolves.toBeUndefined(); + + expect(isUnlockedMock).toHaveBeenCalled(); + }); + }); + + describe('waitForUnlock', () => { + it('resolves immediately if already unlocked', async () => { + const emitSpy = jest.spyOn(appStateController, 'emit'); + const resolveFn = jest.fn(); + appStateController.waitForUnlock(resolveFn, false); + expect(emitSpy).toHaveBeenCalledWith('updateBadge'); + expect(appStateController.messagingSystem.call).toHaveBeenCalledTimes(0); + }); + + it('creates approval request when waitForUnlock is called with shouldShowUnlockRequest as true', async () => { + jest.spyOn(appStateController, 'isUnlocked').mockReturnValue(false); + + const resolveFn = jest.fn(); + appStateController.waitForUnlock(resolveFn, true); + + expect(appStateController.messagingSystem.call).toHaveBeenCalledTimes(1); + expect(appStateController.messagingSystem.call).toHaveBeenCalledWith( + 'ApprovalController:addRequest', + expect.objectContaining({ + id: expect.any(String), + origin: ORIGIN_METAMASK, + type: 'unlock', + }), + true, + ); + }); + }); + + describe('handleUnlock', () => { + beforeEach(() => { + jest.spyOn(appStateController, 'isUnlocked').mockReturnValue(false); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('accepts approval request revolving all the related promises', async () => { + const emitSpy = jest.spyOn(appStateController, 'emit'); + const resolveFn = jest.fn(); + appStateController.waitForUnlock(resolveFn, true); + + appStateController.handleUnlock(); + + expect(emitSpy).toHaveBeenCalled(); + expect(emitSpy).toHaveBeenCalledWith('updateBadge'); + expect(appStateController.messagingSystem.call).toHaveBeenCalled(); + expect(appStateController.messagingSystem.call).toHaveBeenCalledWith( + 'ApprovalController:acceptRequest', + expect.any(String), + ); + }); + + it('logs if rejecting approval request throws', async () => { + appStateController._approvalRequestId = 'mock-approval-request-id'; + appStateController = new AppStateController({ addUnlockListener: jest.fn(), isUnlocked: jest.fn(() => true), - initState: {}, onInactiveTimeout: jest.fn(), showUnlockRequest: jest.fn(), preferencesStore: { @@ -20,14 +166,184 @@ describe('AppStateController', () => { qrHardwareStore: { subscribe: jest.fn(), }, + messenger: { + call: jest.fn(() => { + throw new Error('mock error'); + }), + }, }); - const date = new Date(); - appStateController.setOutdatedBrowserWarningLastShown(date); + appStateController.handleUnlock(); + + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith( + 'Attempted to accept missing unlock approval request', + ); + }); + + it('returns without call messenger if no approval request in pending', async () => { + const emitSpy = jest.spyOn(appStateController, 'emit'); + + appStateController.handleUnlock(); + + expect(emitSpy).toHaveBeenCalledTimes(0); + expect(appStateController.messagingSystem.call).toHaveBeenCalledTimes(0); + expect(log.error).toHaveBeenCalledTimes(1); + expect(log.error).toHaveBeenCalledWith( + 'Attempted to accept missing unlock approval request', + ); + }); + }); + + describe('setDefaultHomeActiveTabName', () => { + it('sets the default home tab name', () => { + appStateController.setDefaultHomeActiveTabName('testTabName'); + expect(appStateController.store.getState().defaultHomeActiveTabName).toBe( + 'testTabName', + ); + }); + }); + + describe('setConnectedStatusPopoverHasBeenShown', () => { + it('sets connected status popover as shown', () => { + appStateController.setConnectedStatusPopoverHasBeenShown(); + expect( + appStateController.store.getState().connectedStatusPopoverHasBeenShown, + ).toBe(true); + }); + }); + + describe('setRecoveryPhraseReminderHasBeenShown', () => { + it('sets recovery phrase reminder as shown', () => { + appStateController.setRecoveryPhraseReminderHasBeenShown(); + expect( + appStateController.store.getState().recoveryPhraseReminderHasBeenShown, + ).toBe(true); + }); + }); + + describe('setRecoveryPhraseReminderLastShown', () => { + it('sets the last shown time of recovery phrase reminder', () => { + const timestamp = Date.now(); + appStateController.setRecoveryPhraseReminderLastShown(timestamp); expect( - appStateController.store.getState().outdatedBrowserWarningLastShown, - ).toStrictEqual(date); + appStateController.store.getState().recoveryPhraseReminderLastShown, + ).toBe(timestamp); + }); + }); + + describe('setLastActiveTime', () => { + it('sets the last active time to the current time', () => { + const spy = jest.spyOn(appStateController, '_resetTimer'); + appStateController.setLastActiveTime(); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('setBrowserEnvironment', () => { + it('sets the current browser and OS environment', () => { + appStateController.setBrowserEnvironment('Windows', 'Chrome'); + expect( + appStateController.store.getState().browserEnvironment, + ).toStrictEqual({ + os: 'Windows', + browser: 'Chrome', + }); + }); + }); + + describe('addPollingToken', () => { + it('adds a pollingToken for a given environmentType', () => { + const pollingTokenType = 'popupGasPollTokens'; + appStateController.addPollingToken('token1', pollingTokenType); + expect(appStateController.store.getState()[pollingTokenType]).toContain( + 'token1', + ); + }); + }); + + describe('removePollingToken', () => { + it('removes a pollingToken for a given environmentType', () => { + const pollingTokenType = 'popupGasPollTokens'; + appStateController.addPollingToken('token1', pollingTokenType); + appStateController.removePollingToken('token1', pollingTokenType); + expect( + appStateController.store.getState()[pollingTokenType], + ).not.toContain('token1'); + }); + }); + + describe('clearPollingTokens', () => { + it('clears all pollingTokens', () => { + appStateController.addPollingToken('token1', 'popupGasPollTokens'); + appStateController.addPollingToken('token2', 'notificationGasPollTokens'); + appStateController.addPollingToken('token3', 'fullScreenGasPollTokens'); + appStateController.clearPollingTokens(); + + expect( + appStateController.store.getState().popupGasPollTokens, + ).toStrictEqual([]); + expect( + appStateController.store.getState().notificationGasPollTokens, + ).toStrictEqual([]); + expect( + appStateController.store.getState().fullScreenGasPollTokens, + ).toStrictEqual([]); + }); + }); + + describe('setShowTestnetMessageInDropdown', () => { + it('sets whether the testnet dismissal link should be shown in the network dropdown', () => { + appStateController.setShowTestnetMessageInDropdown(true); + expect( + appStateController.store.getState().showTestnetMessageInDropdown, + ).toBe(true); + + appStateController.setShowTestnetMessageInDropdown(false); + expect( + appStateController.store.getState().showTestnetMessageInDropdown, + ).toBe(false); + }); + }); + + describe('setShowBetaHeader', () => { + it('sets whether the beta notification heading on the home page', () => { + appStateController.setShowBetaHeader(true); + expect(appStateController.store.getState().showBetaHeader).toBe(true); + + appStateController.setShowBetaHeader(false); + expect(appStateController.store.getState().showBetaHeader).toBe(false); + }); + }); + + describe('setCurrentPopupId', () => { + it('sets the currentPopupId in the appState', () => { + const popupId = 'popup1'; + + appStateController.setCurrentPopupId(popupId); + expect(appStateController.store.getState().currentPopupId).toBe(popupId); + }); + }); + + describe('getCurrentPopupId', () => { + it('retrieves the currentPopupId saved in the appState', () => { + const popupId = 'popup1'; + + appStateController.setCurrentPopupId(popupId); + expect(appStateController.getCurrentPopupId()).toBe(popupId); + }); + }); + + describe('setFirstTimeUsedNetwork', () => { + it('updates the array of the first time used networks', () => { + const chainId = '0x1'; + + appStateController.setFirstTimeUsedNetwork(chainId); + expect(appStateController.store.getState().usedNetworks[chainId]).toBe( + true, + ); }); }); }); diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 133f5a877..53bba6767 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -520,9 +520,15 @@ export default class MetamaskController extends EventEmitter { isUnlocked: this.isUnlocked.bind(this), initState: initState.AppStateController, onInactiveTimeout: () => this.setLocked(), - showUnlockRequest: opts.showUserConfirmation, preferencesStore: this.preferencesController.store, qrHardwareStore: this.qrHardwareKeyring.getMemStore(), + messenger: this.controllerMessenger.getRestricted({ + name: 'AppStateController', + allowedActions: [ + `${this.approvalController.name}:addRequest`, + `${this.approvalController.name}:acceptRequest`, + ], + }), }); const currencyRateMessenger = this.controllerMessenger.getRestricted({