-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
ca9be28
commit 560bf7f
Showing
3 changed files
with
390 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,245 @@ | ||
import { AccountsControllerState } from '@metamask/accounts-controller'; | ||
import { cloneDeep } from 'lodash'; | ||
import { createMockInternalAccount } from '../../../test/jest/mocks'; | ||
import { migrate, version } from './128'; | ||
|
||
const sentryCaptureExceptionMock = jest.fn(); | ||
|
||
global.sentry = { | ||
captureException: sentryCaptureExceptionMock, | ||
}; | ||
|
||
const oldVersion = 127; | ||
|
||
const mockInternalAccount = createMockInternalAccount(); | ||
const mockAccountsControllerState: AccountsControllerState = { | ||
internalAccounts: { | ||
accounts: { | ||
[mockInternalAccount.id]: mockInternalAccount, | ||
}, | ||
selectedAccount: mockInternalAccount.id, | ||
}, | ||
}; | ||
|
||
describe('migration #128', () => { | ||
afterEach(() => jest.resetAllMocks()); | ||
|
||
it('updates the version metadata', async () => { | ||
const oldStorage = { | ||
meta: { version: oldVersion }, | ||
data: { | ||
AccountsController: mockAccountsControllerState, | ||
}, | ||
}; | ||
|
||
const newStorage = await migrate(cloneDeep(oldStorage)); | ||
|
||
expect(newStorage.meta).toStrictEqual({ version }); | ||
}); | ||
|
||
it('updates selected account if it is not found in the list of accounts', async () => { | ||
const oldStorage = { | ||
meta: { version: oldVersion }, | ||
data: { | ||
AccountsController: { | ||
...mockAccountsControllerState, | ||
internalAccounts: { | ||
...mockAccountsControllerState.internalAccounts, | ||
selectedAccount: 'unknown id', | ||
}, | ||
}, | ||
}, | ||
}; | ||
|
||
const newStorage = await migrate(cloneDeep(oldStorage)); | ||
|
||
expect(newStorage.data.AccountsController).toStrictEqual({ | ||
...mockAccountsControllerState, | ||
internalAccounts: { | ||
...mockAccountsControllerState.internalAccounts, | ||
selectedAccount: mockInternalAccount.id, | ||
}, | ||
}); | ||
}); | ||
|
||
it('does nothing if the selectedAccount is found in the list of accounts', async () => { | ||
const oldStorage = { | ||
meta: { version: oldVersion }, | ||
data: { | ||
AccountsController: mockAccountsControllerState, | ||
}, | ||
}; | ||
|
||
const newStorage = await migrate(cloneDeep(oldStorage)); | ||
|
||
expect(newStorage.data).toStrictEqual(oldStorage.data); | ||
}); | ||
|
||
it('does nothing if AccountsController state is missing', async () => { | ||
const oldStorage = { | ||
meta: { version: oldVersion }, | ||
data: { | ||
OtherController: {}, | ||
}, | ||
}; | ||
|
||
const newStorage = await migrate(cloneDeep(oldStorage)); | ||
|
||
expect(newStorage.data).toStrictEqual(oldStorage.data); | ||
}); | ||
|
||
it('sets selected account to default state if there are no accounts', async () => { | ||
const oldStorage = { | ||
meta: { version: oldVersion }, | ||
data: { | ||
AccountsController: { | ||
...mockAccountsControllerState, | ||
internalAccounts: { | ||
...mockAccountsControllerState.internalAccounts, | ||
accounts: {}, | ||
}, | ||
}, | ||
}, | ||
}; | ||
|
||
const newStorage = await migrate(cloneDeep(oldStorage)); | ||
|
||
expect(newStorage.data.AccountsController).toStrictEqual({ | ||
...mockAccountsControllerState, | ||
internalAccounts: { | ||
...mockAccountsControllerState.internalAccounts, | ||
accounts: {}, | ||
selectedAccount: '', | ||
}, | ||
}); | ||
}); | ||
|
||
it('does nothing if selectedAccount is unset', async () => { | ||
const oldStorage = { | ||
meta: { version: oldVersion }, | ||
data: { | ||
AccountsController: { | ||
...mockAccountsControllerState, | ||
internalAccounts: { | ||
...mockAccountsControllerState.internalAccounts, | ||
selectedAccount: '', | ||
}, | ||
}, | ||
}, | ||
}; | ||
|
||
const newStorage = await migrate(cloneDeep(oldStorage)); | ||
|
||
expect(newStorage.data).toStrictEqual(oldStorage.data); | ||
}); | ||
|
||
const invalidState = [ | ||
{ | ||
errorMessage: `Migration ${version}: Invalid AccountsController state of type 'string'`, | ||
label: 'AccountsController type', | ||
state: { AccountsController: 'invalid' }, | ||
}, | ||
{ | ||
errorMessage: `Migration ${version}: Invalid AccountsController state, missing internalAccounts`, | ||
label: 'Missing internalAccounts', | ||
state: { AccountsController: {} }, | ||
}, | ||
{ | ||
errorMessage: `Migration ${version}: Invalid AccountsController internalAccounts state of type 'string'`, | ||
label: 'Invalid internalAccounts', | ||
state: { AccountsController: { internalAccounts: 'invalid' } }, | ||
}, | ||
{ | ||
errorMessage: `Migration ${version}: Invalid AccountsController internalAccounts state, missing selectedAccount`, | ||
label: 'Missing selectedAccount', | ||
state: { AccountsController: { internalAccounts: { accounts: {} } } }, | ||
}, | ||
{ | ||
errorMessage: `Migration ${version}: Invalid AccountsController internalAccounts.selectedAccount state of type 'object'`, | ||
label: 'Invalid selectedAccount', | ||
state: { | ||
AccountsController: { | ||
internalAccounts: { accounts: {}, selectedAccount: {} }, | ||
}, | ||
}, | ||
}, | ||
{ | ||
errorMessage: `Migration ${version}: Invalid AccountsController internalAccounts state, missing accounts`, | ||
label: 'Missing accounts', | ||
state: { | ||
AccountsController: { | ||
internalAccounts: { selectedAccount: '' }, | ||
}, | ||
}, | ||
}, | ||
{ | ||
errorMessage: `Migration ${version}: Invalid AccountsController internalAccounts.accounts state of type 'string'`, | ||
label: 'Missing accounts', | ||
state: { | ||
AccountsController: { | ||
internalAccounts: { accounts: 'invalid', selectedAccount: '' }, | ||
}, | ||
}, | ||
}, | ||
{ | ||
errorMessage: `Migration ${version}: Invalid AccountsController internalAccounts.accounts state, entry found of type 'string'`, | ||
label: 'Account entry type', | ||
state: { | ||
AccountsController: { | ||
internalAccounts: { | ||
accounts: { [mockInternalAccount.id]: 'invalid' }, | ||
selectedAccount: 'unknown id', | ||
}, | ||
}, | ||
}, | ||
}, | ||
{ | ||
errorMessage: `Migration ${version}: Invalid AccountsController internalAccounts.accounts state, entry found that is missing an id`, | ||
label: 'Account entry missing ID', | ||
state: { | ||
AccountsController: { | ||
internalAccounts: { | ||
accounts: { [mockInternalAccount.id]: {} }, | ||
selectedAccount: 'unknown id', | ||
}, | ||
}, | ||
}, | ||
}, | ||
{ | ||
errorMessage: `Migration ${version}: Invalid AccountsController internalAccounts.accounts state, entry found with an id of type 'object'`, | ||
label: 'Account entry missing ID', | ||
state: { | ||
AccountsController: { | ||
internalAccounts: { | ||
accounts: { [mockInternalAccount.id]: { id: {} } }, | ||
selectedAccount: 'unknown id', | ||
}, | ||
}, | ||
}, | ||
}, | ||
]; | ||
|
||
// @ts-expect-error 'each' function missing from type definitions, but it does exist | ||
it.each(invalidState)( | ||
'captures error when state is invalid due to: $label', | ||
async ({ | ||
errorMessage, | ||
state, | ||
}: { | ||
errorMessage: string; | ||
state: Record<string, unknown>; | ||
}) => { | ||
const oldStorage = { | ||
meta: { version: oldVersion }, | ||
data: state, | ||
}; | ||
|
||
const newStorage = await migrate(cloneDeep(oldStorage)); | ||
|
||
expect(sentryCaptureExceptionMock).toHaveBeenCalledWith( | ||
new Error(errorMessage), | ||
); | ||
expect(newStorage.data).toStrictEqual(oldStorage.data); | ||
}, | ||
); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
import { hasProperty } from '@metamask/utils'; | ||
import { cloneDeep, isObject } from 'lodash'; | ||
import log from 'loglevel'; | ||
|
||
type VersionedData = { | ||
meta: { version: number }; | ||
data: Record<string, unknown>; | ||
}; | ||
|
||
export const version = 128; | ||
|
||
/** | ||
* Fix AccountsController state corruption, where the `selectedAccount` state is set to an invalid | ||
* ID. | ||
* | ||
* @param originalVersionedData - Versioned MetaMask extension state, exactly | ||
* what we persist to dist. | ||
* @param originalVersionedData.meta - State metadata. | ||
* @param originalVersionedData.meta.version - The current state version. | ||
* @param originalVersionedData.data - The persisted MetaMask state, keyed by | ||
* controller. | ||
* @returns Updated versioned MetaMask extension state. | ||
*/ | ||
export async function migrate( | ||
originalVersionedData: VersionedData, | ||
): Promise<VersionedData> { | ||
const versionedData = cloneDeep(originalVersionedData); | ||
versionedData.meta.version = version; | ||
transformState(versionedData.data); | ||
return versionedData; | ||
} | ||
|
||
function transformState(state: Record<string, unknown>): void { | ||
if (!hasProperty(state, 'AccountsController')) { | ||
return; | ||
} | ||
|
||
const accountsControllerState = state.AccountsController; | ||
|
||
if (!isObject(accountsControllerState)) { | ||
global.sentry?.captureException( | ||
new Error( | ||
`Migration ${version}: Invalid AccountsController state of type '${typeof accountsControllerState}'`, | ||
), | ||
); | ||
return; | ||
} else if (!hasProperty(accountsControllerState, 'internalAccounts')) { | ||
global.sentry?.captureException( | ||
new Error( | ||
`Migration ${version}: Invalid AccountsController state, missing internalAccounts`, | ||
), | ||
); | ||
return; | ||
} else if (!isObject(accountsControllerState.internalAccounts)) { | ||
global.sentry?.captureException( | ||
new Error( | ||
`Migration ${version}: Invalid AccountsController internalAccounts state of type '${typeof accountsControllerState.internalAccounts}'`, | ||
), | ||
); | ||
return; | ||
} else if ( | ||
!hasProperty(accountsControllerState.internalAccounts, 'selectedAccount') | ||
) { | ||
global.sentry?.captureException( | ||
new Error( | ||
`Migration ${version}: Invalid AccountsController internalAccounts state, missing selectedAccount`, | ||
), | ||
); | ||
return; | ||
} else if ( | ||
typeof accountsControllerState.internalAccounts.selectedAccount !== 'string' | ||
) { | ||
global.sentry?.captureException( | ||
new Error( | ||
`Migration ${version}: Invalid AccountsController internalAccounts.selectedAccount state of type '${typeof accountsControllerState | ||
.internalAccounts.selectedAccount}'`, | ||
), | ||
); | ||
return; | ||
} else if ( | ||
!hasProperty(accountsControllerState.internalAccounts, 'accounts') | ||
) { | ||
global.sentry?.captureException( | ||
new Error( | ||
`Migration ${version}: Invalid AccountsController internalAccounts state, missing accounts`, | ||
), | ||
); | ||
return; | ||
} else if (!isObject(accountsControllerState.internalAccounts.accounts)) { | ||
global.sentry?.captureException( | ||
new Error( | ||
`Migration ${version}: Invalid AccountsController internalAccounts.accounts state of type '${typeof accountsControllerState | ||
.internalAccounts.accounts}'`, | ||
), | ||
); | ||
return; | ||
} | ||
|
||
if ( | ||
Object.keys(accountsControllerState.internalAccounts.accounts).length === 0 | ||
) { | ||
// In this case since there aren't any accounts, we set the selected account to the default state to unblock the extension. | ||
accountsControllerState.internalAccounts.selectedAccount = ''; | ||
return; | ||
} else if (accountsControllerState.internalAccounts.selectedAccount === '') { | ||
log.warn(`Migration ${version}: Skipping, no selected account set`); | ||
return; | ||
} | ||
|
||
const firstAccount = Object.values( | ||
accountsControllerState.internalAccounts.accounts, | ||
)[0]; | ||
if (!isObject(firstAccount)) { | ||
global.sentry?.captureException( | ||
new Error( | ||
`Migration ${version}: Invalid AccountsController internalAccounts.accounts state, entry found of type '${typeof firstAccount}'`, | ||
), | ||
); | ||
return; | ||
} else if (!hasProperty(firstAccount, 'id')) { | ||
global.sentry?.captureException( | ||
new Error( | ||
`Migration ${version}: Invalid AccountsController internalAccounts.accounts state, entry found that is missing an id`, | ||
), | ||
); | ||
return; | ||
} else if (typeof firstAccount.id !== 'string') { | ||
global.sentry?.captureException( | ||
new Error( | ||
`Migration ${version}: Invalid AccountsController internalAccounts.accounts state, entry found with an id of type '${typeof firstAccount.id}'`, | ||
), | ||
); | ||
return; | ||
} | ||
|
||
if ( | ||
!hasProperty( | ||
accountsControllerState.internalAccounts.accounts, | ||
accountsControllerState.internalAccounts.selectedAccount, | ||
) | ||
) { | ||
accountsControllerState.internalAccounts.selectedAccount = firstAccount.id; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters