Skip to content

Commit c680ca3

Browse files
authored
feat: add error_type to REHYDRATION_PASSWORD_FAILED event (MetaMask#21906)
## **Description** Added `error_type` property to the `REHYDRATION_PASSWORD_FAILED` analytics event to distinguish between incorrect password errors and other error types during social login rehydration. **Changes:** - Added `error_type: 'incorrect_password'` to `REHYDRATION_PASSWORD_FAILED` event tracking - Applied to both standard password errors and seedless onboarding incorrect password errors - Only tracks during OAuth rehydration flow (`isComingFromOauthOnboarding`) ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SL-240 ## **Manual testing steps** ```gherkin Feature: Social login rehydration error tracking Scenario: user enters incorrect password during social login rehydration Given user has a social login account And user is on the login screen after OAuth authentication When user enters an incorrect password Then REHYDRATION_PASSWORD_FAILED event is tracked with error_type: 'incorrect_password' And the event includes account_type: 'social' and failed_attempts count ``` ## **Screenshots/Recordings** N/A - Analytics tracking change only ### **Before** `REHYDRATION_PASSWORD_FAILED` event tracked without `error_type` property ### **After** `REHYDRATION_PASSWORD_FAILED` event tracked with `error_type: 'incorrect_password'` ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds error_type to REHYDRATION_PASSWORD_FAILED during social login rehydration, distinguishing incorrect_password from unknown_error, with updated tests. > > - **Analytics (Login)**: > - Track `REHYDRATION_PASSWORD_FAILED` with `error_type: 'incorrect_password'` for wrong password cases (`WRONG_PASSWORD_ERROR`, Android variants) during OAuth rehydration. > - Track `error_type: 'incorrect_password'` on seedless `IncorrectPassword` and `TooManyLoginAttempts` (syncs failed attempts), and `error_type: 'unknown_error'` on seedless `PasswordRecentlyUpdated` and other unexpected OAuth failures. > - Remove generic tracking from `handlePasswordError`; add final fallback tracking with `error_type: 'unknown_error'` for other OAuth errors. > - **Tests (Login)**: > - Mock `trackOnboarding` and assert events include `error_type` for all wrong password variants; ensure not tracked for `PASSWORD_REQUIREMENTS_NOT_MET`. > - Add coverage for Android error variant `_2`; minor suite rename to clarify navigation checks. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fcd807c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 8d30e14 commit c680ca3

2 files changed

Lines changed: 185 additions & 115 deletions

File tree

app/components/Views/Login/index.test.tsx

Lines changed: 136 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ jest.mock('../../../core/OAuthService/OAuthService', () => ({
135135
resetOauthState: jest.fn(),
136136
}));
137137

138+
jest.mock('../../../util/metrics/TrackOnboarding/trackOnboarding', () =>
139+
jest.fn(),
140+
);
141+
138142
jest.mock('../../../util/trace', () => {
139143
const actualTrace = jest.requireActual('../../../util/trace');
140144
return {
@@ -195,6 +199,9 @@ const mockBackHandlerRemoveEventListener = jest.fn();
195199
describe('Login', () => {
196200
const mockTrace = jest.mocked(trace);
197201
const mockEndTrace = jest.mocked(endTrace);
202+
const mockTrackOnboarding = jest.mocked(
203+
jest.requireMock('../../../util/metrics/TrackOnboarding/trackOnboarding'),
204+
);
198205

199206
beforeEach(() => {
200207
jest.clearAllMocks();
@@ -209,6 +216,7 @@ describe('Login', () => {
209216
mockEndTrace.mockClear();
210217
mockBackHandlerAddEventListener.mockClear();
211218
mockBackHandlerRemoveEventListener.mockClear();
219+
mockTrackOnboarding.mockClear();
212220

213221
BackHandler.addEventListener = mockBackHandlerAddEventListener;
214222
BackHandler.removeEventListener = mockBackHandlerRemoveEventListener;
@@ -499,7 +507,7 @@ describe('Login', () => {
499507
});
500508
});
501509

502-
describe('checkMetricsUISeen', () => {
510+
describe('checkMetricsUISeen navigation', () => {
503511
beforeEach(() => {
504512
jest.clearAllMocks();
505513
mockRoute.mockReturnValue({
@@ -740,111 +748,6 @@ describe('Login', () => {
740748
});
741749
});
742750

743-
describe('Password Error Handling', () => {
744-
beforeEach(() => {
745-
mockRoute.mockReturnValue({
746-
params: {
747-
locked: false,
748-
oauthLoginSuccess: false,
749-
},
750-
});
751-
});
752-
753-
afterEach(() => {
754-
jest.clearAllMocks();
755-
});
756-
757-
it('should handle WRONG_PASSWORD_ERROR', async () => {
758-
(Authentication.userEntryAuth as jest.Mock).mockRejectedValue(
759-
new Error('Decrypt failed'),
760-
);
761-
762-
const { getByTestId } = renderWithProvider(<Login />);
763-
const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT);
764-
765-
await act(async () => {
766-
fireEvent.changeText(passwordInput, 'valid-password123');
767-
});
768-
await act(async () => {
769-
fireEvent(passwordInput, 'submitEditing');
770-
});
771-
772-
const errorElement = getByTestId(LoginViewSelectors.PASSWORD_ERROR);
773-
expect(errorElement).toBeTruthy();
774-
expect(errorElement.props.children).toEqual(
775-
strings('login.invalid_password'),
776-
);
777-
});
778-
779-
it('should handle WRONG_PASSWORD_ERROR_ANDROID', async () => {
780-
(Authentication.userEntryAuth as jest.Mock).mockRejectedValue(
781-
new Error(
782-
'error:1e000065:Cipher functions:OPENSSL_internal:BAD_DECRYPT',
783-
),
784-
);
785-
786-
const { getByTestId } = renderWithProvider(<Login />);
787-
const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT);
788-
789-
await act(async () => {
790-
fireEvent.changeText(passwordInput, 'valid-password123');
791-
});
792-
await act(async () => {
793-
fireEvent(passwordInput, 'submitEditing');
794-
});
795-
796-
const errorElement = getByTestId(LoginViewSelectors.PASSWORD_ERROR);
797-
expect(errorElement).toBeTruthy();
798-
expect(errorElement.props.children).toEqual(
799-
strings('login.invalid_password'),
800-
);
801-
});
802-
803-
it('should handle PASSWORD_REQUIREMENTS_NOT_MET error', async () => {
804-
(Authentication.userEntryAuth as jest.Mock).mockRejectedValue(
805-
new Error('Password requirements not met'),
806-
);
807-
808-
const { getByTestId } = renderWithProvider(<Login />);
809-
const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT);
810-
811-
await act(async () => {
812-
fireEvent.changeText(passwordInput, 'valid-password123');
813-
});
814-
await act(async () => {
815-
fireEvent(passwordInput, 'submitEditing');
816-
});
817-
818-
const errorElement = getByTestId(LoginViewSelectors.PASSWORD_ERROR);
819-
expect(errorElement).toBeTruthy();
820-
expect(errorElement.props.children).toEqual(
821-
strings('login.invalid_password'),
822-
);
823-
});
824-
825-
it('should handle generic error (else case)', async () => {
826-
(Authentication.userEntryAuth as jest.Mock).mockRejectedValue(
827-
new Error('Some unexpected error'),
828-
);
829-
830-
const { getByTestId } = renderWithProvider(<Login />);
831-
const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT);
832-
833-
await act(async () => {
834-
fireEvent.changeText(passwordInput, 'valid-password123');
835-
});
836-
await act(async () => {
837-
fireEvent(passwordInput, 'submitEditing');
838-
});
839-
840-
const errorElement = getByTestId(LoginViewSelectors.PASSWORD_ERROR);
841-
expect(errorElement).toBeTruthy();
842-
expect(errorElement.props.children).toEqual(
843-
'Error: Some unexpected error',
844-
);
845-
});
846-
});
847-
848751
describe('Passcode Authentication', () => {
849752
beforeEach(() => {
850753
(StorageWrapper.getItem as jest.Mock).mockReset();
@@ -950,13 +853,26 @@ describe('Login', () => {
950853
oauthLoginSuccess: false,
951854
},
952855
});
856+
mockTrackOnboarding.mockClear();
953857
});
954858

955859
afterEach(() => {
956860
jest.clearAllMocks();
957861
});
958862

959863
it('should handle WRONG_PASSWORD_ERROR', async () => {
864+
mockRoute.mockReturnValue({
865+
params: {
866+
locked: false,
867+
oauthLoginSuccess: true,
868+
},
869+
});
870+
(
871+
Authentication.componentAuthenticationType as jest.Mock
872+
).mockResolvedValue({
873+
currentAuthType: 'password',
874+
oauth2Login: true,
875+
});
960876
(Authentication.userEntryAuth as jest.Mock).mockRejectedValue(
961877
new Error('Decrypt failed'),
962878
);
@@ -976,9 +892,37 @@ describe('Login', () => {
976892
expect(errorElement.props.children).toEqual(
977893
strings('login.invalid_password'),
978894
);
895+
expect(mockTrackOnboarding).toHaveBeenCalled();
896+
const rehydrationCall = mockTrackOnboarding.mock.calls.find(
897+
(call: unknown[]) =>
898+
call[0] &&
899+
typeof call[0] === 'object' &&
900+
'name' in call[0] &&
901+
call[0].name === 'Rehydration Password Failed' &&
902+
'properties' in call[0] &&
903+
call[0].properties &&
904+
typeof call[0].properties === 'object' &&
905+
'account_type' in call[0].properties &&
906+
call[0].properties.account_type === 'social' &&
907+
'error_type' in call[0].properties &&
908+
call[0].properties.error_type === 'incorrect_password',
909+
);
910+
expect(rehydrationCall).toBeDefined();
979911
});
980912

981913
it('should handle WRONG_PASSWORD_ERROR_ANDROID', async () => {
914+
mockRoute.mockReturnValue({
915+
params: {
916+
locked: false,
917+
oauthLoginSuccess: true,
918+
},
919+
});
920+
(
921+
Authentication.componentAuthenticationType as jest.Mock
922+
).mockResolvedValue({
923+
currentAuthType: 'password',
924+
oauth2Login: true,
925+
});
982926
(Authentication.userEntryAuth as jest.Mock).mockRejectedValue(
983927
new Error(
984928
'error:1e000065:Cipher functions:OPENSSL_internal:BAD_DECRYPT',
@@ -1000,9 +944,87 @@ describe('Login', () => {
1000944
expect(errorElement.props.children).toEqual(
1001945
strings('login.invalid_password'),
1002946
);
947+
expect(mockTrackOnboarding).toHaveBeenCalled();
948+
const rehydrationCall = mockTrackOnboarding.mock.calls.find(
949+
(call: unknown[]) =>
950+
call[0] &&
951+
typeof call[0] === 'object' &&
952+
'name' in call[0] &&
953+
call[0].name === 'Rehydration Password Failed' &&
954+
'properties' in call[0] &&
955+
call[0].properties &&
956+
typeof call[0].properties === 'object' &&
957+
'account_type' in call[0].properties &&
958+
call[0].properties.account_type === 'social' &&
959+
'error_type' in call[0].properties &&
960+
call[0].properties.error_type === 'incorrect_password',
961+
);
962+
expect(rehydrationCall).toBeDefined();
963+
});
964+
965+
it('should handle WRONG_PASSWORD_ERROR_ANDROID_2', async () => {
966+
mockRoute.mockReturnValue({
967+
params: {
968+
locked: false,
969+
oauthLoginSuccess: true,
970+
},
971+
});
972+
(
973+
Authentication.componentAuthenticationType as jest.Mock
974+
).mockResolvedValue({
975+
currentAuthType: 'password',
976+
oauth2Login: true,
977+
});
978+
(Authentication.userEntryAuth as jest.Mock).mockRejectedValue(
979+
new Error('error in DoCipher, status: 2'),
980+
);
981+
982+
const { getByTestId } = renderWithProvider(<Login />);
983+
const passwordInput = getByTestId(LoginViewSelectors.PASSWORD_INPUT);
984+
985+
await act(async () => {
986+
fireEvent.changeText(passwordInput, 'valid-password123');
987+
});
988+
await act(async () => {
989+
fireEvent(passwordInput, 'submitEditing');
990+
});
991+
992+
const errorElement = getByTestId(LoginViewSelectors.PASSWORD_ERROR);
993+
expect(errorElement).toBeTruthy();
994+
expect(errorElement.props.children).toEqual(
995+
strings('login.invalid_password'),
996+
);
997+
expect(mockTrackOnboarding).toHaveBeenCalled();
998+
const rehydrationCall = mockTrackOnboarding.mock.calls.find(
999+
(call: unknown[]) =>
1000+
call[0] &&
1001+
typeof call[0] === 'object' &&
1002+
'name' in call[0] &&
1003+
call[0].name === 'Rehydration Password Failed' &&
1004+
'properties' in call[0] &&
1005+
call[0].properties &&
1006+
typeof call[0].properties === 'object' &&
1007+
'account_type' in call[0].properties &&
1008+
call[0].properties.account_type === 'social' &&
1009+
'error_type' in call[0].properties &&
1010+
call[0].properties.error_type === 'incorrect_password',
1011+
);
1012+
expect(rehydrationCall).toBeDefined();
10031013
});
10041014

10051015
it('should handle PASSWORD_REQUIREMENTS_NOT_MET error', async () => {
1016+
mockRoute.mockReturnValue({
1017+
params: {
1018+
locked: false,
1019+
oauthLoginSuccess: true,
1020+
},
1021+
});
1022+
(
1023+
Authentication.componentAuthenticationType as jest.Mock
1024+
).mockResolvedValue({
1025+
currentAuthType: 'password',
1026+
oauth2Login: true,
1027+
});
10061028
(Authentication.userEntryAuth as jest.Mock).mockRejectedValue(
10071029
new Error('Password requirements not met'),
10081030
);
@@ -1022,6 +1044,14 @@ describe('Login', () => {
10221044
expect(errorElement.props.children).toEqual(
10231045
strings('login.invalid_password'),
10241046
);
1047+
const rehydrationCall = mockTrackOnboarding.mock.calls.find(
1048+
(call: unknown[]) =>
1049+
call[0] &&
1050+
typeof call[0] === 'object' &&
1051+
'name' in call[0] &&
1052+
call[0].name === 'Rehydration Password Failed',
1053+
);
1054+
expect(rehydrationCall).toBeUndefined();
10251055
});
10261056

10271057
it('should handle generic error (else case)', async () => {

0 commit comments

Comments
 (0)