Skip to content

Commit

Permalink
feat: add migration 128
Browse files Browse the repository at this point in the history
  • Loading branch information
montelaidev committed Sep 24, 2024
1 parent ca9be28 commit 560bf7f
Show file tree
Hide file tree
Showing 3 changed files with 390 additions and 0 deletions.
245 changes: 245 additions & 0 deletions app/scripts/migrations/128.test.ts
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);
},
);
});
144 changes: 144 additions & 0 deletions app/scripts/migrations/128.ts
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;
}
}
1 change: 1 addition & 0 deletions app/scripts/migrations/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ const migrations = [
require('./125.1'),
require('./126'),
require('./127'),
require('./128'),
];

export default migrations;

0 comments on commit 560bf7f

Please sign in to comment.