diff --git a/app/views/ProfileView/index.test.tsx b/app/views/ProfileView/index.test.tsx new file mode 100644 index 0000000000..8eea897201 --- /dev/null +++ b/app/views/ProfileView/index.test.tsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import { useDispatch } from 'react-redux'; +import { sha256 } from 'js-sha256'; + +import ProfileView from './index'; +import { useAppSelector } from '../../lib/hooks/useAppSelector'; +import { saveUserProfile } from '../../lib/services/restApi'; +import { twoFactor } from '../../lib/services/twoFactor'; +import handleSaveUserProfileError from '../../lib/methods/helpers/handleSaveUserProfileError'; +import EventEmitter from '../../lib/methods/helpers/events'; +import { setUser } from '../../actions/login'; + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn() +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useFocusEffect: jest.fn() +})); + +jest.mock('../../lib/hooks/useAppSelector', () => ({ + useAppSelector: jest.fn() +})); + +jest.mock('../../lib/services/restApi', () => ({ + saveUserProfile: jest.fn() +})); + +jest.mock('../../lib/services/twoFactor', () => ({ + twoFactor: jest.fn() +})); + +jest.mock('../../lib/methods/helpers/handleSaveUserProfileError', () => jest.fn()); + +const mockShowActionSheet = jest.fn(); +const mockHideActionSheet = jest.fn(); +jest.mock('../../containers/ActionSheet', () => ({ + useActionSheet: () => ({ showActionSheet: mockShowActionSheet, hideActionSheet: mockHideActionSheet }) +})); + +jest.mock('react-native-keyboard-controller', () => { + const { View } = require('react-native'); + return { KeyboardAvoidingView: View }; +}); + +jest.mock('./components/DeleteAccountActionSheetContent', () => () => null); +jest.mock('./components/ConfirmEmailChangeActionSheetContent', () => () => null); +jest.mock('../../containers/CustomFields', () => () => null); +jest.mock('../../containers/Avatar', () => ({ AvatarWithEdit: () => null })); + +const user = { + id: 'user-id', + name: 'John Doe', + username: 'john.doe', + emails: [{ address: 'john@rocket.chat', verified: true }], + bio: 'My bio', + nickname: 'johnny', + customFields: {} +}; + +const buildState = () => ({ + login: { user }, + app: { isMasterDetail: false }, + server: { version: '7.0.0' }, + settings: { + Accounts_AllowEmailChange: true, + Accounts_AllowPasswordChange: true, + Accounts_AllowRealNameChange: true, + Accounts_AllowUserAvatarChange: true, + Accounts_AllowUsernameChange: true, + Accounts_CustomFields: '', + Accounts_AllowDeleteOwnAccount: true + } +}); + +const navigation = { + setOptions: jest.fn(), + navigate: jest.fn() +} as any; + +const dispatch = jest.fn(); + +const renderProfile = () => { + (useAppSelector as jest.Mock).mockImplementation((selector: (state: any) => unknown) => selector(buildState())); + return render(); +}; + +// Make the form dirty + valid so the Save button is enabled, then press it. +const changeNameAndSubmit = (getByTestId: (id: string) => any, newName = 'Jane Doe') => { + fireEvent.changeText(getByTestId('profile-view-name'), newName); + fireEvent.press(getByTestId('profile-view-submit')); +}; + +beforeEach(() => { + jest.clearAllMocks(); + (useDispatch as jest.Mock).mockReturnValue(dispatch); +}); + +describe('ProfileView submit', () => { + it('saves the changed fields and notifies the user on success', async () => { + (saveUserProfile as jest.Mock).mockResolvedValue(true); + const emitSpy = jest.spyOn(EventEmitter, 'emit'); + + const { getByTestId } = renderProfile(); + changeNameAndSubmit(getByTestId); + + await waitFor(() => expect(saveUserProfile).toHaveBeenCalledWith({ name: 'Jane Doe' }, {})); + expect(dispatch).toHaveBeenCalledWith(setUser({ ...user, name: 'Jane Doe', customFields: {} })); + expect(emitSpy).toHaveBeenCalled(); + expect(handleSaveUserProfileError).not.toHaveBeenCalled(); + }); + + it('asks for the current password before changing the email', async () => { + const { getByTestId } = renderProfile(); + + fireEvent.changeText(getByTestId('profile-view-email'), 'jane@rocket.chat'); + fireEvent.press(getByTestId('profile-view-submit')); + + await waitFor(() => expect(mockShowActionSheet).toHaveBeenCalled()); + expect(saveUserProfile).not.toHaveBeenCalled(); + }); + + it('retries the save through the 2FA challenge', async () => { + (saveUserProfile as jest.Mock) + .mockRejectedValueOnce({ error: 'totp-invalid', details: { method: 'totp' } }) + .mockResolvedValueOnce(true); + (twoFactor as jest.Mock).mockResolvedValue({ twoFactorCode: '123456', twoFactorMethod: 'totp' }); + + const { getByTestId } = renderProfile(); + changeNameAndSubmit(getByTestId); + + await waitFor(() => expect(twoFactor).toHaveBeenCalledWith({ method: 'totp', invalid: false })); + await waitFor(() => expect(saveUserProfile).toHaveBeenCalledTimes(2)); + expect(handleSaveUserProfileError).not.toHaveBeenCalled(); + }); + + it('handles the save error after a cancelled/non-2FA failure', async () => { + const error = { error: 'some-other-error' }; + (saveUserProfile as jest.Mock).mockRejectedValue(error); + + const { getByTestId } = renderProfile(); + changeNameAndSubmit(getByTestId); + + await waitFor(() => expect(handleSaveUserProfileError).toHaveBeenCalledWith(error, 'saving_profile')); + expect(twoFactor).not.toHaveBeenCalled(); + }); + + it('hashes the password supplied through the confirm-email action sheet', async () => { + (saveUserProfile as jest.Mock).mockResolvedValue(true); + // Capture the onSubmit handler passed to the confirm-email action sheet. + let confirmOnSubmit: ((password: string) => Promise) | undefined; + mockShowActionSheet.mockImplementation(({ children }: any) => { + confirmOnSubmit = children?.props?.onSubmit; + }); + + const { getByTestId } = renderProfile(); + fireEvent.changeText(getByTestId('profile-view-email'), 'jane@rocket.chat'); + fireEvent.press(getByTestId('profile-view-submit')); + + await waitFor(() => expect(confirmOnSubmit).toBeDefined()); + await act(async () => { + await confirmOnSubmit!('my-secret'); + }); + + await waitFor(() => + expect(saveUserProfile).toHaveBeenCalledWith({ email: 'jane@rocket.chat', currentPassword: sha256('my-secret') }, {}) + ); + expect(mockHideActionSheet).toHaveBeenCalled(); + }); +}); diff --git a/app/views/ProfileView/index.tsx b/app/views/ProfileView/index.tsx index e5206058ea..a36a555c6a 100644 --- a/app/views/ProfileView/index.tsx +++ b/app/views/ProfileView/index.tsx @@ -1,5 +1,4 @@ import { type NativeStackNavigationOptions, type NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { sha256 } from 'js-sha256'; import React, { useCallback, useLayoutEffect, useRef, useState } from 'react'; import { Keyboard, ScrollView, View, type TextInput } from 'react-native'; import { useDispatch } from 'react-redux'; @@ -39,6 +38,7 @@ import CustomFields from '../../containers/CustomFields'; import ListSeparator from '../../containers/List/ListSeparator'; import handleSaveUserProfileError from '../../lib/methods/helpers/handleSaveUserProfileError'; import logoutOtherLocations from './methods/logoutOtherLocations'; +import buildProfileParams from './methods/buildProfileParams'; import ConfirmEmailChangeActionSheetContent from './components/ConfirmEmailChangeActionSheetContent'; // https://github.com/RocketChat/Rocket.Chat/blob/174c28d40b3d5a52023ee2dca2e81dd77ff33fa5/apps/meteor/app/lib/server/functions/saveUser.js#L24-L25 @@ -155,7 +155,70 @@ const ProfileView = ({ navigation }: IProfileViewProps): React.ReactElement => { showActionSheet({ children: }); }; - // TODO: function is too long, split it + const showConfirmEmailChangeActionSheet = () => { + showActionSheet({ + children: ( + { + hideActionSheet(); + setValue('currentPassword', p as any); + await submit(); + }} + /> + ) + }); + }; + + const resetSavingState = () => { + setValue('saving', false); + setValue('currentPassword', null); + setTwoFactorCode(null); + }; + + const applySaveSuccess = (params: IProfileParams) => { + logEvent(events.PROFILE_SAVE_CHANGES); + + const updatedUser = { ...user, ...params }; + + reset({ + name: updatedUser.name || '', + username: updatedUser.username || '', + email: updatedUser.emails?.[0]?.address || updatedUser.email || '', + currentPassword: null, + bio: updatedUser.bio || '', + nickname: updatedUser.nickname || '', + saving: false + }); + dispatch(setUser({ ...user, ...params, customFields })); + EventEmitter.emit(LISTENER, { message: I18n.t('Profile_saved_successfully') }); + }; + + const setFieldErrorsFromResponse = (e: any, email: string | null) => { + if (e?.error === 'error-could-not-save-identity') { + setError('username', { message: I18n.t('Username_not_available'), type: 'validate' }); + } + + if (email && e?.message?.startsWith(email) && e?.error === 'error-field-unavailable') { + setError('email', { message: I18n.t('Email_associated_with_another_user'), type: 'validate' }); + } + }; + + // Returns true if a 2FA retry was issued and submit should yield to it. + const handleTwoFactorChallenge = async (e: any): Promise => { + if (e?.error !== 'totp-invalid' || e?.details.method === TwoFactorMethods.PASSWORD) { + return false; + } + try { + const code = await twoFactor({ method: e.details.method, invalid: e?.error === 'totp-invalid' && !!twoFactorCode }); + setTwoFactorCode(code as any); + await submit(); + return true; + } catch { + // cancelled twoFactor modal + return false; + } + }; + const submit = async (): Promise => { Keyboard.dismiss(); @@ -165,91 +228,30 @@ const ProfileView = ({ navigation }: IProfileViewProps): React.ReactElement => { setValue('saving', true); - const { name, username, email, currentPassword, bio, nickname } = getValues(); - const params = {} as IProfileParams; - - if (user.name !== name) params.name = name; - if (user.username !== username) params.username = username; - if (user.emails?.[0].address !== email) params.email = email; - if (user.bio !== bio) params.bio = bio; - if (user.nickname !== nickname) params.nickname = nickname; - if (currentPassword) params.currentPassword = sha256(currentPassword); - + const params = buildProfileParams(getValues(), user); const requirePassword = !!params.email; if (requirePassword && !params.currentPassword) { setValue('saving', false); - showActionSheet({ - children: ( - { - hideActionSheet(); - setValue('currentPassword', p as any); - submit(); - }} - /> - ) - }); + showConfirmEmailChangeActionSheet(); return; } try { const result = await saveUserProfile(params, customFields); - if (result) { - logEvent(events.PROFILE_SAVE_CHANGES); - if (customFields) { - dispatch(setUser({ customFields, ...params })); - setCustomFields(customFields); - } else { - dispatch(setUser({ ...params })); - const user = { ...getValues(), ...params }; - Object.entries(user).forEach(([key, value]) => setValue(key as any, value)); - } - - const updatedUser = { - ...user, - ...params - }; - - reset({ - name: updatedUser.name || '', - username: updatedUser.username || '', - email: updatedUser.emails?.[0]?.address || updatedUser.email || '', - currentPassword: null, - bio: updatedUser.bio || '', - nickname: updatedUser.nickname || '', - saving: false - }); - dispatch(setUser({ ...user, ...params, customFields })); - EventEmitter.emit(LISTENER, { message: I18n.t('Profile_saved_successfully') }); + applySaveSuccess(params); } - - setValue('saving', false); - setValue('currentPassword', null); - setTwoFactorCode(null); + resetSavingState(); } catch (e: any) { - if (e?.error === 'error-could-not-save-identity') { - setError('username', { message: I18n.t('Username_not_available'), type: 'validate' }); - } + const { email } = getValues(); + setFieldErrorsFromResponse(e, email); - if (e?.message.startsWith(email) && e?.error === 'error-field-unavailable') { - setError('email', { message: I18n.t('Email_associated_with_another_user'), type: 'validate' }); - } + const retried = await handleTwoFactorChallenge(e); + if (retried) return; - if (e?.error === 'totp-invalid' && e?.details.method !== TwoFactorMethods.PASSWORD) { - try { - const code = await twoFactor({ method: e.details.method, invalid: e?.error === 'totp-invalid' && !!twoFactorCode }); - setTwoFactorCode(code as any); - return submit(); - } catch { - // cancelled twoFactor modal - } - } logEvent(events.PROFILE_SAVE_CHANGES_F); - setValue('saving', false); - setValue('currentPassword', null); - setTwoFactorCode(null); + resetSavingState(); handleSaveUserProfileError(e, 'saving_profile'); } }; diff --git a/app/views/ProfileView/methods/buildProfileParams.test.ts b/app/views/ProfileView/methods/buildProfileParams.test.ts new file mode 100644 index 0000000000..02302cc704 --- /dev/null +++ b/app/views/ProfileView/methods/buildProfileParams.test.ts @@ -0,0 +1,80 @@ +import { sha256 } from 'js-sha256'; + +import { type IUser } from '../../../definitions'; +import buildProfileParams from './buildProfileParams'; + +const baseUser = { + name: 'John Doe', + username: 'john.doe', + emails: [{ address: 'john@rocket.chat', verified: true }], + bio: 'My bio', + nickname: 'johnny' +} as unknown as IUser; + +const baseForm = { + name: 'John Doe', + username: 'john.doe', + email: 'john@rocket.chat', + currentPassword: null, + bio: 'My bio', + nickname: 'johnny' +}; + +describe('buildProfileParams', () => { + it('returns an empty object when nothing changed', () => { + expect(buildProfileParams(baseForm, baseUser)).toEqual({}); + }); + + it('includes name only when it changed', () => { + expect(buildProfileParams({ ...baseForm, name: 'Jane Doe' }, baseUser)).toEqual({ name: 'Jane Doe' }); + }); + + it('includes username only when it changed', () => { + expect(buildProfileParams({ ...baseForm, username: 'jane.doe' }, baseUser)).toEqual({ username: 'jane.doe' }); + }); + + it('includes email only when it changed', () => { + expect(buildProfileParams({ ...baseForm, email: 'jane@rocket.chat' }, baseUser)).toEqual({ email: 'jane@rocket.chat' }); + }); + + it('includes bio only when it changed', () => { + expect(buildProfileParams({ ...baseForm, bio: 'New bio' }, baseUser)).toEqual({ bio: 'New bio' }); + }); + + it('includes nickname only when it changed', () => { + expect(buildProfileParams({ ...baseForm, nickname: 'jd' }, baseUser)).toEqual({ nickname: 'jd' }); + }); + + it('hashes currentPassword with sha256 when provided', () => { + const params = buildProfileParams({ ...baseForm, currentPassword: 'my-secret' }, baseUser); + expect(params).toEqual({ currentPassword: sha256('my-secret') }); + expect(params.currentPassword).not.toBe('my-secret'); + }); + + it('does not include currentPassword when it is null', () => { + const params = buildProfileParams(baseForm, baseUser); + expect(params).not.toHaveProperty('currentPassword'); + }); + + it('aggregates every changed field at once', () => { + const params = buildProfileParams( + { + name: 'Jane Doe', + username: 'jane.doe', + email: 'jane@rocket.chat', + currentPassword: 'pass123', + bio: 'New bio', + nickname: 'jd' + }, + baseUser + ); + expect(params).toEqual({ + name: 'Jane Doe', + username: 'jane.doe', + email: 'jane@rocket.chat', + bio: 'New bio', + nickname: 'jd', + currentPassword: sha256('pass123') + }); + }); +}); diff --git a/app/views/ProfileView/methods/buildProfileParams.ts b/app/views/ProfileView/methods/buildProfileParams.ts new file mode 100644 index 0000000000..8adfa56156 --- /dev/null +++ b/app/views/ProfileView/methods/buildProfileParams.ts @@ -0,0 +1,28 @@ +import { sha256 } from 'js-sha256'; + +import { type IProfileParams, type IUser } from '../../../definitions'; + +interface IProfileFormValues { + name: string; + username: string; + email: string | null; + currentPassword: string | null; + bio?: string; + nickname?: string; +} + +const buildProfileParams = (formValues: IProfileFormValues, user: IUser): IProfileParams => { + const { name, username, email, currentPassword, bio, nickname } = formValues; + const params = {} as IProfileParams; + + if (user.name !== name) params.name = name; + if (user.username !== username) params.username = username; + if (user.emails?.[0].address !== email) params.email = email; + if (user.bio !== bio) params.bio = bio; + if (user.nickname !== nickname) params.nickname = nickname; + if (currentPassword) params.currentPassword = sha256(currentPassword); + + return params; +}; + +export default buildProfileParams;