diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts index 300d0e04d4..5aa1f3b8a1 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts @@ -1,13 +1,8 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import type nock from 'nock'; -import encryption, { createSHA256Hash } from '../../shared/encryption'; import { USER_STORAGE_FEATURE_NAMES } from '../../shared/storage-schema'; -import { - MOCK_INTERNAL_ACCOUNTS, - MOCK_USER_STORAGE_ACCOUNTS, -} from './__fixtures__/mockAccounts'; -import { mockUserStorageMessenger as _mockUserStorageMessenger } from './__fixtures__/mockMessenger'; +import { mockUserStorageMessenger } from './__fixtures__/mockMessenger'; import { mockEndpointBatchUpsertUserStorage, mockEndpointGetUserStorage, @@ -22,13 +17,10 @@ import { MOCK_STORAGE_KEY, } from './__fixtures__/mockStorage'; import { waitFor } from './__fixtures__/test-utils'; -import * as AccountsUserStorageModule from './accounts/user-storage'; +import { mockUserStorageMessengerForAccountSyncing } from './account-syncing/__fixtures__/test-utils'; +import * as AccountSyncControllerIntegrationModule from './account-syncing/controller-integration'; import * as NetworkSyncIntegrationModule from './network-syncing/controller-integration'; -import type { - GetUserStorageAllFeatureEntriesResponse, - GetUserStorageResponse, - UserStorageBaseOptions, -} from './services'; +import type { UserStorageBaseOptions } from './services'; import UserStorageController, { defaultState } from './UserStorageController'; describe('user-storage/user-storage-controller - constructor() tests', () => { @@ -114,6 +106,7 @@ describe('user-storage/user-storage-controller - performGetStorage() tests', () state: { isProfileSyncingEnabled: false, isProfileSyncingUpdateLoading: false, + isAccountSyncingInProgress: false, hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, }, @@ -197,6 +190,7 @@ describe('user-storage/user-storage-controller - performGetStorageAllFeatureEntr isProfileSyncingUpdateLoading: false, hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, + isAccountSyncingInProgress: false, }, }); @@ -278,6 +272,7 @@ describe('user-storage/user-storage-controller - performSetStorage() tests', () isProfileSyncingUpdateLoading: false, hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, + isAccountSyncingInProgress: false, }, }); @@ -383,6 +378,7 @@ describe('user-storage/user-storage-controller - performBatchSetStorage() tests' isProfileSyncingUpdateLoading: false, hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, + isAccountSyncingInProgress: false, }, }); @@ -485,6 +481,7 @@ describe('user-storage/user-storage-controller - performBatchDeleteStorage() tes isProfileSyncingUpdateLoading: false, hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, + isAccountSyncingInProgress: false, }, }); @@ -588,6 +585,7 @@ describe('user-storage/user-storage-controller - performDeleteStorage() tests', isProfileSyncingUpdateLoading: false, hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, + isAccountSyncingInProgress: false, }, }); @@ -688,6 +686,7 @@ describe('user-storage/user-storage-controller - performDeleteStorageAllFeatureE isProfileSyncingUpdateLoading: false, hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, + isAccountSyncingInProgress: false, }, }); @@ -780,6 +779,7 @@ describe('user-storage/user-storage-controller - getStorageKey() tests', () => { isProfileSyncingUpdateLoading: false, hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, + isAccountSyncingInProgress: false, }, }); @@ -826,6 +826,7 @@ describe('user-storage/user-storage-controller - enableProfileSyncing() tests', isProfileSyncingUpdateLoading: false, hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, + isAccountSyncingInProgress: false, }, }); @@ -838,1165 +839,121 @@ describe('user-storage/user-storage-controller - enableProfileSyncing() tests', }); describe('user-storage/user-storage-controller - syncInternalAccountsWithUserStorage() tests', () => { - it('returns void if UserStorage is not enabled', async () => { - const arrangeMocks = async () => { - return { - messengerMocks: mockUserStorageMessenger(), - mockAPI: mockEndpointGetUserStorage(), - }; - }; - - const { messengerMocks } = await arrangeMocks(); - const controller = new UserStorageController({ + const arrangeMocks = () => { + const messengerMocks = mockUserStorageMessengerForAccountSyncing(); + const mockSyncInternalAccountsWithUserStorage = jest.spyOn( + AccountSyncControllerIntegrationModule, + 'syncInternalAccountsWithUserStorage', + ); + const mockSaveInternalAccountToUserStorage = jest.spyOn( + AccountSyncControllerIntegrationModule, + 'saveInternalAccountToUserStorage', + ); + return { messenger: messengerMocks.messenger, - getMetaMetricsState: () => true, - env: { - isAccountSyncingEnabled: true, - }, - state: { - isProfileSyncingEnabled: false, - isProfileSyncingUpdateLoading: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - }, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - expect(messengerMocks.mockAccountsListAccounts).not.toHaveBeenCalled(); - }); - - it('returns void if account syncing feature flag is disabled', async () => { - const arrangeMocks = async () => { - return { - messengerMocks: mockUserStorageMessenger(), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }, - }; + mockSyncInternalAccountsWithUserStorage, + mockSaveInternalAccountToUserStorage, }; + }; - const { messengerMocks, mockAPI } = await arrangeMocks(); + // NOTE the actual testing of the implementation is done in `controller-integration.ts` file. + // See relevant unit tests to see how this feature works and is tested + it('should invoke syncing from the integration module', async () => { + const { messenger, mockSyncInternalAccountsWithUserStorage } = + arrangeMocks(); const controller = new UserStorageController({ - messenger: messengerMocks.messenger, + messenger, getMetaMetricsState: () => true, env: { + // We're only verifying that calling this controller method will call the integration module + // The actual implementation is tested in the integration tests + // This is done to prevent creating unnecessary nock instances in this test isAccountSyncingEnabled: false, }, - }); - - await controller.syncInternalAccountsWithUserStorage(); - expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(false); - }); - - it('throws if AccountsController:listAccounts fails or returns an empty list', async () => { - const mockUserStorageAccountsResponse = async () => { - return { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL, - ), - }; - }; - - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: [], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await expect( - controller.syncInternalAccountsWithUserStorage(), - ).rejects.toThrow(expect.any(Error)); - - mockAPI.mockEndpointGetUserStorage.done(); - }); - - it('uploads accounts list to user storage if user storage is empty', async () => { - const mockUserStorageAccountsResponse = { - status: 404, - body: [], - }; - - const arrangeMocks = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: MOCK_INTERNAL_ACCOUNTS.ALL.slice( - 0, - 2, - ) as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - mockUserStorageAccountsResponse, - ), - mockEndpointBatchUpsertUserStorage: - mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - undefined, - async (_uri, requestBody) => { - const decryptedBody = await decryptBatchUpsertBody( - requestBody, - MOCK_STORAGE_KEY, - ); - - const expectedBody = createExpectedAccountSyncBatchUpsertBody( - MOCK_INTERNAL_ACCOUNTS.ALL.slice(0, 2).map((account) => [ - account.address, - account as InternalAccount, - ]), - MOCK_STORAGE_KEY, - ); - - expect(decryptedBody).toStrictEqual(expectedBody); - }, - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocks(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - mockAPI.mockEndpointGetUserStorage.done(); - - expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); - expect(mockAPI.mockEndpointBatchUpsertUserStorage.isDone()).toBe(true); - }); - - it('creates internal accounts if user storage has more accounts. it also updates hasAccountSyncingSyncedAtLeastOnce accordingly', async () => { - const mockUserStorageAccountsResponse = async () => { - return { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL, - ), - }; - }; - - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: MOCK_INTERNAL_ACCOUNTS.ONE as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - mockEndpointBatchDeleteUserStorage: - mockEndpointBatchDeleteUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - undefined, - async (_uri, requestBody) => { - if (typeof requestBody === 'string') { - return; - } - - const expectedBody = createExpectedAccountSyncBatchDeleteBody( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.filter( - (account) => - !MOCK_INTERNAL_ACCOUNTS.ONE.find( - (internalAccount) => - internalAccount.address === account.a, - ), - ).map((account) => account.a), - MOCK_STORAGE_KEY, - ); - - expect(requestBody.batch_delete).toStrictEqual(expectedBody); - }, - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); - - expect(messengerMocks.mockKeyringAddNewAccount).toHaveBeenCalledTimes( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.length - - MOCK_INTERNAL_ACCOUNTS.ONE.length, - ); - - expect(mockAPI.mockEndpointBatchDeleteUserStorage.isDone()).toBe(true); - - expect(controller.state.hasAccountSyncingSyncedAtLeastOnce).toBe(true); - }); - - describe('handles corrupted user storage gracefully', () => { - const mockUserStorageAccountsResponse = async () => { - return { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.TWO_DEFAULT_NAMES_WITH_ONE_BOGUS, - ), - }; - }; - - const arrangeMocksForBogusAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - mockEndpointBatchDeleteUserStorage: - mockEndpointBatchDeleteUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - undefined, - async (_uri, requestBody) => { - if (typeof requestBody === 'string') { - return; - } - - const expectedBody = createExpectedAccountSyncBatchDeleteBody( - [ - MOCK_USER_STORAGE_ACCOUNTS - .TWO_DEFAULT_NAMES_WITH_ONE_BOGUS[1].a, - ], - MOCK_STORAGE_KEY, - ); - - expect(requestBody.batch_delete).toStrictEqual(expectedBody); - }, - ), - mockEndpointBatchUpsertUserStorage: - mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }, - }; - }; - - it('does not save the bogus account to user storage, and deletes it from user storage', async () => { - const { messengerMocks, mockAPI } = await arrangeMocksForBogusAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); - expect(mockAPI.mockEndpointBatchUpsertUserStorage.isDone()).toBe(false); - expect(mockAPI.mockEndpointBatchDeleteUserStorage.isDone()).toBe(true); - }); - - it('fires the onAccountSyncErroneousSituation callback in erroneous situations', async () => { - const onAccountSyncErroneousSituation = jest.fn(); - - const { messengerMocks } = await arrangeMocksForBogusAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - config: { - accountSyncing: { - onAccountSyncErroneousSituation, - }, - }, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - expect(onAccountSyncErroneousSituation).toHaveBeenCalledTimes(1); - }); - }); - - it('fires the onAccountAdded callback when adding an account', async () => { - const mockUserStorageAccountsResponse = async () => { - return { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL, - ), - }; - }; - - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: MOCK_INTERNAL_ACCOUNTS.ONE as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - mockEndpointBatchDeleteUserStorage: - mockEndpointBatchDeleteUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - undefined, - async (_uri, requestBody) => { - if (typeof requestBody === 'string') { - return; - } - - const expectedBody = createExpectedAccountSyncBatchDeleteBody( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.filter( - (account) => - !MOCK_INTERNAL_ACCOUNTS.ONE.find( - (internalAccount) => - internalAccount.address === account.a, - ), - ).map((account) => account.a), - MOCK_STORAGE_KEY, - ); - - expect(requestBody.batch_delete).toStrictEqual(expectedBody); - }, - ), - }, - }; - }; - - const onAccountAdded = jest.fn(); - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, config: { accountSyncing: { - onAccountAdded, + onAccountAdded: jest.fn(), + onAccountNameUpdated: jest.fn(), + onAccountSyncErroneousSituation: jest.fn(), }, }, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, }); - await controller.syncInternalAccountsWithUserStorage(); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect(onAccountAdded).toHaveBeenCalledTimes( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.length - - MOCK_INTERNAL_ACCOUNTS.ONE.length, - ); - - expect(mockAPI.mockEndpointBatchDeleteUserStorage.isDone()).toBe(true); - }); - - it('does not create internal accounts if user storage has less accounts', async () => { - const mockUserStorageAccountsResponse = async () => { - return { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.slice(0, 1), - ), - }; - }; - - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: MOCK_INTERNAL_ACCOUNTS.ALL.slice( - 0, - 2, - ) as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - mockEndpointBatchUpsertUserStorage: - mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - mockAPI.mockEndpointGetUserStorage.done(); - mockAPI.mockEndpointBatchUpsertUserStorage.done(); - - expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); - expect(mockAPI.mockEndpointBatchUpsertUserStorage.isDone()).toBe(true); - - expect(messengerMocks.mockKeyringAddNewAccount).not.toHaveBeenCalled(); - }); - - describe('User storage name is a default name', () => { - const mockUserStorageAccountsResponse = async () => { - return { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_DEFAULT_NAME, - ), - }; - }; - - it('does not update the internal account name if both user storage and internal accounts have default names', async () => { - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).not.toHaveBeenCalled(); - }); - - it('does not update the internal account name if the internal account name is custom without last updated', async () => { - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - mockEndpointBatchUpsertUserStorage: - mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - mockAPI.mockEndpointGetUserStorage.done(); - mockAPI.mockEndpointBatchUpsertUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).not.toHaveBeenCalled(); - }); - - it('does not update the internal account name if the internal account name is custom with last updated', async () => { - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - mockEndpointBatchUpsertUserStorage: - mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - mockAPI.mockEndpointGetUserStorage.done(); - mockAPI.mockEndpointBatchUpsertUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).not.toHaveBeenCalled(); - }); - }); - - describe('User storage name is a custom name without last updated', () => { - const mockUserStorageAccountsResponse = async () => { - return { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED, - ), - }; - }; - - it('updates the internal account name if the internal account name is a default name', async () => { - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).toHaveBeenCalledWith( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED[0].i, + mockSyncInternalAccountsWithUserStorage.mockImplementation( + async ( { - name: MOCK_USER_STORAGE_ACCOUNTS - .ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED[0].n, - }, - ); - }); - - it('does not update internal account name if both user storage and internal accounts have custom names without last updated', async () => { - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).not.toHaveBeenCalled(); - }); - - it('does not update the internal account name if the internal account name is custom with last updated', async () => { - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - mockEndpointBatchUpsertUserStorage: - mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - mockAPI.mockEndpointGetUserStorage.done(); - mockAPI.mockEndpointBatchUpsertUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).not.toHaveBeenCalled(); - }); - - it('fires the onAccountNameUpdated callback when renaming an internal account', async () => { - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - }, - }; - }; - - const onAccountNameUpdated = jest.fn(); - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - config: { - accountSyncing: { - onAccountNameUpdated, - }, - }, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect(onAccountNameUpdated).toHaveBeenCalledTimes(1); - }); - }); - - describe('User storage name is a custom name with last updated', () => { - const mockUserStorageAccountsResponse = async () => { - return { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED, - ), - }; - }; - - it('updates the internal account name if the internal account name is a default name', async () => { - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).toHaveBeenCalledWith( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].i, - { - name: MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0] - .n, - }, - ); - }); - - it('updates the internal account name and last updated if the internal account name is a custom name without last updated', async () => { - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, + onAccountAdded, + onAccountNameUpdated, + onAccountSyncErroneousSituation, }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).toHaveBeenCalledWith( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].i, { - name: MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0] - .n, - nameLastUpdatedAt: - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].nlu, - }, - ); - }); - - it('updates the internal account name and last updated if the user storage account is more recent', async () => { - const arrangeMocksForAccounts = async () => { - const mockGetEntriesResponse = await mockUserStorageAccountsResponse(); - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - mockGetEntriesResponse, - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, + getMessenger = jest.fn(), + getUserStorageControllerInstance = jest.fn(), }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); - - mockAPI.mockEndpointGetUserStorage.done(); - - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).toHaveBeenCalledWith( - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].i, - { - name: MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0] - .n, - nameLastUpdatedAt: - MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].nlu, - }, - ); - }); - - it('does not update the internal account if the user storage account is less recent', async () => { - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED_MOST_RECENT as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - mockEndpointBatchUpsertUserStorage: - mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }, - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocksForAccounts(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await controller.syncInternalAccountsWithUserStorage(); + ) => { + onAccountAdded?.(); + onAccountNameUpdated?.(); + onAccountSyncErroneousSituation?.('error message'); + getMessenger(); + getUserStorageControllerInstance(); + return undefined; + }, + ); - mockAPI.mockEndpointGetUserStorage.done(); - mockAPI.mockEndpointBatchUpsertUserStorage.done(); + await controller.syncInternalAccountsWithUserStorage(); - expect( - messengerMocks.mockAccountsUpdateAccountMetadata, - ).not.toHaveBeenCalled(); - }); + expect(mockSyncInternalAccountsWithUserStorage).toHaveBeenCalled(); }); }); describe('user-storage/user-storage-controller - saveInternalAccountToUserStorage() tests', () => { - it('returns void if UserStorage is not enabled', async () => { - const arrangeMocks = async () => { - return { - messengerMocks: mockUserStorageMessenger(), - }; - }; - - const mapInternalAccountToUserStorageAccountMock = jest.spyOn( - AccountsUserStorageModule, - 'mapInternalAccountToUserStorageAccount', + const arrangeMocks = () => { + const messengerMocks = mockUserStorageMessengerForAccountSyncing(); + const mockSaveInternalAccountToUserStorage = jest.spyOn( + AccountSyncControllerIntegrationModule, + 'saveInternalAccountToUserStorage', ); - - const { messengerMocks } = await arrangeMocks(); - const controller = new UserStorageController({ + return { messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - state: { - isProfileSyncingEnabled: false, - isProfileSyncingUpdateLoading: false, - hasAccountSyncingSyncedAtLeastOnce: false, - isAccountSyncingReadyToBeDispatched: false, - }, - }); - - await controller.saveInternalAccountToUserStorage( - MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, - ); - - expect(mapInternalAccountToUserStorageAccountMock).not.toHaveBeenCalled(); - }); - - it('returns void if account syncing feature flag is disabled', async () => { - const arrangeMocks = async () => { - return { - messengerMocks: mockUserStorageMessenger(), - mockAPI: mockEndpointUpsertUserStorage( - `${USER_STORAGE_FEATURE_NAMES.accounts}.${MOCK_INTERNAL_ACCOUNTS.ONE[0].address}`, - ), - }; + mockSaveInternalAccountToUserStorage, }; + }; - const { messengerMocks, mockAPI } = await arrangeMocks(); + // NOTE the actual testing of the implementation is done in `controller-integration.ts` file. + // See relevant unit tests to see how this feature works and is tested + it('should invoke syncing from the integration module', async () => { + const { messenger, mockSaveInternalAccountToUserStorage } = arrangeMocks(); const controller = new UserStorageController({ - messenger: messengerMocks.messenger, + messenger, + getMetaMetricsState: () => true, env: { + // We're only verifying that calling this controller method will call the integration module + // The actual implementation is tested in the integration tests + // This is done to prevent creating unnecessary nock instances in this test isAccountSyncingEnabled: false, }, - getMetaMetricsState: () => true, }); - await controller.saveInternalAccountToUserStorage( - MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, - ); - - expect(mockAPI.isDone()).toBe(false); - }); - - it('saves an internal account to user storage', async () => { - const arrangeMocks = async () => { - return { - messengerMocks: mockUserStorageMessenger(), - mockAPI: mockEndpointUpsertUserStorage( - `${USER_STORAGE_FEATURE_NAMES.accounts}.${MOCK_INTERNAL_ACCOUNTS.ONE[0].address}`, - ), - }; - }; - - const { messengerMocks, mockAPI } = await arrangeMocks(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, + mockSaveInternalAccountToUserStorage.mockImplementation( + async ( + _internalAccount, + _config, + { + getMessenger = jest.fn(), + getUserStorageControllerInstance = jest.fn(), + }, + ) => { + getMessenger(); + getUserStorageControllerInstance(); + return undefined; }, - getMetaMetricsState: () => true, - }); - - await controller.saveInternalAccountToUserStorage( - MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, ); - expect(mockAPI.isDone()).toBe(true); - }); - - it('rejects if api call fails', async () => { - const arrangeMocks = async () => { - return { - messengerMocks: mockUserStorageMessenger(), - mockAPI: mockEndpointUpsertUserStorage( - `${USER_STORAGE_FEATURE_NAMES.accounts}.${MOCK_INTERNAL_ACCOUNTS.ONE[0].address}`, - { status: 500 }, - ), - }; - }; - - const { messengerMocks } = await arrangeMocks(); - const controller = new UserStorageController({ - messenger: messengerMocks.messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - await expect( - controller.saveInternalAccountToUserStorage( - MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, - ), - ).rejects.toThrow(expect.any(Error)); - }); - - describe('it reacts to other controller events', () => { - const mockUserStorageAccountsResponse = async () => { - return { - status: 200, - body: await createMockUserStorageEntries( - MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL, - ), - }; - }; - - const arrangeMocksForAccounts = async () => { - return { - messengerMocks: mockUserStorageMessenger({ - accounts: { - accountsList: - MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED_MOST_RECENT as InternalAccount[], - }, - }), - mockAPI: { - mockEndpointGetUserStorage: - await mockEndpointGetUserStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - await mockUserStorageAccountsResponse(), - ), - mockEndpointBatchUpsertUserStorage: - mockEndpointBatchUpsertUserStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - ), - }, - }; - }; - - it('saves an internal account to user storage when the AccountsController:accountRenamed event is fired', async () => { - await arrangeMocksForAccounts(); - - const { baseMessenger, messenger } = mockUserStorageMessenger(); - - const controller = new UserStorageController({ - messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - // We need to sync at least once before we listen for other controller events - await controller.syncInternalAccountsWithUserStorage(); - - const mockSaveInternalAccountToUserStorage = jest - .spyOn(controller, 'saveInternalAccountToUserStorage') - .mockImplementation(); - - baseMessenger.publish( - 'AccountsController:accountRenamed', - MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, - ); - - expect(mockSaveInternalAccountToUserStorage).toHaveBeenCalledWith( - MOCK_INTERNAL_ACCOUNTS.ONE[0], - ); - }); + await controller.saveInternalAccountToUserStorage({ + id: '1', + } as InternalAccount); - it('saves an internal account to user storage when the AccountsController:accountAdded event is fired', async () => { - await arrangeMocksForAccounts(); - - const { baseMessenger, messenger } = mockUserStorageMessenger(); - - const controller = new UserStorageController({ - messenger, - env: { - isAccountSyncingEnabled: true, - }, - getMetaMetricsState: () => true, - }); - - // We need to sync at least once before we listen for other controller events - await controller.syncInternalAccountsWithUserStorage(); - - const mockSaveInternalAccountToUserStorage = jest - .spyOn(controller, 'saveInternalAccountToUserStorage') - .mockImplementation(); - - baseMessenger.publish( - 'AccountsController:accountAdded', - MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, - ); - - expect(mockSaveInternalAccountToUserStorage).toHaveBeenCalledWith( - MOCK_INTERNAL_ACCOUNTS.ONE[0], - ); - }); + expect(mockSaveInternalAccountToUserStorage).toHaveBeenCalled(); }); }); @@ -2079,122 +1036,3 @@ describe('user-storage/user-storage-controller - syncNetworks() tests', () => { expect(controller.state.hasNetworkSyncingSyncedAtLeastOnce).toBe(true); }); }); - -/** - * Jest Mock Utility - create a mock user storage messenger - * - * @param options - options for the mock messenger - * @param options.accounts - options for the accounts part of the controller - * @param options.accounts.accountsList - list of accounts to return for the 'AccountsController:listAccounts' action - * @returns Mock User Storage Messenger - */ -function mockUserStorageMessenger(options?: { - accounts?: { - accountsList?: InternalAccount[]; - }; -}) { - const messengerMocks = _mockUserStorageMessenger(); - - messengerMocks.mockKeyringAddNewAccount.mockImplementation(async () => { - messengerMocks.baseMessenger.publish( - 'AccountsController:accountAdded', - MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, - ); - return MOCK_INTERNAL_ACCOUNTS.ONE[0].address; - }); - - messengerMocks.mockAccountsListAccounts.mockReturnValue( - (options?.accounts?.accountsList ?? - MOCK_INTERNAL_ACCOUNTS.ALL) as InternalAccount[], - ); - - return messengerMocks; -} - -/** - * Test Utility - creates a realistic mock user-storage entry - * @param data - data to encrypt - * @returns user storage entry - */ -async function createMockUserStorageEntry( - data: unknown, -): Promise { - return { - HashedKey: 'HASHED_KEY', - Data: await encryption.encryptString( - JSON.stringify(data), - MOCK_STORAGE_KEY, - ), - }; -} - -/** - * Test Utility - creates a realistic mock user-storage get-all entry - * @param data - data array to encrypt - * @returns user storage entry - */ -async function createMockUserStorageEntries( - data: unknown[], -): Promise { - return await Promise.all(data.map((d) => createMockUserStorageEntry(d))); -} - -/** - * Test Utility - decrypts a realistic batch upsert payload - * @param requestBody - nock body - * @param storageKey - storage key - * @returns decrypted body - */ -async function decryptBatchUpsertBody( - requestBody: nock.Body, - storageKey: string, -) { - if (typeof requestBody === 'string') { - return requestBody; - } - return await Promise.all( - Object.entries(requestBody.data).map( - async ([entryKey, entryValue]) => { - return [ - entryKey, - await encryption.decryptString(entryValue, storageKey), - ]; - }, - ), - ); -} - -/** - * Test Utility - creates a realistic expected batch upsert payload - * @param data - data supposed to be upserted - * @param storageKey - storage key - * @returns expected body - */ -function createExpectedAccountSyncBatchUpsertBody( - data: [string, InternalAccount][], - storageKey: string, -) { - return data.map(([entryKey, entryValue]) => [ - createSHA256Hash(String(entryKey) + storageKey), - JSON.stringify( - AccountsUserStorageModule.mapInternalAccountToUserStorageAccount( - entryValue, - ), - ), - ]); -} - -/** - * Test Utility - creates a realistic expected batch delete payload - * @param data - data supposed to be deleted - * @param storageKey - storage key - * @returns expected body - */ -function createExpectedAccountSyncBatchDeleteBody( - data: string[], - storageKey: string, -) { - return data.map((entryKey) => - createSHA256Hash(String(entryKey) + storageKey), - ); -} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts index 74a3eab880..99c5606bdb 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -11,13 +11,11 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import { isEvmAccountType } from '@metamask/keyring-api'; import { type KeyringControllerGetStateAction, type KeyringControllerLockEvent, type KeyringControllerUnlockEvent, type KeyringControllerAddNewAccountAction, - KeyringTypes, } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { @@ -32,7 +30,6 @@ import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import { createSHA256Hash } from '../../shared/encryption'; import type { UserStorageFeatureKeys } from '../../shared/storage-schema'; import { - USER_STORAGE_FEATURE_NAMES, type UserStoragePathWithFeatureAndKey, type UserStoragePathWithFeatureOnly, } from '../../shared/storage-schema'; @@ -45,11 +42,11 @@ import type { AuthenticationControllerPerformSignIn, AuthenticationControllerPerformSignOut, } from '../authentication/AuthenticationController'; -import type { UserStorageAccount } from './accounts/user-storage'; import { - isNameDefaultAccountName, - mapInternalAccountToUserStorageAccount, -} from './accounts/user-storage'; + saveInternalAccountToUserStorage, + syncInternalAccountsWithUserStorage, +} from './account-syncing/controller-integration'; +import { setupAccountSyncingSubscriptions } from './account-syncing/setup-subscriptions'; import { performMainNetworkSync, startNetworkSyncing, @@ -97,6 +94,10 @@ export type UserStorageControllerState = { * Condition used by UI to determine if account syncing is ready to be dispatched. */ isAccountSyncingReadyToBeDispatched: boolean; + /** + * Condition used by UI to determine if account syncing is in progress. + */ + isAccountSyncingInProgress: boolean; /** * Condition used to ensure that we do not perform any network sync mutations until we have synced at least once */ @@ -108,6 +109,7 @@ export const defaultState: UserStorageControllerState = { isProfileSyncingUpdateLoading: false, hasAccountSyncingSyncedAtLeastOnce: false, isAccountSyncingReadyToBeDispatched: false, + isAccountSyncingInProgress: false, }; const metadata: StateMetadata = { @@ -127,6 +129,10 @@ const metadata: StateMetadata = { persist: false, anonymous: false, }, + isAccountSyncingInProgress: { + persist: false, + anonymous: false, + }, hasNetworkSyncingSyncedAtLeastOnce: { persist: true, anonymous: false, @@ -334,121 +340,6 @@ export default class UserStorageController extends BaseController< }, }; - #accounts = { - isAccountSyncingInProgress: false, - maxNumberOfAccountsToAdd: 0, - canSync: () => { - try { - this.#assertProfileSyncingEnabled(); - - if (this.#accounts.isAccountSyncingInProgress) { - return false; - } - - if (!this.#env.isAccountSyncingEnabled) { - return false; - } - - if (!this.#auth.isAuthEnabled()) { - return false; - } - - return true; - } catch { - return false; - } - }, - setupAccountSyncingSubscriptions: () => { - this.messagingSystem.subscribe( - 'AccountsController:accountAdded', - // eslint-disable-next-line @typescript-eslint/no-misused-promises - async (account) => { - if ( - !this.#accounts.canSync() || - !this.state.hasAccountSyncingSyncedAtLeastOnce - ) { - return; - } - - await this.saveInternalAccountToUserStorage(account); - }, - ); - - this.messagingSystem.subscribe( - 'AccountsController:accountRenamed', - // eslint-disable-next-line @typescript-eslint/no-misused-promises - async (account) => { - if ( - !this.#accounts.canSync() || - !this.state.hasAccountSyncingSyncedAtLeastOnce - ) { - return; - } - await this.saveInternalAccountToUserStorage(account); - }, - ); - }, - doesInternalAccountHaveCorrectKeyringType: (account: InternalAccount) => { - return account.metadata.keyring.type === KeyringTypes.hd; - }, - getInternalAccountsList: async (): Promise => { - // eslint-disable-next-line @typescript-eslint/await-thenable - const internalAccountsList = await this.messagingSystem.call( - 'AccountsController:listAccounts', - ); - - return internalAccountsList?.filter( - this.#accounts.doesInternalAccountHaveCorrectKeyringType, - ); - }, - getUserStorageAccountsList: async (): Promise< - UserStorageAccount[] | null - > => { - const rawAccountsListResponse = - await this.performGetStorageAllFeatureEntries( - USER_STORAGE_FEATURE_NAMES.accounts, - ); - - return ( - rawAccountsListResponse?.map((rawAccount) => JSON.parse(rawAccount)) ?? - null - ); - }, - saveInternalAccountToUserStorage: async ( - internalAccount: InternalAccount, - ) => { - // Map the internal account to the user storage account schema - const mappedAccount = - mapInternalAccountToUserStorageAccount(internalAccount); - - await this.performSetStorage( - // ESLint is confused here. - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `${USER_STORAGE_FEATURE_NAMES.accounts}.${internalAccount.address}`, - JSON.stringify(mappedAccount), - ); - }, - saveInternalAccountsListToUserStorage: async () => { - const internalAccountsList = - await this.#accounts.getInternalAccountsList(); - - if (!internalAccountsList) { - return; - } - - const internalAccountsListFormattedForUserStorage = - internalAccountsList.map(mapInternalAccountToUserStorageAccount); - - await this.performBatchSetStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - internalAccountsListFormattedForUserStorage.map((account) => [ - account.a, - JSON.stringify(account), - ]), - ); - }, - }; - #config?: ControllerConfig; #notificationServices = { @@ -516,14 +407,21 @@ export default class UserStorageController extends BaseController< this.#env.isNetworkSyncingEnabled = Boolean(env?.isNetworkSyncingEnabled); this.#config = config; - this.#accounts.maxNumberOfAccountsToAdd = - config?.accountSyncing?.maxNumberOfAccountsToAdd ?? 100; - this.getMetaMetricsState = getMetaMetricsState; this.#keyringController.setupLockedStateSubscriptions(); this.#registerMessageHandlers(); this.#nativeScryptCrypto = nativeScryptCrypto; - this.#accounts.setupAccountSyncingSubscriptions(); + + // Account Syncing + if (this.#env.isAccountSyncingEnabled) { + setupAccountSyncingSubscriptions( + { isAccountSyncingEnabled: true }, + { + getUserStorageControllerInstance: () => this, + getMessenger: () => this.messagingSystem, + }, + ); + } // Network Syncing if (this.#env.isNetworkSyncingEnabled) { @@ -923,7 +821,7 @@ export default class UserStorageController extends BaseController< }); } - private async setHasAccountSyncingSyncedAtLeastOnce( + async setHasAccountSyncingSyncedAtLeastOnce( hasAccountSyncingSyncedAtLeastOnce: boolean, ): Promise { this.update((state) => { @@ -932,7 +830,7 @@ export default class UserStorageController extends BaseController< }); } - public async setIsAccountSyncingReadyToBeDispatched( + async setIsAccountSyncingReadyToBeDispatched( isAccountSyncingReadyToBeDispatched: boolean, ): Promise { this.update((state) => { @@ -941,203 +839,42 @@ export default class UserStorageController extends BaseController< }); } + async setIsAccountSyncingInProgress( + isAccountSyncingInProgress: boolean, + ): Promise { + this.update((state) => { + state.isAccountSyncingInProgress = isAccountSyncingInProgress; + }); + } + /** * Syncs the internal accounts list with the user storage accounts list. * This method is used to make sure that the internal accounts list is up-to-date with the user storage accounts list and vice-versa. * It will add new accounts to the internal accounts list, update/merge conflicting names and re-upload the results in some cases to the user storage. */ async syncInternalAccountsWithUserStorage(): Promise { - if (!this.#accounts.canSync()) { - return; - } - - try { - this.#accounts.isAccountSyncingInProgress = true; - - const profileId = await this.#auth.getProfileId(); - - const userStorageAccountsList = - await this.#accounts.getUserStorageAccountsList(); - - if (!userStorageAccountsList || !userStorageAccountsList.length) { - await this.#accounts.saveInternalAccountsListToUserStorage(); - await this.setHasAccountSyncingSyncedAtLeastOnce(true); - return; - } - - // Prepare an array of internal accounts to be saved to the user storage - const internalAccountsToBeSavedToUserStorage: InternalAccount[] = []; - - // Compare internal accounts list with user storage accounts list - // First step: compare lengths - const internalAccountsList = - await this.#accounts.getInternalAccountsList(); - - if (!internalAccountsList || !internalAccountsList.length) { - throw new Error(`Failed to get internal accounts list`); - } - - const hasMoreUserStorageAccountsThanInternalAccounts = - userStorageAccountsList.length > internalAccountsList.length; - - // We don't want to remove existing accounts for a user - // so we only add new accounts if the user has more accounts in user storage than internal accounts - if (hasMoreUserStorageAccountsThanInternalAccounts) { - const numberOfAccountsToAdd = - Math.min( - userStorageAccountsList.length, - this.#accounts.maxNumberOfAccountsToAdd, - ) - internalAccountsList.length; - - // Create new accounts to match the user storage accounts list - for (let i = 0; i < numberOfAccountsToAdd; i++) { - await this.messagingSystem.call('KeyringController:addNewAccount'); - this.#config?.accountSyncing?.onAccountAdded?.(profileId); - } - } - - // Second step: compare account names - // Get the internal accounts list again since new accounts might have been added in the previous step - const refreshedInternalAccountsList = - await this.#accounts.getInternalAccountsList(); - - const newlyAddedAccounts = refreshedInternalAccountsList.filter( - (account) => - !internalAccountsList.find((a) => a.address === account.address), - ); - - for (const internalAccount of refreshedInternalAccountsList) { - const userStorageAccount = userStorageAccountsList.find( - (account) => account.a === internalAccount.address, - ); - - // If the account is not present in user storage - if (!userStorageAccount) { - // If the account was just added in the previous step, skip saving it, it's likely to be a bogus account - if (newlyAddedAccounts.includes(internalAccount)) { - this.#config?.accountSyncing?.onAccountSyncErroneousSituation?.( - profileId, - 'An account was added to the internal accounts list but was not present in the user storage accounts list', - ); - continue; - } - // Otherwise, it means that this internal account was present before the sync, and needs to be saved to the user storage - internalAccountsToBeSavedToUserStorage.push(internalAccount); - continue; - } - - // From this point on, we know that the account is present in - // both the internal accounts list and the user storage accounts list - - // One or both accounts have default names - const isInternalAccountNameDefault = isNameDefaultAccountName( - internalAccount.metadata.name, - ); - const isUserStorageAccountNameDefault = isNameDefaultAccountName( - userStorageAccount.n, - ); - - // Internal account has default name - if (isInternalAccountNameDefault) { - if (!isUserStorageAccountNameDefault) { - this.messagingSystem.call( - 'AccountsController:updateAccountMetadata', - internalAccount.id, - { - name: userStorageAccount.n, - }, - ); - - this.#config?.accountSyncing?.onAccountNameUpdated?.(profileId); - } - continue; - } - - // Internal account has custom name but user storage account has default name - if (isUserStorageAccountNameDefault) { - internalAccountsToBeSavedToUserStorage.push(internalAccount); - continue; - } - - // Both accounts have custom names - - // User storage account has a nameLastUpdatedAt timestamp - // Note: not storing the undefined checks in constants to act as a type guard - if (userStorageAccount.nlu !== undefined) { - if (internalAccount.metadata.nameLastUpdatedAt !== undefined) { - const isInternalAccountNameNewer = - internalAccount.metadata.nameLastUpdatedAt > - userStorageAccount.nlu; - - if (isInternalAccountNameNewer) { - internalAccountsToBeSavedToUserStorage.push(internalAccount); - continue; - } - } - - this.messagingSystem.call( - 'AccountsController:updateAccountMetadata', - internalAccount.id, - { - name: userStorageAccount.n, - nameLastUpdatedAt: userStorageAccount.nlu, - }, - ); - - const areInternalAndUserStorageAccountNamesEqual = - internalAccount.metadata.name === userStorageAccount.n; - - if (!areInternalAndUserStorageAccountNamesEqual) { - this.#config?.accountSyncing?.onAccountNameUpdated?.(profileId); - } - - continue; - } else if (internalAccount.metadata.nameLastUpdatedAt !== undefined) { - internalAccountsToBeSavedToUserStorage.push(internalAccount); - continue; - } - } - - // Save the internal accounts list to the user storage - if (internalAccountsToBeSavedToUserStorage.length) { - await this.performBatchSetStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - internalAccountsToBeSavedToUserStorage.map((account) => [ - account.address, - JSON.stringify(mapInternalAccountToUserStorageAccount(account)), - ]), - ); - } - - // In case we have corrupted user storage with accounts that don't exist in the internal accounts list - // Delete those accounts from the user storage - const userStorageAccountsToBeDeleted = userStorageAccountsList.filter( - (account) => - !refreshedInternalAccountsList.find((a) => a.address === account.a), - ); - - if (userStorageAccountsToBeDeleted.length) { - await this.performBatchDeleteStorage( - USER_STORAGE_FEATURE_NAMES.accounts, - userStorageAccountsToBeDeleted.map((account) => account.a), - ); - this.#config?.accountSyncing?.onAccountSyncErroneousSituation?.( - profileId, - 'An account was present in the user storage accounts list but was not found in the internal accounts list after the sync', - ); - } + const profileId = await this.#auth.getProfileId(); - // We do this here and not in the finally statement because we want to make sure that - // the accounts are saved / updated / deleted at least once before we set this flag - await this.setHasAccountSyncingSyncedAtLeastOnce(true); - } catch (e) { - const errorMessage = e instanceof Error ? e.message : JSON.stringify(e); - throw new Error( - `${controllerName} - failed to sync user storage accounts list - ${errorMessage}`, - ); - } finally { - this.#accounts.isAccountSyncingInProgress = false; - } + await syncInternalAccountsWithUserStorage( + { + isAccountSyncingEnabled: this.#env.isAccountSyncingEnabled, + maxNumberOfAccountsToAdd: + this.#config?.accountSyncing?.maxNumberOfAccountsToAdd, + onAccountAdded: () => + this.#config?.accountSyncing?.onAccountAdded?.(profileId), + onAccountNameUpdated: () => + this.#config?.accountSyncing?.onAccountNameUpdated?.(profileId), + onAccountSyncErroneousSituation: (situationMessage) => + this.#config?.accountSyncing?.onAccountSyncErroneousSituation?.( + profileId, + situationMessage, + ), + }, + { + getMessenger: () => this.messagingSystem, + getUserStorageControllerInstance: () => this, + }, + ); } /** @@ -1147,22 +884,14 @@ export default class UserStorageController extends BaseController< async saveInternalAccountToUserStorage( internalAccount: InternalAccount, ): Promise { - if ( - !this.#accounts.canSync() || - !isEvmAccountType(internalAccount.type) || - !this.#accounts.doesInternalAccountHaveCorrectKeyringType(internalAccount) - ) { - return; - } - - try { - await this.#accounts.saveInternalAccountToUserStorage(internalAccount); - } catch (e) { - const errorMessage = e instanceof Error ? e.message : JSON.stringify(e); - throw new Error( - `${controllerName} - failed to save account to user storage - ${errorMessage}`, - ); - } + await saveInternalAccountToUserStorage( + internalAccount, + { isAccountSyncingEnabled: this.#env.isAccountSyncingEnabled }, + { + getMessenger: () => this.messagingSystem, + getUserStorageControllerInstance: () => this, + }, + ); } async syncNetworks() { diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/test-utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/test-utils.ts index 6c0983fd23..6bb1b38689 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/test-utils.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/test-utils.ts @@ -1,3 +1,65 @@ +import type nock from 'nock'; + +import encryption from '../../../shared/encryption/encryption'; +import type { + GetUserStorageAllFeatureEntriesResponse, + GetUserStorageResponse, +} from '../services'; +import { MOCK_STORAGE_KEY } from './mockStorage'; + +/** + * Test Utility - creates a realistic mock user-storage entry + * @param data - data to encrypt + * @returns user storage entry + */ +export async function createMockUserStorageEntry( + data: unknown, +): Promise { + return { + HashedKey: 'HASHED_KEY', + Data: await encryption.encryptString( + JSON.stringify(data), + MOCK_STORAGE_KEY, + ), + }; +} + +/** + * Test Utility - creates a realistic mock user-storage get-all entry + * @param data - data array to encrypt + * @returns user storage entry + */ +export async function createMockUserStorageEntries( + data: unknown[], +): Promise { + return await Promise.all(data.map((d) => createMockUserStorageEntry(d))); +} + +/** + * Test Utility - decrypts a realistic batch upsert payload + * @param requestBody - nock body + * @param storageKey - storage key + * @returns decrypted body + */ +export async function decryptBatchUpsertBody( + requestBody: nock.Body, + storageKey: string, +) { + if (typeof requestBody === 'string') { + return requestBody; + } + return await Promise.all( + Object.entries(requestBody.data).map( + async ([entryKey, entryValue]) => { + return [ + entryKey, + await encryption.decryptString(entryValue, storageKey), + ]; + }, + ), + ); +} + type WaitForOptions = { intervalMs?: number; timeoutMs?: number; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockAccounts.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/mockAccounts.ts similarity index 96% rename from packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockAccounts.ts rename to packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/mockAccounts.ts index 8664fcd6cc..faf0e027fa 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockAccounts.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/mockAccounts.ts @@ -2,8 +2,8 @@ import { EthAccountType } from '@metamask/keyring-api'; import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { LOCALIZED_DEFAULT_ACCOUNT_NAMES } from '../accounts/constants'; -import { mapInternalAccountToUserStorageAccount } from '../accounts/user-storage'; +import { LOCALIZED_DEFAULT_ACCOUNT_NAMES } from '../constants'; +import { mapInternalAccountToUserStorageAccount } from '../utils'; /** * Map an array of internal accounts to an array of user storage accounts diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/test-utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/test-utils.ts new file mode 100644 index 0000000000..fc03dd2343 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/__fixtures__/test-utils.ts @@ -0,0 +1,68 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import { createSHA256Hash } from '../../../../shared/encryption'; +import { mockUserStorageMessenger } from '../../__fixtures__/mockMessenger'; +import { mapInternalAccountToUserStorageAccount } from '../utils'; +import { MOCK_INTERNAL_ACCOUNTS } from './mockAccounts'; + +/** + * Test Utility - create a mock user storage messenger for account syncing tests + * + * @param options - options for the mock messenger + * @param options.accounts - options for the accounts part of the controller + * @param options.accounts.accountsList - list of accounts to return for the 'AccountsController:listAccounts' action + * @returns Mock User Storage Messenger + */ +export function mockUserStorageMessengerForAccountSyncing(options?: { + accounts?: { + accountsList?: InternalAccount[]; + }; +}) { + const messengerMocks = mockUserStorageMessenger(); + + messengerMocks.mockKeyringAddNewAccount.mockImplementation(async () => { + messengerMocks.baseMessenger.publish( + 'AccountsController:accountAdded', + MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, + ); + return MOCK_INTERNAL_ACCOUNTS.ONE[0].address; + }); + + messengerMocks.mockAccountsListAccounts.mockReturnValue( + (options?.accounts?.accountsList ?? + MOCK_INTERNAL_ACCOUNTS.ALL) as InternalAccount[], + ); + + return messengerMocks; +} + +/** + * Test Utility - creates a realistic expected batch upsert payload + * @param data - data supposed to be upserted + * @param storageKey - storage key + * @returns expected body + */ +export function createExpectedAccountSyncBatchUpsertBody( + data: [string, InternalAccount][], + storageKey: string, +) { + return data.map(([entryKey, entryValue]) => [ + createSHA256Hash(String(entryKey) + storageKey), + JSON.stringify(mapInternalAccountToUserStorageAccount(entryValue)), + ]); +} + +/** + * Test Utility - creates a realistic expected batch delete payload + * @param data - data supposed to be deleted + * @param storageKey - storage key + * @returns expected body + */ +export function createExpectedAccountSyncBatchDeleteBody( + data: string[], + storageKey: string, +) { + return data.map((entryKey) => + createSHA256Hash(String(entryKey) + storageKey), + ); +} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/accounts/constants.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/constants.ts similarity index 100% rename from packages/profile-sync-controller/src/controllers/user-storage/accounts/constants.ts rename to packages/profile-sync-controller/src/controllers/user-storage/account-syncing/constants.ts diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts new file mode 100644 index 0000000000..d0a9bf109e --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.test.ts @@ -0,0 +1,1110 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import UserStorageController, { USER_STORAGE_FEATURE_NAMES } from '..'; +import { MOCK_STORAGE_KEY } from '../__fixtures__'; +import { + mockEndpointBatchDeleteUserStorage, + mockEndpointBatchUpsertUserStorage, + mockEndpointGetUserStorage, + mockEndpointGetUserStorageAllFeatureEntries, + mockEndpointUpsertUserStorage, +} from '../__fixtures__/mockServices'; +import { + createMockUserStorageEntries, + decryptBatchUpsertBody, +} from '../__fixtures__/test-utils'; +import { + MOCK_INTERNAL_ACCOUNTS, + MOCK_USER_STORAGE_ACCOUNTS, +} from './__fixtures__/mockAccounts'; +import { + createExpectedAccountSyncBatchDeleteBody, + createExpectedAccountSyncBatchUpsertBody, + mockUserStorageMessengerForAccountSyncing, +} from './__fixtures__/test-utils'; +import * as AccountSyncingControllerIntegrationModule from './controller-integration'; +import * as AccountSyncingUtils from './sync-utils'; +import * as AccountsUserStorageModule from './utils'; + +const baseState = { + isProfileSyncingEnabled: true, + isProfileSyncingUpdateLoading: false, + hasAccountSyncingSyncedAtLeastOnce: false, + isAccountSyncingReadyToBeDispatched: false, + isAccountSyncingInProgress: false, +}; + +const arrangeMocks = async ({ + isAccountSyncingEnabled = true, + stateOverrides = baseState as Partial, + messengerMockOptions = undefined as Parameters< + typeof mockUserStorageMessengerForAccountSyncing + >[0], +}) => { + const messengerMocks = + mockUserStorageMessengerForAccountSyncing(messengerMockOptions); + const controller = new UserStorageController({ + messenger: messengerMocks.messenger, + env: { + isAccountSyncingEnabled, + }, + getMetaMetricsState: () => true, + state: { + ...baseState, + ...stateOverrides, + }, + }); + + const config = { + isAccountSyncingEnabled, + }; + + const options = { + getMessenger: () => messengerMocks.messenger, + getUserStorageControllerInstance: () => controller, + }; + + return { + messengerMocks, + controller, + config, + options, + }; +}; + +describe('user-storage/account-syncing/controller-integration - saveInternalAccountsListToUserStorage() tests', () => { + it('returns void if account syncing is not enabled', async () => { + const { controller, config, options } = await arrangeMocks({ + isAccountSyncingEnabled: false, + }); + + const mockPerformBatchSetStorage = jest + .spyOn(controller, 'performBatchSetStorage') + .mockImplementation(() => Promise.resolve()); + + await AccountSyncingControllerIntegrationModule.saveInternalAccountsListToUserStorage( + config, + options, + ); + + expect(mockPerformBatchSetStorage).not.toHaveBeenCalled(); + }); + + it('returns void if account syncing is enabled but the internal accounts list is empty', async () => { + const { controller, config, options } = await arrangeMocks({}); + + const mockPerformBatchSetStorage = jest + .spyOn(controller, 'performBatchSetStorage') + .mockImplementation(() => Promise.resolve()); + + jest + .spyOn(AccountSyncingUtils, 'getInternalAccountsList') + .mockResolvedValue([]); + + await AccountSyncingControllerIntegrationModule.saveInternalAccountsListToUserStorage( + config, + options, + ); + + expect(mockPerformBatchSetStorage).not.toHaveBeenCalled(); + }); +}); + +describe('user-storage/account-syncing/controller-integration - syncInternalAccountsWithUserStorage() tests', () => { + it('returns void if UserStorage is not enabled', async () => { + const { config, controller, messengerMocks, options } = await arrangeMocks({ + stateOverrides: { + isProfileSyncingEnabled: false, + }, + }); + + await mockEndpointGetUserStorage(); + + await controller.setIsAccountSyncingReadyToBeDispatched(true); + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + expect(messengerMocks.mockAccountsListAccounts).not.toHaveBeenCalled(); + }); + + it('returns void if account syncing feature flag is disabled', async () => { + const { config, options } = await arrangeMocks({ + isAccountSyncingEnabled: false, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(false); + }); + + it('throws if AccountsController:listAccounts fails or returns an empty list', async () => { + const { config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: [], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL, + ), + }, + ), + }; + + await expect( + AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ), + ).rejects.toThrow(expect.any(Error)); + + mockAPI.mockEndpointGetUserStorage.done(); + }); + + it('uploads accounts list to user storage if user storage is empty', async () => { + const { config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: MOCK_INTERNAL_ACCOUNTS.ALL.slice( + 0, + 2, + ) as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 404, + body: [], + }, + ), + mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + undefined, + async (_uri, requestBody) => { + const decryptedBody = await decryptBatchUpsertBody( + requestBody, + MOCK_STORAGE_KEY, + ); + + const expectedBody = createExpectedAccountSyncBatchUpsertBody( + MOCK_INTERNAL_ACCOUNTS.ALL.slice(0, 2).map((account) => [ + account.address, + account as InternalAccount, + ]), + MOCK_STORAGE_KEY, + ); + + expect(decryptedBody).toStrictEqual(expectedBody); + }, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + mockAPI.mockEndpointGetUserStorage.done(); + + expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); + expect(mockAPI.mockEndpointBatchUpsertUserStorage.isDone()).toBe(true); + }); + + it('creates internal accounts if user storage has more accounts. it also updates hasAccountSyncingSyncedAtLeastOnce accordingly', async () => { + const { messengerMocks, controller, config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: MOCK_INTERNAL_ACCOUNTS.ONE as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL, + ), + }, + ), + mockEndpointBatchDeleteUserStorage: mockEndpointBatchDeleteUserStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + undefined, + async (_uri, requestBody) => { + if (typeof requestBody === 'string') { + return; + } + + const expectedBody = createExpectedAccountSyncBatchDeleteBody( + MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.filter( + (account) => + !MOCK_INTERNAL_ACCOUNTS.ONE.find( + (internalAccount) => internalAccount.address === account.a, + ), + ).map((account) => account.a), + MOCK_STORAGE_KEY, + ); + + expect(requestBody.batch_delete).toStrictEqual(expectedBody); + }, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + + expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); + + expect(messengerMocks.mockKeyringAddNewAccount).toHaveBeenCalledTimes( + MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.length - + MOCK_INTERNAL_ACCOUNTS.ONE.length, + ); + + expect(mockAPI.mockEndpointBatchDeleteUserStorage.isDone()).toBe(true); + + expect(controller.state.hasAccountSyncingSyncedAtLeastOnce).toBe(true); + }); + + describe('handles corrupted user storage gracefully', () => { + const arrangeMocksForBogusAccounts = async () => { + const { messengerMocks, config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: + MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as InternalAccount[], + }, + }, + }); + + return { + config, + options, + messengerMocks, + mockAPI: { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.TWO_DEFAULT_NAMES_WITH_ONE_BOGUS, + ), + }, + ), + mockEndpointBatchDeleteUserStorage: + mockEndpointBatchDeleteUserStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + undefined, + async (_uri, requestBody) => { + if (typeof requestBody === 'string') { + return; + } + + const expectedBody = createExpectedAccountSyncBatchDeleteBody( + [ + MOCK_USER_STORAGE_ACCOUNTS + .TWO_DEFAULT_NAMES_WITH_ONE_BOGUS[1].a, + ], + MOCK_STORAGE_KEY, + ); + + expect(requestBody.batch_delete).toStrictEqual(expectedBody); + }, + ), + mockEndpointBatchUpsertUserStorage: + mockEndpointBatchUpsertUserStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + ), + }, + }; + }; + + it('does not save the bogus account to user storage, and deletes it from user storage', async () => { + const { config, options, mockAPI } = await arrangeMocksForBogusAccounts(); + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); + expect(mockAPI.mockEndpointBatchUpsertUserStorage.isDone()).toBe(false); + expect(mockAPI.mockEndpointBatchDeleteUserStorage.isDone()).toBe(true); + }); + + it('fires the onAccountSyncErroneousSituation callback in erroneous situations', async () => { + const onAccountSyncErroneousSituation = jest.fn(); + + const { config, options } = await arrangeMocksForBogusAccounts(); + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + { + ...config, + onAccountSyncErroneousSituation, + }, + options, + ); + + expect(onAccountSyncErroneousSituation).toHaveBeenCalledTimes(1); + }); + }); + + it('fires the onAccountAdded callback when adding an account', async () => { + const { config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: MOCK_INTERNAL_ACCOUNTS.ONE as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL, + ), + }, + ), + mockEndpointBatchDeleteUserStorage: mockEndpointBatchDeleteUserStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + undefined, + async (_uri, requestBody) => { + if (typeof requestBody === 'string') { + return; + } + + const expectedBody = createExpectedAccountSyncBatchDeleteBody( + MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.filter( + (account) => + !MOCK_INTERNAL_ACCOUNTS.ONE.find( + (internalAccount) => internalAccount.address === account.a, + ), + ).map((account) => account.a), + MOCK_STORAGE_KEY, + ); + + expect(requestBody.batch_delete).toStrictEqual(expectedBody); + }, + ), + }; + + const onAccountAdded = jest.fn(); + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + { + ...config, + onAccountAdded, + }, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + + expect(onAccountAdded).toHaveBeenCalledTimes( + MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.length - + MOCK_INTERNAL_ACCOUNTS.ONE.length, + ); + + expect(mockAPI.mockEndpointBatchDeleteUserStorage.isDone()).toBe(true); + }); + + it('does not create internal accounts if user storage has less accounts', async () => { + const { messengerMocks, config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: MOCK_INTERNAL_ACCOUNTS.ALL.slice( + 0, + 2, + ) as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL.slice(0, 1), + ), + }, + ), + mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + mockAPI.mockEndpointBatchUpsertUserStorage.done(); + + expect(mockAPI.mockEndpointGetUserStorage.isDone()).toBe(true); + expect(mockAPI.mockEndpointBatchUpsertUserStorage.isDone()).toBe(true); + + expect(messengerMocks.mockKeyringAddNewAccount).not.toHaveBeenCalled(); + }); + + describe('User storage name is a default name', () => { + it('does not update the internal account name if both user storage and internal accounts have default names', async () => { + const { messengerMocks, config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: + MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.ONE_DEFAULT_NAME, + ), + }, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + + expect( + messengerMocks.mockAccountsUpdateAccountMetadata, + ).not.toHaveBeenCalled(); + }); + + it('does not update the internal account name if the internal account name is custom without last updated', async () => { + const { messengerMocks, config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: + MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.ONE_DEFAULT_NAME, + ), + }, + ), + mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + mockAPI.mockEndpointBatchUpsertUserStorage.done(); + + expect( + messengerMocks.mockAccountsUpdateAccountMetadata, + ).not.toHaveBeenCalled(); + }); + + it('does not update the internal account name if the internal account name is custom with last updated', async () => { + const { messengerMocks, config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: + MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.ONE_DEFAULT_NAME, + ), + }, + ), + mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + mockAPI.mockEndpointBatchUpsertUserStorage.done(); + + expect( + messengerMocks.mockAccountsUpdateAccountMetadata, + ).not.toHaveBeenCalled(); + }); + }); + + describe('User storage name is a custom name without last updated', () => { + it('updates the internal account name if the internal account name is a default name', async () => { + const { messengerMocks, config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: + MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED, + ), + }, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + + expect( + messengerMocks.mockAccountsUpdateAccountMetadata, + ).toHaveBeenCalledWith( + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED[0].i, + { + name: MOCK_USER_STORAGE_ACCOUNTS + .ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED[0].n, + }, + ); + }); + + it('does not update internal account name if both user storage and internal accounts have custom names without last updated', async () => { + const { messengerMocks, config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: + MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED, + ), + }, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + + expect( + messengerMocks.mockAccountsUpdateAccountMetadata, + ).not.toHaveBeenCalled(); + }); + + it('does not update the internal account name if the internal account name is custom with last updated', async () => { + const { messengerMocks, config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: + MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED, + ), + }, + ), + mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + mockAPI.mockEndpointBatchUpsertUserStorage.done(); + + expect( + messengerMocks.mockAccountsUpdateAccountMetadata, + ).not.toHaveBeenCalled(); + }); + + it('fires the onAccountNameUpdated callback when renaming an internal account', async () => { + const { config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: + MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED, + ), + }, + ), + }; + + const onAccountNameUpdated = jest.fn(); + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + { + ...config, + onAccountNameUpdated, + }, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + + expect(onAccountNameUpdated).toHaveBeenCalledTimes(1); + }); + }); + + describe('User storage name is a custom name with last updated', () => { + it('updates the internal account name if the internal account name is a default name', async () => { + const { messengerMocks, config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: + MOCK_INTERNAL_ACCOUNTS.ONE_DEFAULT_NAME as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED, + ), + }, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + + expect( + messengerMocks.mockAccountsUpdateAccountMetadata, + ).toHaveBeenCalledWith( + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].i, + { + name: MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0] + .n, + }, + ); + }); + + it('updates the internal account name and last updated if the internal account name is a custom name without last updated', async () => { + const { messengerMocks, config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: + MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITHOUT_LAST_UPDATED as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED, + ), + }, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + + expect( + messengerMocks.mockAccountsUpdateAccountMetadata, + ).toHaveBeenCalledWith( + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].i, + { + name: MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0] + .n, + nameLastUpdatedAt: + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].nlu, + }, + ); + }); + + it('updates the internal account name and last updated if the user storage account is more recent', async () => { + const { messengerMocks, config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: + MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED, + ), + }, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + + expect( + messengerMocks.mockAccountsUpdateAccountMetadata, + ).toHaveBeenCalledWith( + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].i, + { + name: MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0] + .n, + nameLastUpdatedAt: + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED[0].nlu, + }, + ); + }); + + it('does not update the internal account if the user storage account is less recent', async () => { + const { messengerMocks, config, options } = await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: + MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED_MOST_RECENT as InternalAccount[], + }, + }, + }); + + const mockAPI = { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED, + ), + }, + ), + mockEndpointBatchUpsertUserStorage: mockEndpointBatchUpsertUserStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + ), + }; + + await AccountSyncingControllerIntegrationModule.syncInternalAccountsWithUserStorage( + config, + options, + ); + + mockAPI.mockEndpointGetUserStorage.done(); + mockAPI.mockEndpointBatchUpsertUserStorage.done(); + + expect( + messengerMocks.mockAccountsUpdateAccountMetadata, + ).not.toHaveBeenCalled(); + }); + }); +}); + +describe('user-storage/account-syncing/controller-integration - saveInternalAccountToUserStorage() tests', () => { + it('returns void if UserStorage is not enabled', async () => { + const { config, options } = await arrangeMocks({ + stateOverrides: { + isProfileSyncingEnabled: false, + }, + }); + + const mapInternalAccountToUserStorageAccountMock = jest.spyOn( + AccountsUserStorageModule, + 'mapInternalAccountToUserStorageAccount', + ); + + await AccountSyncingControllerIntegrationModule.saveInternalAccountToUserStorage( + MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, + config, + options, + ); + + expect(mapInternalAccountToUserStorageAccountMock).not.toHaveBeenCalled(); + }); + + it('returns void if account syncing feature flag is disabled', async () => { + const { config, options } = await arrangeMocks({ + isAccountSyncingEnabled: false, + }); + + const mockAPI = { + mockEndpointUpsertUserStorage: mockEndpointUpsertUserStorage( + `${USER_STORAGE_FEATURE_NAMES.accounts}.${MOCK_INTERNAL_ACCOUNTS.ONE[0].address}`, + ), + }; + + await AccountSyncingControllerIntegrationModule.saveInternalAccountToUserStorage( + MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, + config, + options, + ); + + expect(mockAPI.mockEndpointUpsertUserStorage.isDone()).toBe(false); + }); + + it('saves an internal account to user storage', async () => { + const { config, options } = await arrangeMocks({}); + const mockAPI = { + mockEndpointUpsertUserStorage: mockEndpointUpsertUserStorage( + `${USER_STORAGE_FEATURE_NAMES.accounts}.${MOCK_INTERNAL_ACCOUNTS.ONE[0].address}`, + ), + }; + + await AccountSyncingControllerIntegrationModule.saveInternalAccountToUserStorage( + MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, + config, + options, + ); + + expect(mockAPI.mockEndpointUpsertUserStorage.isDone()).toBe(true); + }); + + it('rejects if api call fails', async () => { + const { config, options } = await arrangeMocks({}); + + mockEndpointUpsertUserStorage( + `${USER_STORAGE_FEATURE_NAMES.accounts}.${MOCK_INTERNAL_ACCOUNTS.ONE[0].address}`, + { status: 500 }, + ); + + await expect( + AccountSyncingControllerIntegrationModule.saveInternalAccountToUserStorage( + MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, + config, + options, + ), + ).rejects.toThrow(expect.any(Error)); + }); + + describe('it reacts to other controller events', () => { + const arrangeMocksForAccounts = async () => { + const { messengerMocks, controller, config, options } = + await arrangeMocks({ + messengerMockOptions: { + accounts: { + accountsList: + MOCK_INTERNAL_ACCOUNTS.ONE_CUSTOM_NAME_WITH_LAST_UPDATED_MOST_RECENT as InternalAccount[], + }, + }, + }); + + return { + config, + options, + controller, + messengerMocks, + mockAPI: { + mockEndpointGetUserStorage: + await mockEndpointGetUserStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + { + status: 200, + body: await createMockUserStorageEntries( + MOCK_USER_STORAGE_ACCOUNTS.SAME_AS_INTERNAL_ALL, + ), + }, + ), + mockEndpointBatchUpsertUserStorage: + mockEndpointBatchUpsertUserStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + ), + mockEndpointUpsertUserStorage: mockEndpointUpsertUserStorage( + `${USER_STORAGE_FEATURE_NAMES.accounts}.${MOCK_INTERNAL_ACCOUNTS.ONE[0].address}`, + ), + }, + }; + }; + + it('saves an internal account to user storage when the AccountsController:accountRenamed event is fired', async () => { + const { messengerMocks, controller } = await arrangeMocksForAccounts(); + + // We need to sync at least once before we listen for other controller events + await controller.setHasAccountSyncingSyncedAtLeastOnce(true); + + const mockSaveInternalAccountToUserStorage = jest + .spyOn( + AccountSyncingControllerIntegrationModule, + 'saveInternalAccountToUserStorage', + ) + .mockImplementation(); + + messengerMocks.baseMessenger.publish( + 'AccountsController:accountRenamed', + MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, + ); + + expect(mockSaveInternalAccountToUserStorage).toHaveBeenCalledWith( + MOCK_INTERNAL_ACCOUNTS.ONE[0], + expect.anything(), + expect.anything(), + ); + }); + + it('does not save an internal account to user storage when the AccountsController:accountRenamed event is fired and account syncing has never been dispatched at least once', async () => { + const { messengerMocks } = await arrangeMocksForAccounts(); + + const mockSaveInternalAccountToUserStorage = jest + .spyOn( + AccountSyncingControllerIntegrationModule, + 'saveInternalAccountToUserStorage', + ) + .mockImplementation(); + + messengerMocks.baseMessenger.publish( + 'AccountsController:accountRenamed', + MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, + ); + + expect(mockSaveInternalAccountToUserStorage).not.toHaveBeenCalled(); + }); + + it('saves an internal account to user storage when the AccountsController:accountAdded event is fired', async () => { + const { controller, messengerMocks } = await arrangeMocksForAccounts(); + + // We need to sync at least once before we listen for other controller events + await controller.setHasAccountSyncingSyncedAtLeastOnce(true); + + const mockSaveInternalAccountToUserStorage = jest + .spyOn( + AccountSyncingControllerIntegrationModule, + 'saveInternalAccountToUserStorage', + ) + .mockImplementation(); + + messengerMocks.baseMessenger.publish( + 'AccountsController:accountAdded', + MOCK_INTERNAL_ACCOUNTS.ONE[0] as InternalAccount, + ); + + expect(mockSaveInternalAccountToUserStorage).toHaveBeenCalledWith( + MOCK_INTERNAL_ACCOUNTS.ONE[0], + expect.anything(), + expect.anything(), + ); + }); + }); +}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.ts new file mode 100644 index 0000000000..e39498d58b --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/controller-integration.ts @@ -0,0 +1,319 @@ +import { isEvmAccountType } from '@metamask/keyring-api'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; +import { + canPerformAccountSyncing, + getInternalAccountsList, + getUserStorageAccountsList, +} from './sync-utils'; +import type { AccountSyncingConfig, AccountSyncingOptions } from './types'; +import { + doesInternalAccountHaveCorrectKeyringType, + isNameDefaultAccountName, + mapInternalAccountToUserStorageAccount, +} from './utils'; + +/** + * Saves an individual internal account to the user storage. + * @param internalAccount - The internal account to save + * @param config - parameters used for saving the internal account + * @param options - parameters used for saving the internal account + */ +export async function saveInternalAccountToUserStorage( + internalAccount: InternalAccount, + config: AccountSyncingConfig, + options: AccountSyncingOptions, +): Promise { + const { isAccountSyncingEnabled } = config; + const { getUserStorageControllerInstance } = options; + + if ( + !isAccountSyncingEnabled || + !canPerformAccountSyncing(config, options) || + !isEvmAccountType(internalAccount.type) || + !doesInternalAccountHaveCorrectKeyringType(internalAccount) + ) { + return; + } + + try { + // Map the internal account to the user storage account schema + const mappedAccount = + mapInternalAccountToUserStorageAccount(internalAccount); + + await getUserStorageControllerInstance().performSetStorage( + // ESLint is confused here. + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${USER_STORAGE_FEATURE_NAMES.accounts}.${internalAccount.address}`, + JSON.stringify(mappedAccount), + ); + } catch (e) { + // istanbul ignore next + const errorMessage = e instanceof Error ? e.message : JSON.stringify(e); + throw new Error( + `UserStorageController - failed to save account to user storage - ${errorMessage}`, + ); + } +} + +/** + * Saves the list of internal accounts to the user storage. + * @param config - parameters used for saving the list of internal accounts + * @param options - parameters used for saving the list of internal accounts + */ +export async function saveInternalAccountsListToUserStorage( + config: AccountSyncingConfig, + options: AccountSyncingOptions, +): Promise { + const { isAccountSyncingEnabled } = config; + const { getUserStorageControllerInstance } = options; + + if (!isAccountSyncingEnabled) { + return; + } + + const internalAccountsList = await getInternalAccountsList(options); + + if (!internalAccountsList?.length) { + return; + } + + const internalAccountsListFormattedForUserStorage = internalAccountsList.map( + mapInternalAccountToUserStorageAccount, + ); + + await getUserStorageControllerInstance().performBatchSetStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + internalAccountsListFormattedForUserStorage.map((account) => [ + account.a, + JSON.stringify(account), + ]), + ); +} + +type SyncInternalAccountsWithUserStorageConfig = AccountSyncingConfig & { + maxNumberOfAccountsToAdd?: number; + onAccountAdded?: () => void; + onAccountNameUpdated?: () => void; + onAccountSyncErroneousSituation?: (errorMessage: string) => void; +}; + +/** + * Syncs the internal accounts list with the user storage accounts list. + * This method is used to make sure that the internal accounts list is up-to-date with the user storage accounts list and vice-versa. + * It will add new accounts to the internal accounts list, update/merge conflicting names and re-upload the results in some cases to the user storage. + * @param config - parameters used for syncing the internal accounts list with the user storage accounts list + * @param options - parameters used for syncing the internal accounts list with the user storage accounts list + */ +export async function syncInternalAccountsWithUserStorage( + config: SyncInternalAccountsWithUserStorageConfig, + options: AccountSyncingOptions, +): Promise { + const { isAccountSyncingEnabled } = config; + + if (!canPerformAccountSyncing(config, options) || !isAccountSyncingEnabled) { + return; + } + + const { + maxNumberOfAccountsToAdd = 100, + onAccountAdded, + onAccountNameUpdated, + onAccountSyncErroneousSituation, + } = config; + const { getMessenger, getUserStorageControllerInstance } = options; + + try { + await getUserStorageControllerInstance().setIsAccountSyncingInProgress( + true, + ); + + const userStorageAccountsList = await getUserStorageAccountsList(options); + + if (!userStorageAccountsList || !userStorageAccountsList.length) { + await saveInternalAccountsListToUserStorage( + { isAccountSyncingEnabled }, + options, + ); + await getUserStorageControllerInstance().setHasAccountSyncingSyncedAtLeastOnce( + true, + ); + return; + } + + // Prepare an array of internal accounts to be saved to the user storage + const internalAccountsToBeSavedToUserStorage: InternalAccount[] = []; + + // Compare internal accounts list with user storage accounts list + // First step: compare lengths + const internalAccountsList = await getInternalAccountsList(options); + + if (!internalAccountsList || !internalAccountsList.length) { + throw new Error(`Failed to get internal accounts list`); + } + + const hasMoreUserStorageAccountsThanInternalAccounts = + userStorageAccountsList.length > internalAccountsList.length; + + // We don't want to remove existing accounts for a user + // so we only add new accounts if the user has more accounts in user storage than internal accounts + if (hasMoreUserStorageAccountsThanInternalAccounts) { + const numberOfAccountsToAdd = + Math.min(userStorageAccountsList.length, maxNumberOfAccountsToAdd) - + internalAccountsList.length; + + // Create new accounts to match the user storage accounts list + for (let i = 0; i < numberOfAccountsToAdd; i++) { + await getMessenger().call('KeyringController:addNewAccount'); + onAccountAdded?.(); + } + } + + // Second step: compare account names + // Get the internal accounts list again since new accounts might have been added in the previous step + const refreshedInternalAccountsList = await getInternalAccountsList( + options, + ); + + const newlyAddedAccounts = refreshedInternalAccountsList.filter( + (account) => + !internalAccountsList.find((a) => a.address === account.address), + ); + + for (const internalAccount of refreshedInternalAccountsList) { + const userStorageAccount = userStorageAccountsList.find( + (account) => account.a === internalAccount.address, + ); + + // If the account is not present in user storage + // istanbul ignore next + if (!userStorageAccount) { + // If the account was just added in the previous step, skip saving it, it's likely to be a bogus account + if (newlyAddedAccounts.includes(internalAccount)) { + onAccountSyncErroneousSituation?.( + 'An account was added to the internal accounts list but was not present in the user storage accounts list', + ); + continue; + } + // Otherwise, it means that this internal account was present before the sync, and needs to be saved to the user storage + internalAccountsToBeSavedToUserStorage.push(internalAccount); + continue; + } + + // From this point on, we know that the account is present in + // both the internal accounts list and the user storage accounts list + + // One or both accounts have default names + const isInternalAccountNameDefault = isNameDefaultAccountName( + internalAccount.metadata.name, + ); + const isUserStorageAccountNameDefault = isNameDefaultAccountName( + userStorageAccount.n, + ); + + // Internal account has default name + if (isInternalAccountNameDefault) { + if (!isUserStorageAccountNameDefault) { + getMessenger().call( + 'AccountsController:updateAccountMetadata', + internalAccount.id, + { + name: userStorageAccount.n, + }, + ); + + onAccountNameUpdated?.(); + } + continue; + } + + // Internal account has custom name but user storage account has default name + if (isUserStorageAccountNameDefault) { + internalAccountsToBeSavedToUserStorage.push(internalAccount); + continue; + } + + // Both accounts have custom names + + // User storage account has a nameLastUpdatedAt timestamp + // Note: not storing the undefined checks in constants to act as a type guard + if (userStorageAccount.nlu !== undefined) { + if (internalAccount.metadata.nameLastUpdatedAt !== undefined) { + const isInternalAccountNameNewer = + internalAccount.metadata.nameLastUpdatedAt > userStorageAccount.nlu; + + if (isInternalAccountNameNewer) { + internalAccountsToBeSavedToUserStorage.push(internalAccount); + continue; + } + } + + getMessenger().call( + 'AccountsController:updateAccountMetadata', + internalAccount.id, + { + name: userStorageAccount.n, + nameLastUpdatedAt: userStorageAccount.nlu, + }, + ); + + const areInternalAndUserStorageAccountNamesEqual = + internalAccount.metadata.name === userStorageAccount.n; + + if (!areInternalAndUserStorageAccountNamesEqual) { + onAccountNameUpdated?.(); + } + + continue; + } else if (internalAccount.metadata.nameLastUpdatedAt !== undefined) { + internalAccountsToBeSavedToUserStorage.push(internalAccount); + continue; + } + } + + // Save the internal accounts list to the user storage + if (internalAccountsToBeSavedToUserStorage.length) { + await getUserStorageControllerInstance().performBatchSetStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + internalAccountsToBeSavedToUserStorage.map((account) => [ + account.address, + JSON.stringify(mapInternalAccountToUserStorageAccount(account)), + ]), + ); + } + + // In case we have corrupted user storage with accounts that don't exist in the internal accounts list + // Delete those accounts from the user storage + const userStorageAccountsToBeDeleted = userStorageAccountsList.filter( + (account) => + !refreshedInternalAccountsList.find((a) => a.address === account.a), + ); + + if (userStorageAccountsToBeDeleted.length) { + await getUserStorageControllerInstance().performBatchDeleteStorage( + USER_STORAGE_FEATURE_NAMES.accounts, + userStorageAccountsToBeDeleted.map((account) => account.a), + ); + onAccountSyncErroneousSituation?.( + 'An account was present in the user storage accounts list but was not found in the internal accounts list after the sync', + ); + } + + // We do this here and not in the finally statement because we want to make sure that + // the accounts are saved / updated / deleted at least once before we set this flag + await getUserStorageControllerInstance().setHasAccountSyncingSyncedAtLeastOnce( + true, + ); + } catch (e) { + // istanbul ignore next + const errorMessage = e instanceof Error ? e.message : JSON.stringify(e); + throw new Error( + `UserStorageController - failed to sync user storage accounts list - ${errorMessage}`, + ); + } finally { + await getUserStorageControllerInstance().setIsAccountSyncingInProgress( + false, + ); + } +} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.test.ts new file mode 100644 index 0000000000..3e7d5811f1 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.test.ts @@ -0,0 +1,29 @@ +import { setupAccountSyncingSubscriptions } from './setup-subscriptions'; + +describe('user-storage/account-syncing/setup-subscriptions - setupAccountSyncingSubscriptions', () => { + it('should subscribe to accountAdded and accountRenamed events', () => { + const config = { isAccountSyncingEnabled: true }; + const options = { + getMessenger: jest.fn().mockReturnValue({ + subscribe: jest.fn(), + }), + getUserStorageControllerInstance: jest.fn().mockReturnValue({ + state: { + hasAccountSyncingSyncedAtLeastOnce: true, + }, + }), + }; + + setupAccountSyncingSubscriptions(config, options); + + expect(options.getMessenger().subscribe).toHaveBeenCalledWith( + 'AccountsController:accountAdded', + expect.any(Function), + ); + + expect(options.getMessenger().subscribe).toHaveBeenCalledWith( + 'AccountsController:accountRenamed', + expect.any(Function), + ); + }); +}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.ts new file mode 100644 index 0000000000..5e1732e842 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/setup-subscriptions.ts @@ -0,0 +1,47 @@ +import { saveInternalAccountToUserStorage } from './controller-integration'; +import { canPerformAccountSyncing } from './sync-utils'; +import type { AccountSyncingConfig, AccountSyncingOptions } from './types'; + +/** + * Initialize and setup events to listen to for account syncing + * @param config - configuration parameters + * @param options - parameters used for initializing and enabling account syncing + */ +export function setupAccountSyncingSubscriptions( + config: AccountSyncingConfig, + options: AccountSyncingOptions, +) { + const { getMessenger, getUserStorageControllerInstance } = options; + + getMessenger().subscribe( + 'AccountsController:accountAdded', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (account) => { + if ( + !canPerformAccountSyncing(config, options) || + !getUserStorageControllerInstance().state + .hasAccountSyncingSyncedAtLeastOnce + ) { + return; + } + + await saveInternalAccountToUserStorage(account, config, options); + }, + ); + + getMessenger().subscribe( + 'AccountsController:accountRenamed', + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async (account) => { + if ( + !canPerformAccountSyncing(config, options) || + !getUserStorageControllerInstance().state + .hasAccountSyncingSyncedAtLeastOnce + ) { + return; + } + + await saveInternalAccountToUserStorage(account, config, options); + }, + ); +} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.test.ts new file mode 100644 index 0000000000..c19d698c22 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.test.ts @@ -0,0 +1,130 @@ +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import { + canPerformAccountSyncing, + getInternalAccountsList, + getUserStorageAccountsList, +} from './sync-utils'; +import type { AccountSyncingConfig, AccountSyncingOptions } from './types'; +import * as utils from './utils'; + +describe('user-storage/account-syncing/sync-utils', () => { + describe('canPerformAccountSyncing', () => { + const arrangeMocks = ({ + isAccountSyncingEnabled = true, + isProfileSyncingEnabled = true, + isAccountSyncingInProgress = false, + messengerCallControllerAndAction = 'AuthenticationController:isSignedIn', + messengerCallCallback = () => true, + }) => { + const config: AccountSyncingConfig = { isAccountSyncingEnabled }; + const options: AccountSyncingOptions = { + getMessenger: jest.fn().mockReturnValue({ + call: jest + .fn() + .mockImplementation((controllerAndActionName) => + controllerAndActionName === messengerCallControllerAndAction + ? messengerCallCallback() + : null, + ), + }), + getUserStorageControllerInstance: jest.fn().mockReturnValue({ + state: { + isProfileSyncingEnabled, + isAccountSyncingInProgress, + }, + }), + }; + + return { config, options }; + }; + + const failureCases = [ + ['profile syncing is not enabled', { isProfileSyncingEnabled: false }], + [ + 'authentication is not enabled', + { + messengerCallControllerAndAction: + 'AuthenticationController:isSignedIn', + messengerCallCallback: () => false, + }, + ], + ['account syncing is not enabled', { isAccountSyncingEnabled: false }], + ['account syncing is in progress', { isAccountSyncingInProgress: true }], + ] as const; + + it.each(failureCases)('returns false if %s', (_message, mocks) => { + const { config, options } = arrangeMocks(mocks); + + expect(canPerformAccountSyncing(config, options)).toBe(false); + }); + + it('returns true if all conditions are met', () => { + const { config, options } = arrangeMocks({}); + + expect(canPerformAccountSyncing(config, options)).toBe(true); + }); + }); + + describe('getInternalAccountsList', () => { + it('returns filtered internal accounts list', async () => { + const internalAccounts = [ + { id: '1', metadata: { keyring: { type: KeyringTypes.hd } } }, + { id: '2', metadata: { keyring: { type: KeyringTypes.trezor } } }, + ] as InternalAccount[]; + + const options: AccountSyncingOptions = { + getMessenger: jest.fn().mockReturnValue({ + call: jest + .fn() + .mockImplementation((controllerAndActionName) => + controllerAndActionName === 'AccountsController:listAccounts' + ? internalAccounts + : null, + ), + }), + getUserStorageControllerInstance: jest.fn(), + }; + + jest + .spyOn(utils, 'doesInternalAccountHaveCorrectKeyringType') + .mockImplementation( + (account) => account.metadata.keyring.type === KeyringTypes.hd, + ); + + const result = await getInternalAccountsList(options); + expect(result).toStrictEqual([internalAccounts[0]]); + }); + }); + + describe('getUserStorageAccountsList', () => { + it('returns parsed user storage accounts list', async () => { + const rawAccounts = ['{"id":"1"}', '{"id":"2"}']; + + const options: AccountSyncingOptions = { + getUserStorageControllerInstance: jest.fn().mockReturnValue({ + performGetStorageAllFeatureEntries: jest + .fn() + .mockResolvedValue(rawAccounts), + }), + getMessenger: jest.fn(), + }; + + const result = await getUserStorageAccountsList(options); + expect(result).toStrictEqual([{ id: '1' }, { id: '2' }]); + }); + + it('returns null if no raw accounts are found', async () => { + const options: AccountSyncingOptions = { + getUserStorageControllerInstance: jest.fn().mockReturnValue({ + performGetStorageAllFeatureEntries: jest.fn().mockResolvedValue(null), + }), + getMessenger: jest.fn(), + }; + + const result = await getUserStorageAccountsList(options); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.ts new file mode 100644 index 0000000000..c20a6ca44d --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/sync-utils.ts @@ -0,0 +1,78 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import { USER_STORAGE_FEATURE_NAMES } from '../../../shared/storage-schema'; +import type { + AccountSyncingConfig, + AccountSyncingOptions, + UserStorageAccount, +} from './types'; +import { doesInternalAccountHaveCorrectKeyringType } from './utils'; + +/** + * Checks if account syncing can be performed based on a set of conditions + * @param config - configuration parameters + * @param options - parameters used for checking if account syncing can be performed + * @returns Returns true if account syncing can be performed, false otherwise. + */ +export function canPerformAccountSyncing( + config: AccountSyncingConfig, + options: AccountSyncingOptions, +): boolean { + const { isAccountSyncingEnabled } = config; + const { getMessenger, getUserStorageControllerInstance } = options; + + const { isProfileSyncingEnabled, isAccountSyncingInProgress } = + getUserStorageControllerInstance().state; + const isAuthEnabled = getMessenger().call( + 'AuthenticationController:isSignedIn', + ); + + if ( + !isProfileSyncingEnabled || + !isAuthEnabled || + !isAccountSyncingEnabled || + isAccountSyncingInProgress + ) { + return false; + } + + return true; +} + +/** + * Get the list of internal accounts + * @param options - parameters used for getting the list of internal accounts + */ +export async function getInternalAccountsList( + options: AccountSyncingOptions, +): Promise { + const { getMessenger } = options; + + // eslint-disable-next-line @typescript-eslint/await-thenable + const internalAccountsList = await getMessenger().call( + 'AccountsController:listAccounts', + ); + + return internalAccountsList?.filter( + doesInternalAccountHaveCorrectKeyringType, + ); +} + +/** + * Get the list of user storage accounts + * @param options - parameters used for getting the list of user storage accounts + */ +export async function getUserStorageAccountsList( + options: AccountSyncingOptions, +): Promise { + const { getUserStorageControllerInstance } = options; + + const rawAccountsListResponse = + await getUserStorageControllerInstance().performGetStorageAllFeatureEntries( + USER_STORAGE_FEATURE_NAMES.accounts, + ); + + return ( + rawAccountsListResponse?.map((rawAccount) => JSON.parse(rawAccount)) ?? null + ); +} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/types.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/types.ts new file mode 100644 index 0000000000..745107f814 --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/types.ts @@ -0,0 +1,31 @@ +import type { UserStorageControllerMessenger } from '../UserStorageController'; +import type UserStorageController from '../UserStorageController'; +import type { + USER_STORAGE_VERSION_KEY, + USER_STORAGE_VERSION, +} from './constants'; + +export type UserStorageAccount = { + /** + * The Version 'v' of the User Storage. + * NOTE - will allow us to support upgrade/downgrades in the future + */ + [USER_STORAGE_VERSION_KEY]: typeof USER_STORAGE_VERSION; + /** the id 'i' of the account */ + i: string; + /** the address 'a' of the account */ + a: string; + /** the name 'n' of the account */ + n: string; + /** the nameLastUpdatedAt timestamp 'nlu' of the account */ + nlu?: number; +}; + +export type AccountSyncingConfig = { + isAccountSyncingEnabled: boolean; +}; + +export type AccountSyncingOptions = { + getUserStorageControllerInstance: () => UserStorageController; + getMessenger: () => UserStorageControllerMessenger; +}; diff --git a/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.test.ts new file mode 100644 index 0000000000..bd8123822a --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.test.ts @@ -0,0 +1,108 @@ +import { KeyringTypes } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import { getMockRandomDefaultAccountName } from './__fixtures__/mockAccounts'; +import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants'; +import { + doesInternalAccountHaveCorrectKeyringType, + isNameDefaultAccountName, + mapInternalAccountToUserStorageAccount, +} from './utils'; + +describe('user-storage/account-syncing/utils', () => { + describe('isNameDefaultAccountName', () => { + it('should return true for default account names', () => { + expect( + isNameDefaultAccountName(`${getMockRandomDefaultAccountName()} 89`), + ).toBe(true); + expect( + isNameDefaultAccountName(`${getMockRandomDefaultAccountName()} 1`), + ).toBe(true); + expect( + isNameDefaultAccountName(`${getMockRandomDefaultAccountName()} 123543`), + ).toBe(true); + }); + + it('should return false for non-default account names', () => { + expect(isNameDefaultAccountName('My Account')).toBe(false); + expect(isNameDefaultAccountName('Mon compte 34')).toBe(false); + }); + }); + describe('mapInternalAccountToUserStorageAccount', () => { + const internalAccount = { + address: '0x123', + id: '1', + metadata: { + name: `${getMockRandomDefaultAccountName()} 1`, + nameLastUpdatedAt: 1620000000000, + keyring: { + type: KeyringTypes.hd, + }, + }, + } as InternalAccount; + + it('should map an internal account to a user storage account with default account name', () => { + const userStorageAccount = + mapInternalAccountToUserStorageAccount(internalAccount); + + expect(userStorageAccount).toStrictEqual({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: internalAccount.address, + i: internalAccount.id, + n: internalAccount.metadata.name, + }); + }); + + it('should map an internal account to a user storage account with non-default account name', () => { + const internalAccountWithCustomName = { + ...internalAccount, + metadata: { + ...internalAccount.metadata, + name: 'My Account', + }, + } as InternalAccount; + + const userStorageAccount = mapInternalAccountToUserStorageAccount( + internalAccountWithCustomName, + ); + + expect(userStorageAccount).toStrictEqual({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + a: internalAccountWithCustomName.address, + i: internalAccountWithCustomName.id, + n: internalAccountWithCustomName.metadata.name, + nlu: internalAccountWithCustomName.metadata.nameLastUpdatedAt, + }); + }); + }); + + describe('doesInternalAccountHaveCorrectKeyringType', () => { + it('should return true if the internal account has the correct keyring type', () => { + const internalAccount = { + metadata: { + keyring: { + type: KeyringTypes.hd, + }, + }, + } as InternalAccount; + + expect(doesInternalAccountHaveCorrectKeyringType(internalAccount)).toBe( + true, + ); + }); + + it('should return false if the internal account does not have the correct keyring type', () => { + const internalAccount = { + metadata: { + keyring: { + type: KeyringTypes.snap, + }, + }, + } as InternalAccount; + + expect(doesInternalAccountHaveCorrectKeyringType(internalAccount)).toBe( + false, + ); + }); + }); +}); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/accounts/user-storage.ts b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.ts similarity index 71% rename from packages/profile-sync-controller/src/controllers/user-storage/accounts/user-storage.ts rename to packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.ts index 374063a20f..59a8048f10 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/accounts/user-storage.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/account-syncing/utils.ts @@ -1,3 +1,4 @@ +import { KeyringTypes } from '@metamask/keyring-controller'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { @@ -5,22 +6,7 @@ import { USER_STORAGE_VERSION, LOCALIZED_DEFAULT_ACCOUNT_NAMES, } from './constants'; - -export type UserStorageAccount = { - /** - * The Version 'v' of the User Storage. - * NOTE - will allow us to support upgrade/downgrades in the future - */ - [USER_STORAGE_VERSION_KEY]: typeof USER_STORAGE_VERSION; - /** the id 'i' of the account */ - i: string; - /** the address 'a' of the account */ - a: string; - /** the name 'n' of the account */ - n: string; - /** the nameLastUpdatedAt timestamp 'nlu' of the account */ - nlu?: number; -}; +import type { UserStorageAccount } from './types'; /** * Tells if the given name is a default account name. @@ -55,3 +41,14 @@ export const mapInternalAccountToUserStorageAccount = ( ...(isNameDefaultAccountName(name) ? {} : { nlu: nameLastUpdatedAt }), }; }; + +/** + * Checks if the given internal account has the correct keyring type. + * @param account - The internal account to check + * @returns Returns true if the internal account has the correct keyring type, false otherwise. + */ +export function doesInternalAccountHaveCorrectKeyringType( + account: InternalAccount, +) { + return account.metadata.keyring.type === KeyringTypes.hd; +} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/accounts/user-storage.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/accounts/user-storage.test.ts deleted file mode 100644 index a28ffa6d70..0000000000 --- a/packages/profile-sync-controller/src/controllers/user-storage/accounts/user-storage.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { getMockRandomDefaultAccountName } from '../__fixtures__/mockAccounts'; -import { isNameDefaultAccountName } from './user-storage'; - -describe('user-storage/acounts/isNameDefaultAccountName', () => { - it('should return true for default account names', () => { - expect( - isNameDefaultAccountName(`${getMockRandomDefaultAccountName()} 89`), - ).toBe(true); - expect( - isNameDefaultAccountName(`${getMockRandomDefaultAccountName()} 1`), - ).toBe(true); - expect( - isNameDefaultAccountName(`${getMockRandomDefaultAccountName()} 123543`), - ).toBe(true); - }); - - it('should return false for non-default account names', () => { - expect(isNameDefaultAccountName('My Account')).toBe(false); - expect(isNameDefaultAccountName('Mon compte 34')).toBe(false); - }); -});