diff --git a/app/scripts/background.js b/app/scripts/background.js index 1b037f09328b..c2bf629da098 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -56,11 +56,11 @@ import { import { getCurrentChainId } from '../../shared/modules/selectors/networks'; import { addNonceToCsp } from '../../shared/modules/add-nonce-to-csp'; import { checkURLForProviderInjection } from '../../shared/modules/provider-injection'; +import { ExtensionStore } from './lib/Stores/ExtensionStore'; +import ReadOnlyNetworkStore from './lib/Stores/ReadOnlyNetworkStore'; import migrations from './migrations'; import Migrator from './lib/migrator'; import ExtensionPlatform from './platforms/extension'; -import LocalStore from './lib/local-store'; -import ReadOnlyNetworkStore from './lib/network-store'; import { SENTRY_BACKGROUND_STATE } from './constants/sentry-state'; import createStreamSink from './lib/createStreamSink'; @@ -70,7 +70,6 @@ import NotificationManager, { import MetamaskController, { METAMASK_CONTROLLER_EVENTS, } from './metamask-controller'; -import rawFirstTimeState from './first-time-state'; import getFirstPreferredLangCode from './lib/get-first-preferred-lang-code'; import getObjStructure from './lib/getObjStructure'; import setupEnsIpfsResolver from './lib/ens-ipfs/setup'; @@ -79,7 +78,6 @@ import { getPlatform, shouldEmitDappViewedEvent, } from './lib/util'; -import { generateWalletState } from './fixtures/generate-wallet-state'; import { createOffscreen } from './offscreen'; /* eslint-enable import/first */ @@ -94,12 +92,19 @@ const BADGE_MAX_COUNT = 9; // Setup global hook for improved Sentry state snapshots during initialization const inTest = process.env.IN_TEST; -const localStore = inTest ? new ReadOnlyNetworkStore() : new LocalStore(); +const migrator = new Migrator({ + migrations, + defaultVersion: process.env.WITH_STATE + ? FIXTURE_STATE_METADATA_VERSION + : null, +}); +const localStore = inTest + ? new ReadOnlyNetworkStore({ migrator }) + : new ExtensionStore({ migrator }); global.stateHooks.getMostRecentPersistedState = () => localStore.mostRecentRetrievedState; const { sentry } = global; -let firstTimeState = { ...rawFirstTimeState }; const metamaskInternalProcessHash = { [ENVIRONMENT_TYPE_POPUP]: true, @@ -120,7 +125,6 @@ let uiIsTriggering = false; const openMetamaskTabsIDs = {}; const requestAccountTabIds = {}; let controller; -let versionedData; const tabOriginMapping = {}; if (inTest || process.env.METAMASK_DEBUG) { @@ -607,38 +611,16 @@ async function loadPhishingWarningPage() { */ export async function loadStateFromPersistence() { // migrations - const migrator = new Migrator({ - migrations, - defaultVersion: process.env.WITH_STATE - ? FIXTURE_STATE_METADATA_VERSION - : null, - }); migrator.on('error', console.warn); - if (process.env.WITH_STATE) { - const stateOverrides = await generateWalletState(); - firstTimeState = { ...firstTimeState, ...stateOverrides }; - } - // read from disk // first from preferred, async API: - versionedData = - (await localStore.get()) || migrator.generateInitialState(firstTimeState); - - // check if somehow state is empty - // this should never happen but new error reporting suggests that it has - // for a small number of users - // https://github.com/metamask/metamask-extension/issues/3919 - if (versionedData && !versionedData.data) { - // unable to recover, clear state - versionedData = migrator.generateInitialState(firstTimeState); - sentry.captureMessage('MetaMask - Empty vault found - unable to recover'); - } + const preMigrationVersionedData = await localStore.get(); // report migration errors to sentry migrator.on('error', (err) => { // get vault structure without secrets - const vaultStructure = getObjStructure(versionedData); + const vaultStructure = getObjStructure(preMigrationVersionedData); sentry.captureException(err, { // "extra" key is required by Sentry extra: { vaultStructure }, @@ -646,7 +628,7 @@ export async function loadStateFromPersistence() { }); // migrate data - versionedData = await migrator.migrateData(versionedData); + const versionedData = await migrator.migrateData(preMigrationVersionedData); if (!versionedData) { throw new Error('MetaMask - migrator returned undefined'); } else if (!isObject(versionedData.meta)) { @@ -664,7 +646,7 @@ export async function loadStateFromPersistence() { ); } // this initializes the meta/version data as a class variable to be used for future writes - localStore.setMetadata(versionedData.meta); + localStore.metadata = versionedData.meta; // write to disk localStore.set(versionedData.data); @@ -1301,12 +1283,12 @@ const addAppInstalledEvent = () => { // On first install, open a new tab with MetaMask async function onInstall() { - const storeAlreadyExisted = Boolean(await localStore.get()); - // If the store doesn't exist, then this is the first time running this script, - // and is therefore an install + const isFirstTimeInstall = await localStore.isFirstTimeInstall(); if (process.env.IN_TEST) { addAppInstalledEvent(); - } else if (!storeAlreadyExisted && !process.env.METAMASK_DEBUG) { + } else if (isFirstTimeInstall && !process.env.METAMASK_DEBUG) { + // If isFirstTimeInstall is true then this is a fresh installation + // and an app installed event should be tracked. addAppInstalledEvent(); platform.openExtensionInBrowser(); } diff --git a/app/scripts/lib/Stores/BaseStore.ts b/app/scripts/lib/Stores/BaseStore.ts new file mode 100644 index 000000000000..6e7687459530 --- /dev/null +++ b/app/scripts/lib/Stores/BaseStore.ts @@ -0,0 +1,167 @@ +import type Migrator from '../migrator'; +import firstTimeState from '../../first-time-state'; +import { generateWalletState } from '../../fixtures/generate-wallet-state'; + +/** + * This type is a temporary type that is used to represent the state tree of + * MetaMask. This type is used in the BaseStore class and its extending classes + * and should ultimately be replaced by the fully typed State Tree once that is + * available for consumption. We should likely optimize the state tree by + * storing the individual controllers in their own keys in the state tree. This + * would allow for partial updates at the controller state level, without + * modifying the entire data key. + */ +export type IntermediaryStateType = Record; + +/** + * This type represents the 'meta' key on the state object. This key is used to + * store the current version of the state tree as set in the various migrations + * ran by the migrator. This key is used to determine if the state tree should + * be updated when the extension is loaded, by comparing the version to the + * target versions of the migrations. + */ +export type MetaData = { version: number }; + +/** + * This type represents the structure of the storage object that is saved in + * extension storage. This object has two keys, 'data' and 'meta'. The 'data' + * key is the entire state tree of MetaMask and the meta key contains an object + * with a single key 'version' that is the current version of the state tree. + */ +export type MetaMaskStorageStructure = { + data?: IntermediaryStateType; + meta?: MetaData; +}; + +/** + * When loading state from storage, if the state is not available, then the + * extension storage api, at least in the case of chrome, returns an empty + * object. This type represents that empty object to be used in error handling + * and state initialization. + */ +export type EmptyState = Omit; + +/** + * The BaseStore class is an Abstract Class meant to be extended by other classes + * that implement the methods and properties marked as abstract. There are a + * few properties and methods that are not abstract and are implemented here to + * be consumed by the extending classes. At the time of writing this class + * there are only two extending classes: ReadOnlyNetworkStore and + * ExtensionStore. Both of these extending classes are the result of + * refactoring the previous storage implementation to TypeScript while + * consolidating some logic related to storage that was external to the + * implementation of those storage systems. ReadOnlyNetworkStore is a class + * that is used while in an End To End or other Test environment where the full + * chrome storage API may not be available. ExtensionStore is the class that is + * used when the full chrome storage API is available. While Chrome is the + * target of this documentation, Firefox also has a mostly identical storage + * API that is used interchangeably. + * + * The classes that extend this system take on the responsibilities listed here + * 1. Retrieve the current state from the underlying storage system. If that + * state is unavailable, then the storage system should return a default state + * in the case that this is the first time the extension has been installed. If + * the state is not available due to some form of possible corruption, using + * the best methods available to detect such things, then a backup of the vault + * should be inserted into a state tree that otherwise resembles a first time + * installation. If the backup of the vault is unavailable, then a default + * state tree should be used. In any case we should provide clear and concise + * communication to the user about what happened and their best recourse for + * handling the situation if the extension cannot gracefully recover. + * + * 2. Set the current state to the underlying storage system. This should be + * implemented in such a way that the current metadata is stored in a separate + * key that is tracked by the storage system. This metadata should *not* be a + * input to the set method. If the underlying storage system allows for partial + * state objects it should be sufficient to pass the data key, which is the + * full MetaMask state tree. If not, then the metadata should be supplied by + * the storage system itself. + * + * 3. Provide a method for generating a first time state tree. This method is + * implemented as a part of this Abstract class and should not be overwritten + * unless future work requires specific implementations for different storage + * systems. This method should return a state tree that is the default state + * tree for a new install. + */ +export abstract class BaseStore { + /** + * isSupported is a boolean that is set to true if the underlying storage + * system is supported by the current browser and implementation. + */ + abstract isSupported: boolean; + + /** + * dataPersistenceFailing is a boolean that is set to true if the storage + * system attempts to write state and the write operation fails. This is only + * used as a way of deduplicating error reports sent to sentry as it is + * likely that multiple writes will fail concurrently. + */ + abstract dataPersistenceFailing: boolean; + + /** + * mostRecentRetrievedState is a property that holds the most recent state + * successfully retrieved from memory. Due to the nature of async read + * operations it is beneficial to have a near real-time snapshot of the state + * for sending data to sentry as well as other developer tooling. + */ + abstract mostRecentRetrievedState: MetaMaskStorageStructure | null; + + /** + * metadata is a property that holds the current metadata object. This object + * includes a single key which is 'version' and contains the current version + * number of the state tree. This is only incremented via the migrator and in + * a well functioning (typical) install should match the latest migration's + * version number. + */ + #metadata?: { version: number }; + + /** + * migrator is a property that holds the migrator instance that is used to + * migrate state from one shape to another. This migrator is used to create + * the first time state tree. + */ + abstract migrator: Migrator; + + /** + * Sets the current metadata. The set method that is implemented in storage + * classes only requires an object that is set on the 'data' key. The + * metadata key of this class is set on the 'meta' key of the underlying + * storage implementation (e.g. chrome.storage.local). + */ + set metadata(metadata: { version: number }) { + this.#metadata = metadata; + } + + /** + * Gets the current metadata object and returns it. The underlying key is + * private and implemented in the BaseStore class so that the extending class + * can access it through this getter. + */ + get metadata(): { version: number } | undefined { + return this.#metadata; + } + + /** + * Generates the first time state tree. This method is used to generate the + * MetaMask state tree in its initial shape using the migrator. + * + * @returns state - The first time state tree generated by the migrator + */ + async generateFirstTimeState() { + let _firstTimeState = { ...firstTimeState }; + if (process.env.WITH_STATE) { + const stateOverrides = await generateWalletState(); + _firstTimeState = { ..._firstTimeState, ...stateOverrides }; + } + + return this.migrator.generateInitialState( + _firstTimeState, + ) as Required; + } + + abstract set(state: IntermediaryStateType): Promise; + + abstract get(): Promise; + + abstract isFirstTimeInstall(): Promise; +} diff --git a/app/scripts/lib/local-store.test.js b/app/scripts/lib/Stores/ExtensionStore.test.ts similarity index 51% rename from app/scripts/lib/local-store.test.js rename to app/scripts/lib/Stores/ExtensionStore.test.ts index 34289185de79..9e57c6e28d2d 100644 --- a/app/scripts/lib/local-store.test.js +++ b/app/scripts/lib/Stores/ExtensionStore.test.ts @@ -1,20 +1,46 @@ import browser from 'webextension-polyfill'; -import LocalStore from './local-store'; +import Migrator from '../migrator'; +import { ExtensionStore } from './ExtensionStore'; +import { IntermediaryStateType } from './BaseStore'; + +const DEFAULT_INITIAL_STATE = { + data: { config: {} }, + meta: { version: 0 }, +}; jest.mock('webextension-polyfill', () => ({ runtime: { lastError: null }, storage: { local: true }, })); -const setup = ({ localMock = jest.fn() } = {}) => { - browser.storage.local = localMock; - return new LocalStore(); +const setup = (options: { localMock?: { get?: unknown } | false } = {}) => { + if (typeof options.localMock === 'undefined') { + browser.storage.local = + jest.fn() as unknown as browser.Storage.LocalStorageArea; + } else if (options.localMock === false) { + browser.storage.local = + undefined as unknown as browser.Storage.LocalStorageArea; + } else { + browser.storage.local = + options.localMock as unknown as browser.Storage.LocalStorageArea; + } + const migrator = new Migrator(); + return new ExtensionStore({ migrator }); }; -describe('LocalStore', () => { +describe('ExtensionStore', () => { + beforeEach(() => { + global.sentry = { + captureException: jest.fn(), + }; + }); + afterEach(() => { jest.resetModules(); + jest.clearAllMocks(); + browser.storage.local = + undefined as unknown as browser.Storage.LocalStorageArea; }); - describe('contructor', () => { + describe('constructor', () => { it('should set isSupported property to false when browser does not support local storage', () => { const localStore = setup({ localMock: false }); @@ -41,7 +67,7 @@ describe('LocalStore', () => { it('should set the metadata property on LocalStore', () => { const metadata = { version: 74 }; const localStore = setup(); - localStore.setMetadata(metadata); + localStore.metadata = metadata; expect(localStore.metadata).toStrictEqual(metadata); }); @@ -50,16 +76,16 @@ describe('LocalStore', () => { describe('set', () => { it('should throw an error if called in a browser that does not support local storage', async () => { const localStore = setup({ localMock: false }); - await expect(() => localStore.set()).rejects.toThrow( + await expect(() => localStore.set({})).rejects.toThrow( 'Metamask- cannot persist state to local store as this browser does not support this action', ); }); - it('should throw an error if not passed a truthy value as an argument', async () => { + it('should throw an error if not passed a state parameter', async () => { const localStore = setup(); - await expect(() => localStore.set()).rejects.toThrow( - 'MetaMask - updated state is missing', - ); + await expect(() => + localStore.set(undefined as unknown as IntermediaryStateType), + ).rejects.toThrow('MetaMask - updated state is missing'); }); it('should throw an error if passed a valid argument but metadata has not yet been set', async () => { @@ -73,7 +99,7 @@ describe('LocalStore', () => { it('should not throw if passed a valid argument and metadata has been set', async () => { const localStore = setup(); - localStore.setMetadata({ version: 74 }); + localStore.metadata = { version: 74 }; await expect(async function () { localStore.set({ appState: { test: true } }); }).not.toThrow(); @@ -81,17 +107,17 @@ describe('LocalStore', () => { it('should set isExtensionInitialized if data is set with no error', async () => { const localStore = setup(); - localStore.setMetadata({ version: 74 }); + localStore.metadata = { version: 74 }; await localStore.set({ appState: { test: true } }); expect(localStore.isExtensionInitialized).toBeTruthy(); }); }); describe('get', () => { - it('should return undefined if called in a browser that does not support local storage', async () => { + it('should return default state tree if called in a browser that does not support local storage', async () => { const localStore = setup({ localMock: false }); const result = await localStore.get(); - expect(result).toStrictEqual(undefined); + expect(result).toStrictEqual(DEFAULT_INITIAL_STATE); }); it('should update mostRecentRetrievedState', async () => { @@ -100,28 +126,80 @@ describe('LocalStore', () => { get: jest .fn() .mockImplementation(() => - Promise.resolve({ appState: { test: true } }), + Promise.resolve({ data: { test: true } }), ), - }, + } as unknown as browser.Storage.LocalStorageArea, }); await localStore.get(); expect(localStore.mostRecentRetrievedState).toStrictEqual({ - appState: { test: true }, + data: { test: true }, }); }); - it('should reset mostRecentRetrievedState to null if storage.local is empty', async () => { + it('should return default state, reset mostRecentRetrievedState to null, and set stateCorruptionDetected to true, if storage.local is an empty object', async () => { const localStore = setup({ localMock: { get: jest.fn().mockImplementation(() => Promise.resolve({})), - }, + } as unknown as browser.Storage.LocalStorageArea, }); - await localStore.get(); + const result = await localStore.get(); + + expect(result).toStrictEqual(DEFAULT_INITIAL_STATE); + + expect(localStore.mostRecentRetrievedState).toStrictEqual(null); + expect(localStore.stateCorruptionDetected).toStrictEqual(true); + }); + + it('should return default state, reset mostRecentRetrievedState to null, and set stateCorruptionDetected to true, if storage.local returns undefined', async () => { + const localStore = setup({ + localMock: { + get: jest.fn().mockImplementation(() => Promise.resolve()), + } as unknown as browser.Storage.LocalStorageArea, + }); + + const result = await localStore.get(); + + expect(result).toStrictEqual(DEFAULT_INITIAL_STATE); + + expect(localStore.mostRecentRetrievedState).toStrictEqual(null); + expect(localStore.stateCorruptionDetected).toStrictEqual(true); + }); + + it('should return default state, reset mostRecentRetrievedState to null, and set stateCorruptionDetected to true, if storage.local returns an object without a data property', async () => { + const localStore = setup({ + localMock: { + get: jest + .fn() + .mockImplementation(() => Promise.resolve({ foo: 'bar' })), + } as unknown as browser.Storage.LocalStorageArea, + }); + + const result = await localStore.get(); + + expect(result).toStrictEqual(DEFAULT_INITIAL_STATE); + + expect(localStore.mostRecentRetrievedState).toStrictEqual(null); + expect(localStore.stateCorruptionDetected).toStrictEqual(true); + }); + + it('should return default state, reset mostRecentRetrievedState to null, and set stateCorruptionDetected to true, if storage.local returns an object with an undefined data property', async () => { + const localStore = setup({ + localMock: { + get: jest + .fn() + .mockImplementation(() => Promise.resolve({ data: undefined })), + } as unknown as browser.Storage.LocalStorageArea, + }); + + const result = await localStore.get(); + + expect(result).toStrictEqual(DEFAULT_INITIAL_STATE); expect(localStore.mostRecentRetrievedState).toStrictEqual(null); + expect(localStore.stateCorruptionDetected).toStrictEqual(true); }); it('should set mostRecentRetrievedState to current state if isExtensionInitialized is true', async () => { @@ -130,7 +208,7 @@ describe('LocalStore', () => { get: jest.fn().mockImplementation(() => Promise.resolve({})), }, }); - localStore.setMetadata({ version: 74 }); + localStore.metadata = { version: 74 }; await localStore.set({ appState: { test: true } }); await localStore.get(); expect(localStore.mostRecentRetrievedState).toStrictEqual(null); diff --git a/app/scripts/lib/Stores/ExtensionStore.ts b/app/scripts/lib/Stores/ExtensionStore.ts new file mode 100644 index 000000000000..ffee3f6bf51c --- /dev/null +++ b/app/scripts/lib/Stores/ExtensionStore.ts @@ -0,0 +1,196 @@ +import browser from 'webextension-polyfill'; +import log from 'loglevel'; +import { captureException } from '@sentry/browser'; +import { checkForLastError } from '../../../../shared/modules/browser-runtime.utils'; +import type Migrator from '../migrator'; +import { + type IntermediaryStateType, + BaseStore, + MetaMaskStorageStructure, + EmptyState, +} from './BaseStore'; + +/** + * Returns whether or not the given object contains no keys + * + * @param obj - The object to check + * @returns + */ +function isEmpty( + obj: MetaMaskStorageStructure | EmptyState, +): obj is EmptyState { + return Object.keys(obj).length === 0; +} + +/** + * An implementation of the MetaMask Extension BaseStore system that uses the + * browser.storage.local API to persist and retrieve state. + */ +export class ExtensionStore extends BaseStore { + isSupported: boolean; + + stateCorruptionDetected: boolean; + + dataPersistenceFailing: boolean; + + isExtensionInitialized: boolean; + + mostRecentRetrievedState: MetaMaskStorageStructure | null; + + migrator: Migrator; + + constructor({ migrator }: { migrator: Migrator }) { + super(); + this.stateCorruptionDetected = false; + this.isSupported = Boolean(browser.storage.local); + if (!this.isSupported) { + log.error('Storage local API not available.'); + } + // we use this flag to avoid flooding sentry with a ton of errors: + // once data persistence fails once and it flips true we don't send further + // data persistence errors to sentry + this.dataPersistenceFailing = false; + this.mostRecentRetrievedState = null; + this.isExtensionInitialized = false; + this.migrator = migrator; + } + + /** + * Persists the MetaMask state tree on the 'data' key of the + * browser.storage.local API. This function will first do some sanity checks + * to determine if it is likely for the operation to succeed. It will throw + * an error in the following cases. + * 1. The browser does not support the browser.storage.local API. + * 2. No state object was provided to the function. As more of the codebase + * is migrated to TypeScript, this should become less of a possibility but + * never impossible. + * 3. The metadata property is not set on the class which is required before + * setting state. This ensures the 'meta' key of the storage is set. + * + * @param state - The state to persist to the data key of the local store + * @returns void + */ + async set(state: IntermediaryStateType) { + if (!this.isSupported) { + throw new Error( + 'Metamask- cannot persist state to local store as this browser does not support this action', + ); + } + if (!state) { + throw new Error('MetaMask - updated state is missing'); + } + if (!this.metadata) { + throw new Error( + 'MetaMask - metadata must be set on instance of ExtensionStore before calling "set"', + ); + } + try { + // we format the data for storage as an object with the "data" key for the controller state object + // and the "meta" key for a metadata object containing a version number that tracks how the data shape + // has changed using migrations to adapt to backwards incompatible changes + await this.#set({ data: state, meta: this.metadata }); + if (this.dataPersistenceFailing) { + this.dataPersistenceFailing = false; + } + } catch (err) { + if (!this.dataPersistenceFailing) { + this.dataPersistenceFailing = true; + captureException(err); + } + log.error('error setting state in local store:', err); + } finally { + this.isExtensionInitialized = true; + } + } + + /** + * Returns all of the keys currently saved + */ + async get() { + /** + * If chrome.storage.local is not available, return the default state tree + * which will not be persisted. This should probably be a bug that we + * report to sentry. + * + */ + if (!this.isSupported) { + return this.generateFirstTimeState(); + } + try { + const result = await this.#get(); + // extension.storage.local always returns an obj + // if the object is empty, treat it as undefined + if (!result?.data) { + this.mostRecentRetrievedState = null; + this.stateCorruptionDetected = true; + + global.sentry?.captureMessage('Empty/corrupted vault found'); + + // If the data is missing, but we have a record of it existing at some + // point return an empty object, return the fallback state tree from + return this.generateFirstTimeState(); + } + if (!this.isExtensionInitialized) { + this.mostRecentRetrievedState = result; + } + return result; + } catch (err) { + this.stateCorruptionDetected = true; + global.sentry?.captureException(err); + // If we get an error trying to read the state, this indicated some kind + // of corruption or fault of the storage mechanism and we should fallback + // to the process for handling corrupted state. + return this.generateFirstTimeState(); + } + } + + async isFirstTimeInstall(): Promise { + const result = await this.#get(); + return Boolean(isEmpty(result)); + } + + cleanUpMostRecentRetrievedState() { + if (this.mostRecentRetrievedState) { + this.mostRecentRetrievedState = null; + } + } + + /** + * Returns all of the keys currently saved + * + * @private + * @returns the key-value map from local storage + */ + async #get(): Promise { + const { local } = browser.storage; + const result = await local.get(null); + const err = checkForLastError(); + if (err) { + throw err; + } + return result as MetaMaskStorageStructure; + } + + /** + * Sets the key in local state + * + * @param obj - The key to set + * @param obj.data - The MetaMask State tree + * @param obj.meta - The metadata object + * @param obj.meta.version - The version of the state tree determined by the + * migration + * @returns a promise resolving to undefined. + * @private + */ + async #set(obj: { + data: IntermediaryStateType; + meta: { version: number }; + }): Promise { + const { local } = browser.storage; + await local.set(obj); + const err = checkForLastError(); + if (err) { + throw err; + } + } +} diff --git a/app/scripts/lib/Stores/ReadOnlyNetworkStore.test.ts b/app/scripts/lib/Stores/ReadOnlyNetworkStore.test.ts new file mode 100644 index 000000000000..76abacdca317 --- /dev/null +++ b/app/scripts/lib/Stores/ReadOnlyNetworkStore.test.ts @@ -0,0 +1,199 @@ +import log from 'loglevel'; +import nock from 'nock'; +import Migrator from '../migrator'; +import ReadOnlyNetworkStore from './ReadOnlyNetworkStore'; +import { IntermediaryStateType } from './BaseStore'; + +const FIXTURE_SERVER_HOST = 'localhost'; +const FIXTURE_SERVER_PORT = 12345; +const FIXTURE_SERVER_ORIGIN = `http://${FIXTURE_SERVER_HOST}:${FIXTURE_SERVER_PORT}`; +const FIXTURE_SERVER_PATH = '/state.json'; + +const DEFAULT_INITIAL_STATE = { + data: { config: {} }, + meta: { version: 0 }, +}; + +const MOCK_STATE = { data: { config: { foo: 'bar' } }, meta: { version: 1 } }; + +/** + * Initiatilizes a ReadOnlyNetworkStore for testing + * + * @returns store - a ReadOnlyNetworkStore + */ +function setupReadOnlyNetworkStore() { + const migrator = new Migrator(); + const store = new ReadOnlyNetworkStore({ migrator }); + return store; +} + +/** + * Create a Nock scope for the fixture server response. + * + * @returns A Nock interceptor for the fixture server response. + */ +function mockFixtureServerInterceptor(): nock.Interceptor { + return nock(FIXTURE_SERVER_ORIGIN).get(FIXTURE_SERVER_PATH); +} + +/** + * Create a Nock scope for the fixture server response, which will have a successful reply. + * + * @param state + */ +function setMockFixtureServerReply(state = DEFAULT_INITIAL_STATE) { + mockFixtureServerInterceptor().reply(200, state); +} + +describe('ReadOnlyNetworkStore', () => { + beforeEach(() => { + jest.resetModules(); + }); + + describe('constructor', () => { + it('initializes with correct default properties', () => { + const store = setupReadOnlyNetworkStore(); + expect(store.mostRecentRetrievedState).toBeNull(); + expect(store.stateCorruptionDetected).toBe(false); + expect(store.dataPersistenceFailing).toBe(false); + expect(store.firstTimeInstall).toBe(false); + expect(store.isSupported).toBe(true); + }); + }); + + describe('initialization (#init)', () => { + it('loads state from the network if fetch is successful and response is ok', async () => { + setMockFixtureServerReply(MOCK_STATE); + const store = setupReadOnlyNetworkStore(); + const result = await store.get(); + expect(result).toStrictEqual(MOCK_STATE); + expect(store.mostRecentRetrievedState).toStrictEqual(MOCK_STATE); + }); + + it('does not throw, and logs a debug message, if fetch is not okay', async () => { + const logDebugSpy = jest + .spyOn(log, 'debug') + .mockImplementation(() => undefined); + + mockFixtureServerInterceptor().reply(400); + + const store = setupReadOnlyNetworkStore(); + const result = await store.get(); + expect(result).toStrictEqual(DEFAULT_INITIAL_STATE); + expect(store.mostRecentRetrievedState).toStrictEqual(null); + expect(logDebugSpy).toHaveBeenCalledWith( + 'Received response with a status of 400 Bad Request', + ); + }); + + it('does not throw, and logs a debug message, if fetch throws an error', async () => { + mockFixtureServerInterceptor().replyWithError('error!'); + const logDebugSpy = jest + .spyOn(log, 'debug') + .mockImplementation(() => undefined); + + const store = setupReadOnlyNetworkStore(); + const result = await store.get(); + expect(result).toStrictEqual(DEFAULT_INITIAL_STATE); + expect(store.mostRecentRetrievedState).toStrictEqual(null); + expect(logDebugSpy).toHaveBeenCalledWith( + "Error loading network state: 'request to http://localhost:12345/state.json failed, reason: error!'", + ); + }); + }); + + describe('get', () => { + it('returns fallback state if #state is null', async () => { + const store = setupReadOnlyNetworkStore(); + const result = await store.get(); + expect(result).toStrictEqual(DEFAULT_INITIAL_STATE); + expect(store.mostRecentRetrievedState).toStrictEqual(null); + }); + + it('returns fallback state if #state not have data', async () => { + setMockFixtureServerReply({ ...MOCK_STATE, data: null }); + + const store = setupReadOnlyNetworkStore(); + const result = await store.get(); + expect(result).toStrictEqual(DEFAULT_INITIAL_STATE); + expect(store.mostRecentRetrievedState).toStrictEqual(null); + }); + + it('returns stored state if #state has data', async () => { + setMockFixtureServerReply(MOCK_STATE); + + const store = setupReadOnlyNetworkStore(); + const result = await store.get(); + expect(result).toStrictEqual(MOCK_STATE); + }); + + it('does not overwrite mostRecentRetrievedState once set', async () => { + setMockFixtureServerReply(MOCK_STATE); + + const store = setupReadOnlyNetworkStore(); + await store.get(); // sets mostRecentRetrievedState + // Calling it again should not change mostRecentRetrievedState since it's already set + await store.get(); + expect(store.mostRecentRetrievedState).toStrictEqual(MOCK_STATE); + }); + }); + + describe('set', () => { + it('throws if not passed a state parameter', async () => { + const store = setupReadOnlyNetworkStore(); + await expect( + store.set(undefined as unknown as IntermediaryStateType), + ).rejects.toThrow('MetaMask - updated state is missing'); + }); + + it('throws if metadata has not yet been set', async () => { + const store = setupReadOnlyNetworkStore(); + await expect(store.set({ appState: { test: true } })).rejects.toThrow( + 'MetaMask - metadata must be set on instance of ExtensionStore before calling "set"', + ); + }); + + it('sets the state if metadata is set and store is supported', async () => { + const store = setupReadOnlyNetworkStore(); + store.metadata = { version: 10 }; + await store.set({ appState: { test: true } }); + const result = await store.get(); + expect(result).toStrictEqual({ + data: { appState: { test: true } }, + meta: { version: 10 }, + }); + }); + + it('throws if browser is not supported (though isSupported is always true)', async () => { + const store = setupReadOnlyNetworkStore(); + store.isSupported = false; + await expect(store.set({ appState: { test: true } })).rejects.toThrow( + 'Metamask- cannot persist state to local store as this browser does not support this action', + ); + }); + }); + + describe('isFirstTimeInstall', () => { + it('returns true if get() returns null (theoretically), otherwise false', async () => { + const store = setupReadOnlyNetworkStore(); + const result = await store.isFirstTimeInstall(); + expect(result).toBe(false); + }); + }); + + describe('cleanUpMostRecentRetrievedState', () => { + it('sets mostRecentRetrievedState to null if it is defined', async () => { + const store = setupReadOnlyNetworkStore(); + await store.get(); + store.cleanUpMostRecentRetrievedState(); + expect(store.mostRecentRetrievedState).toBeNull(); + }); + + it('does nothing if mostRecentRetrievedState is already null', async () => { + const store = setupReadOnlyNetworkStore(); + expect(store.mostRecentRetrievedState).toBeNull(); + store.cleanUpMostRecentRetrievedState(); + expect(store.mostRecentRetrievedState).toBeNull(); + }); + }); +}); diff --git a/app/scripts/lib/Stores/ReadOnlyNetworkStore.ts b/app/scripts/lib/Stores/ReadOnlyNetworkStore.ts new file mode 100644 index 000000000000..98fd5cd33ce4 --- /dev/null +++ b/app/scripts/lib/Stores/ReadOnlyNetworkStore.ts @@ -0,0 +1,128 @@ +import log from 'loglevel'; +import { isErrorWithMessage } from '@metamask/utils'; +import getFetchWithTimeout from '../../../../shared/modules/fetch-with-timeout'; +import type Migrator from '../migrator'; +import { type IntermediaryStateType, BaseStore } from './BaseStore'; + +const fetchWithTimeout = getFetchWithTimeout(); + +const FIXTURE_SERVER_HOST = 'localhost'; +const FIXTURE_SERVER_PORT = 12345; +const FIXTURE_SERVER_URL = `http://${FIXTURE_SERVER_HOST}:${FIXTURE_SERVER_PORT}/state.json`; + +/** + * A read-only network-based storage wrapper + */ +export default class ReadOnlyNetworkStore extends BaseStore { + #initialized: boolean; + + #promiseToInitialize?: Promise; + + #state: IntermediaryStateType | null; + + mostRecentRetrievedState: IntermediaryStateType | null; + + stateCorruptionDetected: boolean; + + dataPersistenceFailing: boolean; + + migrator: Migrator; + + firstTimeInstall: boolean; + + constructor({ migrator }: { migrator: Migrator }) { + super(); + this.#initialized = false; + this.#promiseToInitialize = this.#init(); + this.#state = null; + this.mostRecentRetrievedState = null; + this.stateCorruptionDetected = false; + this.dataPersistenceFailing = false; + this.migrator = migrator; + this.firstTimeInstall = false; + } + + /** + * Declares this store as compatible with the current browser + */ + isSupported = true; + + /** + * Initializes by loading state from the network + */ + async #init() { + try { + const response = await fetchWithTimeout(FIXTURE_SERVER_URL); + + if (response.ok) { + this.#state = await response.json(); + } else { + log.debug( + `Received response with a status of ${response.status} ${response.statusText}`, + ); + } + } catch (error) { + if (isErrorWithMessage(error)) { + log.debug(`Error loading network state: '${error.message}'`); + } else { + log.debug(`Unknown error loading network state`); + } + } finally { + this.#initialized = true; + } + } + + async isFirstTimeInstall(): Promise { + const result = await this.get(); + if (result === null) { + return true; + } + return false; + } + + cleanUpMostRecentRetrievedState() { + if (this.mostRecentRetrievedState) { + this.mostRecentRetrievedState = null; + } + } + + /** + * Returns state + */ + async get() { + if (!this.#initialized) { + await this.#promiseToInitialize; + } + // Delay setting this until after the first read, to match the + // behavior of the local store. + if (!this.mostRecentRetrievedState && this.#state?.data) { + this.mostRecentRetrievedState = this.#state; + } + return this.#state?.data ? this.#state : this.generateFirstTimeState(); + } + + /** + * Set state + * + * @param state - The state to set + */ + async set(state: IntermediaryStateType) { + if (!this.isSupported) { + throw new Error( + 'Metamask- cannot persist state to local store as this browser does not support this action', + ); + } + if (!state) { + throw new Error('MetaMask - updated state is missing'); + } + if (!this.metadata) { + throw new Error( + 'MetaMask - metadata must be set on instance of ExtensionStore before calling "set"', + ); + } + if (!this.#initialized) { + await this.#promiseToInitialize; + } + this.#state = { data: state, meta: this.metadata }; + } +} diff --git a/app/scripts/lib/local-store.js b/app/scripts/lib/local-store.js deleted file mode 100644 index 886693ff9469..000000000000 --- a/app/scripts/lib/local-store.js +++ /dev/null @@ -1,139 +0,0 @@ -import browser from 'webextension-polyfill'; -import log from 'loglevel'; -import { captureException } from '@sentry/browser'; -import { checkForLastError } from '../../../shared/modules/browser-runtime.utils'; - -/** - * A wrapper around the extension's storage local API - */ -export default class ExtensionStore { - constructor() { - this.isSupported = Boolean(browser.storage.local); - if (!this.isSupported) { - log.error('Storage local API not available.'); - } - // we use this flag to avoid flooding sentry with a ton of errors: - // once data persistence fails once and it flips true we don't send further - // data persistence errors to sentry - this.dataPersistenceFailing = false; - this.mostRecentRetrievedState = null; - this.isExtensionInitialized = false; - } - - setMetadata(initMetaData) { - this.metadata = initMetaData; - } - - async set(state) { - if (!this.isSupported) { - throw new Error( - 'Metamask- cannot persist state to local store as this browser does not support this action', - ); - } - if (!state) { - throw new Error('MetaMask - updated state is missing'); - } - if (!this.metadata) { - throw new Error( - 'MetaMask - metadata must be set on instance of ExtensionStore before calling "set"', - ); - } - try { - // we format the data for storage as an object with the "data" key for the controller state object - // and the "meta" key for a metadata object containing a version number that tracks how the data shape - // has changed using migrations to adapt to backwards incompatible changes - await this._set({ data: state, meta: this.metadata }); - if (this.dataPersistenceFailing) { - this.dataPersistenceFailing = false; - } - } catch (err) { - if (!this.dataPersistenceFailing) { - this.dataPersistenceFailing = true; - captureException(err); - } - log.error('error setting state in local store:', err); - } finally { - this.isExtensionInitialized = true; - } - } - - /** - * Returns all of the keys currently saved - * - * @returns {Promise<*>} - */ - async get() { - if (!this.isSupported) { - return undefined; - } - - const result = await this._get(); - // extension.storage.local always returns an obj - // if the object is empty, treat it as undefined - if (isEmpty(result)) { - this.mostRecentRetrievedState = null; - return undefined; - } - if (!this.isExtensionInitialized) { - this.mostRecentRetrievedState = result; - } - return result; - } - - /** - * Returns all of the keys currently saved - * - * @private - * @returns {object} the key-value map from local storage - */ - _get() { - const { local } = browser.storage; - return new Promise((resolve, reject) => { - local.get(null).then((/** @type {any} */ result) => { - const err = checkForLastError(); - if (err) { - reject(err); - } else { - resolve(result); - } - }); - }); - } - - /** - * Sets the key in local state - * - * @param {object} obj - The key to set - * @returns {Promise} - * @private - */ - _set(obj) { - const { local } = browser.storage; - return new Promise((resolve, reject) => { - local.set(obj).then(() => { - const err = checkForLastError(); - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); - } - - cleanUpMostRecentRetrievedState() { - if (this.mostRecentRetrievedState) { - this.mostRecentRetrievedState = null; - } - } -} - -/** - * Returns whether or not the given object contains no keys - * - * @param {object} obj - The object to check - * @returns {boolean} - */ -function isEmpty(obj) { - return Object.keys(obj).length === 0; -} diff --git a/app/scripts/lib/network-store.js b/app/scripts/lib/network-store.js deleted file mode 100644 index e167807c7d9d..000000000000 --- a/app/scripts/lib/network-store.js +++ /dev/null @@ -1,98 +0,0 @@ -import log from 'loglevel'; -import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout'; - -const fetchWithTimeout = getFetchWithTimeout(); - -const FIXTURE_SERVER_HOST = 'localhost'; -const FIXTURE_SERVER_PORT = 12345; -const FIXTURE_SERVER_URL = `http://${FIXTURE_SERVER_HOST}:${FIXTURE_SERVER_PORT}/state.json`; - -/** - * A read-only network-based storage wrapper - */ -export default class ReadOnlyNetworkStore { - constructor() { - this._initialized = false; - this._initializing = this._init(); - this._state = undefined; - this.mostRecentRetrievedState = null; - } - - /** - * Declares this store as compatible with the current browser - */ - isSupported = true; - - /** - * Initializes by loading state from the network - */ - async _init() { - try { - const response = await fetchWithTimeout(FIXTURE_SERVER_URL); - if (response.ok) { - this._state = await response.json(); - } - } catch (error) { - log.debug(`Error loading network state: '${error.message}'`); - } finally { - this._initialized = true; - } - } - - /** - * Returns state - * - * @returns {Promise} - */ - async get() { - if (!this._initialized) { - await this._initializing; - } - // Delay setting this until after the first read, to match the - // behavior of the local store. - if (!this.mostRecentRetrievedState) { - this.mostRecentRetrievedState = this._state; - } - return this._state; - } - - /** - * Set metadata/version state - * - * @param {object} metadata - The metadata/version data to set - */ - setMetadata(metadata) { - this.metadata = metadata; - } - - /** - * Set state - * - * @param {object} state - The state to set - */ - async set(state) { - if (!this.isSupported) { - throw new Error( - 'Metamask- cannot persist state to local store as this browser does not support this action', - ); - } - if (!state) { - throw new Error('MetaMask - updated state is missing'); - } - if (!this.metadata) { - throw new Error( - 'MetaMask - metadata must be set on instance of ExtensionStore before calling "set"', - ); - } - if (!this._initialized) { - await this._initializing; - } - this._state = { data: state, meta: this._metadata }; - } - - cleanUpMostRecentRetrievedState() { - if (this.mostRecentRetrievedState) { - this.mostRecentRetrievedState = null; - } - } -} diff --git a/app/scripts/lib/setup-initial-state-hooks.js b/app/scripts/lib/setup-initial-state-hooks.js index d0b689c9cb30..459bec3c05a7 100644 --- a/app/scripts/lib/setup-initial-state-hooks.js +++ b/app/scripts/lib/setup-initial-state-hooks.js @@ -1,15 +1,18 @@ import { maskObject } from '../../../shared/modules/object.utils'; import ExtensionPlatform from '../platforms/extension'; import { SENTRY_BACKGROUND_STATE } from '../constants/sentry-state'; -import LocalStore from './local-store'; -import ReadOnlyNetworkStore from './network-store'; +import ReadOnlyNetworkStore from './Stores/ReadOnlyNetworkStore'; +import { ExtensionStore } from './Stores/ExtensionStore'; +import Migrator from './migrator'; const platform = new ExtensionPlatform(); +const migrator = new Migrator(); + // This instance of `localStore` is used by Sentry to get the persisted state const sentryLocalStore = process.env.IN_TEST - ? new ReadOnlyNetworkStore() - : new LocalStore(); + ? new ReadOnlyNetworkStore({ migrator }) + : new ExtensionStore({ migrator }); /** * Get the persisted wallet state.