1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-25 03:20:23 +01:00

Trigger unlock popup in appStateController using ApprovalController (#18386)

This commit is contained in:
Vinicius Stevam 2023-04-14 05:50:17 +01:00 committed by GitHub
parent 5d2c4c143a
commit a3af0b53e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 377 additions and 12 deletions

View File

@ -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;
}
}

View File

@ -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,
);
});
});
});

View File

@ -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({