Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 123 additions & 18 deletions app/util/generateSkipOnboardingState.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,37 @@
import StorageWrapper from '../store/storage-wrapper';
import { seedphraseBackedUp } from '../actions/user';
import {
seedphraseBackedUp,
setExistingUser,
setMultichainAccountsIntroModalSeen,
} from '../actions/user';
import { setCompletedOnboarding } from '../actions/onboarding';
import { storePrivacyPolicyClickedOrClosed } from '../actions/legalNotices';
import { Authentication } from '../core';
import { importNewSecretRecoveryPhrase } from '../actions/multiSrp';
import { store } from '../store';
import { setLockTime } from '../actions/settings';
import AppConstants from '../core/AppConstants';
import Engine from '../core/Engine';
import AUTHENTICATION_TYPE from '../constants/userProperties';

// Mock all dependencies
jest.mock('../store/storage-wrapper');
jest.mock('../actions/user');
jest.mock('../actions/onboarding');
jest.mock('../actions/legalNotices');
jest.mock('../core');
jest.mock('../core/Engine', () => ({
__esModule: true,
default: {
context: {
KeyringController: {
state: {
keyrings: [],
},
},
},
},
}));
jest.mock('../actions/multiSrp');
jest.mock('../store');
jest.mock('../actions/settings', () => ({
Expand All @@ -23,7 +43,7 @@ jest.mock('../actions/settings', () => ({
}));

// Import after mocks are set up
let applyVaultInitialization: () => Promise<null>;
let applyVaultInitialization: (password?: string) => Promise<null>;
let VAULT_INITIALIZED_KEY: string;
let predefinedPassword: string | undefined;
let additionalSrps: (string | undefined)[];
Expand All @@ -33,6 +53,15 @@ const mockStorageWrapper = StorageWrapper as jest.Mocked<typeof StorageWrapper>;
const mockSeedphraseBackedUp = seedphraseBackedUp as jest.MockedFunction<
typeof seedphraseBackedUp
>;
const mockSetExistingUser = setExistingUser as jest.MockedFunction<
typeof setExistingUser
>;
const mockSetMultichainAccountsIntroModalSeen =
setMultichainAccountsIntroModalSeen as jest.MockedFunction<
typeof setMultichainAccountsIntroModalSeen
>;
const mockSetCompletedOnboarding =
setCompletedOnboarding as jest.MockedFunction<typeof setCompletedOnboarding>;
const mockStorePrivacyPolicyClickedOrClosed =
storePrivacyPolicyClickedOrClosed as jest.MockedFunction<
typeof storePrivacyPolicyClickedOrClosed
Expand All @@ -43,8 +72,27 @@ const mockImportNewSecretRecoveryPhrase =
typeof importNewSecretRecoveryPhrase
>;
const mockStore = store as jest.Mocked<typeof store>;
const mockEngine = Engine as unknown as {
context: {
KeyringController: {
state: {
keyrings: { accounts?: string[] }[];
};
};
};
};

describe('generateSkipOnboardingState', () => {
const originalPredefinedPassword = process.env.PREDEFINED_PASSWORD;
const restorePredefinedPassword = () => {
if (originalPredefinedPassword === undefined) {
delete process.env.PREDEFINED_PASSWORD;
return;
}

process.env.PREDEFINED_PASSWORD = originalPredefinedPassword;
};

beforeAll(() => {
// Import the module after all mocks are set up
const actualModule = jest.requireActual(
Expand All @@ -59,22 +107,38 @@ describe('generateSkipOnboardingState', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(console, 'warn').mockImplementation(jest.fn());
restorePredefinedPassword();
mockEngine.context.KeyringController.state.keyrings = [];

mockStorageWrapper.getItem.mockResolvedValue(null);
mockStorageWrapper.setItem.mockResolvedValue();
mockAuthentication.newWalletAndKeychain.mockResolvedValue();
mockAuthentication.storePassword.mockResolvedValue();
mockImportNewSecretRecoveryPhrase.mockResolvedValue({
address: '0x123',
discoveredAccountsCount: 1,
});
mockSeedphraseBackedUp.mockReturnValue({ type: 'test' } as never);
mockSetExistingUser.mockReturnValue({
type: 'SET_EXISTING_USER',
payload: { existingUser: true },
} as never);
mockSetMultichainAccountsIntroModalSeen.mockReturnValue({
type: 'SET_MULTICHAIN_ACCOUNTS_INTRO_MODAL_SEEN',
payload: { seen: true },
} as never);
mockSetCompletedOnboarding.mockReturnValue({
type: 'SET_COMPLETED_ONBOARDING',
completedOnboarding: true,
} as never);
mockStorePrivacyPolicyClickedOrClosed.mockReturnValue({
type: 'test',
} as never);
mockStore.dispatch.mockReturnValue({ type: 'test' } as never);
});

afterEach(() => {
restorePredefinedPassword();
jest.restoreAllMocks();
});

Expand All @@ -90,7 +154,7 @@ describe('generateSkipOnboardingState', () => {
// Given the PREDEFINED_PASSWORD environment variable
// When the module is loaded
// Then it should export the correct value
expect(predefinedPassword).toBe(process.env.PREDEFINED_PASSWORD);
expect(predefinedPassword).toBe(originalPredefinedPassword);
});

it('exports array of 20 additional SRP slots', () => {
Expand All @@ -107,28 +171,17 @@ describe('generateSkipOnboardingState', () => {
it('returns null without initializing vault', async () => {
// Given predefinedPassword is undefined
// When applyVaultInitialization is called
const result = await applyVaultInitialization();
const result = await applyVaultInitialization('');

// Then it returns null and performs no operations
expect(result).toBeNull();
expect(mockAuthentication.newWalletAndKeychain).not.toHaveBeenCalled();
expect(mockAuthentication.storePassword).not.toHaveBeenCalled();
expect(mockStore.dispatch).not.toHaveBeenCalled();
});
});

describe('when predefined password is set', () => {
let originalEnv: string | undefined;

beforeAll(() => {
// Save original env var
originalEnv = process.env.PREDEFINED_PASSWORD;
// Set predefined password for these tests
process.env.PREDEFINED_PASSWORD = 'test-password-123';
});

afterAll(() => {
// Restore original env var
process.env.PREDEFINED_PASSWORD = originalEnv;
});

it('dispatches setLockTime action during initialization flow', () => {
// Arrange
const lockTimeAction = setLockTime(AppConstants.DEFAULT_LOCK_TIMEOUT);
Expand Down Expand Up @@ -160,6 +213,58 @@ describe('generateSkipOnboardingState', () => {
});
expect(mockStore.dispatch).toHaveBeenCalledWith(action);
});

it('marks the app as an existing onboarded user when vault accounts already exist', async () => {
mockStorageWrapper.getItem.mockResolvedValue('true');
mockEngine.context.KeyringController.state.keyrings = [
{ accounts: ['0x123'] },
];

await applyVaultInitialization('test-password-123');

expect(mockAuthentication.newWalletAndKeychain).not.toHaveBeenCalled();
expect(mockAuthentication.storePassword).not.toHaveBeenCalled();
expect(mockSetExistingUser).toHaveBeenCalledWith(true);
expect(mockSetCompletedOnboarding).toHaveBeenCalledWith(true);
expect(mockSeedphraseBackedUp).toHaveBeenCalled();
expect(mockSetMultichainAccountsIntroModalSeen).toHaveBeenCalledWith(
true,
);
});

it('repairs the password entry when accounts exist but the initialized flag is missing', async () => {
mockStorageWrapper.getItem.mockResolvedValue(null);
mockEngine.context.KeyringController.state.keyrings = [
{ accounts: ['0x123'] },
];

await applyVaultInitialization('test-password-123');

expect(mockAuthentication.newWalletAndKeychain).not.toHaveBeenCalled();
expect(mockAuthentication.storePassword).toHaveBeenCalledWith(
'test-password-123',
AUTHENTICATION_TYPE.PASSWORD,
true,
);
expect(mockSetExistingUser).toHaveBeenCalledWith(true);
expect(mockSetCompletedOnboarding).toHaveBeenCalledWith(true);
});

it('recreates the predefined wallet when the initialized flag exists but accounts are missing', async () => {
mockStorageWrapper.getItem.mockResolvedValue('true');

await applyVaultInitialization('test-password-123');

expect(mockAuthentication.newWalletAndKeychain).toHaveBeenCalledWith(
'test-password-123',
{
currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
},
);
expect(mockAuthentication.storePassword).not.toHaveBeenCalled();
expect(mockSetExistingUser).toHaveBeenCalledWith(true);
expect(mockSetCompletedOnboarding).toHaveBeenCalledWith(true);
});
});
});

Expand Down
83 changes: 57 additions & 26 deletions app/util/generateSkipOnboardingState.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import StorageWrapper from '../store/storage-wrapper';
import {
seedphraseBackedUp,
setExistingUser,
setMultichainAccountsIntroModalSeen,
} from '../actions/user';
import { setCompletedOnboarding } from '../actions/onboarding';
import {
HAS_USER_TURNED_OFF_ONCE_NOTIFICATIONS,
OPTIN_META_METRICS_UI_SEEN,
Expand All @@ -17,6 +19,7 @@
import { store } from '../store';
import { setLockTime } from '../actions/settings';
import AppConstants from '../core/AppConstants';
import Engine from '../core/Engine';

export const VAULT_INITIALIZED_KEY = '@MetaMask:vaultInitialized';

Expand Down Expand Up @@ -49,12 +52,26 @@
* Apply the vault initialization to Redux store and return vault data if needed
* This should be called during EngineService startup
*/
async function applyVaultInitialization() {
if (
predefinedPassword &&
!(await StorageWrapper.getItem(VAULT_INITIALIZED_KEY))
) {
await Authentication.newWalletAndKeychain(predefinedPassword, {
async function applyVaultInitialization(password = predefinedPassword) {

Check failure on line 55 in app/util/generateSkipOnboardingState.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to not always return the same value.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZ3-YhjyvoJsTPlpnLgJ&open=AZ3-YhjyvoJsTPlpnLgJ&pullRequest=29721
if (!password) {
return null;
}

const flagSet = !!(await StorageWrapper.getItem(VAULT_INITIALIZED_KEY));

// Verify that the Engine actually loaded accounts, not just that the flag exists.
//
// The flag is written immediately after wallet creation, but the KeyringController
// state is flushed to disk with a 200 ms debounce (createPersistController).
// If the app is restarted within that debounce window the flag is already set but
// the vault was never written to ControllerStorage, so the Engine starts empty.
// Checking the live Engine state catches that scenario and allows re-initialization.
const hasAccounts = Engine.context?.KeyringController?.state?.keyrings?.some(
(keyring: { accounts?: string[] }) => (keyring.accounts?.length ?? 0) > 0,
);

if (!hasAccounts) {
await Authentication.newWalletAndKeychain(password, {
currentAuthType: AUTHENTICATION_TYPE.PASSWORD,
});

Expand All @@ -73,32 +90,46 @@
);
}
}
} else if (!flagSet) {
// The app can be terminated after controller state is persisted but before
// the keychain/Redux flags finish writing. Repair the password entry without
// resetting the already persisted vault.
await Authentication.storePassword(
password,
AUTHENTICATION_TYPE.PASSWORD,
true,
);
}

await StorageWrapper.setItem(VAULT_INITIALIZED_KEY, 'true');
await StorageWrapper.setItem(VAULT_INITIALIZED_KEY, 'true');

// removes the necessity of the user to see the protect your wallet modal
store.dispatch(seedphraseBackedUp());
// removes the necessity of the user to see the privacy policy modal
store.dispatch(storePrivacyPolicyClickedOrClosed());
// removes the necessity of the user to see the multichain accounts intro modal
store.dispatch(setMultichainAccountsIntroModalSeen(true));
// Set auto-lock time for the default
// Note: This line is tested via component tests (setLockTime action creator + store.dispatch)
// Full integration testing requires PREDEFINED_PASSWORD env var set before module load
store.dispatch(setLockTime(AppConstants.DEFAULT_LOCK_TIMEOUT));
// Keep Redux auth/onboarding state consistent with the preloaded vault on every
// launch. Appium can terminate the app between controller persistence and Redux
// persistence, leaving the next launch with accounts but existingUser=false.
store.dispatch(setExistingUser(true));
store.dispatch(setCompletedOnboarding(true));
// removes the necessity of the user to see the protect your wallet modal
store.dispatch(seedphraseBackedUp());
// removes the necessity of the user to see the privacy policy modal
store.dispatch(storePrivacyPolicyClickedOrClosed());
// removes the necessity of the user to see the multichain accounts intro modal
store.dispatch(setMultichainAccountsIntroModalSeen(true));
// Set auto-lock time for the default
// Note: This line is tested via component tests (setLockTime action creator + store.dispatch)
// Full integration testing requires PREDEFINED_PASSWORD env var set before module load
store.dispatch(setLockTime(AppConstants.DEFAULT_LOCK_TIMEOUT));

// removes the necessity of the user to see the terms of use modal
await StorageWrapper.setItem(USE_TERMS, TRUE);
// removes the necessity of the user to see the terms of use modal
await StorageWrapper.setItem(USE_TERMS, TRUE);

// removes the necessity of the user to see the opt-in metrics modal
await StorageWrapper.setItem(OPTIN_META_METRICS_UI_SEEN, TRUE);
// removes the necessity of the user to see the opt-in metrics modal
await StorageWrapper.setItem(OPTIN_META_METRICS_UI_SEEN, TRUE);

// removes the necessity of the user to see the predictions GTM modal
await StorageWrapper.setItem(PREDICT_GTM_MODAL_SHOWN, TRUE);
// removes the necessity of the user to see the predictions GTM modal
await StorageWrapper.setItem(PREDICT_GTM_MODAL_SHOWN, TRUE);

// prevents the enable notifications modal from showing
await StorageWrapper.setItem(HAS_USER_TURNED_OFF_ONCE_NOTIFICATIONS, TRUE);
}
// prevents the enable notifications modal from showing
await StorageWrapper.setItem(HAS_USER_TURNED_OFF_ONCE_NOTIFICATIONS, TRUE);

return null;
}
Expand Down
Loading