From 399ff2140da8a52663cd0e7fca542e2ccf542939 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 27 Oct 2026 16:29:49 -0400 Subject: [PATCH 1/2] test(mosaic): cover user profile MFA and profile paths that no test caught --- .../mosaic-user-profile-mutation-tests.md | 2 + .../user-profile-mfa.feature.test.tsx | 71 ++++++++++++++++++ ...r-profile-profile-section.feature.test.tsx | 75 ++++++++++++++++++- 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 .changeset/mosaic-user-profile-mutation-tests.md diff --git a/.changeset/mosaic-user-profile-mutation-tests.md b/.changeset/mosaic-user-profile-mutation-tests.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-user-profile-mutation-tests.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa.feature.test.tsx index 5cc5e3a24bc..fb8567eb845 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-mfa-section/__tests__/user-profile-mfa.feature.test.tsx @@ -178,12 +178,83 @@ describe('User profile MFA', () => { const fapi = await renderMfa(fapiUser({ id: 'user_1' }), environment); const user = userEvent.setup(); await user.click(screen.getByRole('button', { name: 'Add verification method' })); + expect(screen.queryByRole('button', { name: /SMS verification/ })).toBeNull(); await user.click(screen.getByRole('button', { name: /Authenticator app/ })); await user.type(await screen.findByRole('textbox', { name: 'Verification code' }), '123456'); expect(await screen.findByText('CODE0001')).toBeVisible(); expect(fapi.mfa.backupCodeCreations).toBe(0); }); + it('offers only the methods the user has not set up yet', async () => { + const reserved = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + await renderMfa( + fapiUser({ + id: 'user_1', + phone_numbers: [reserved], + totp_enabled: true, + backup_code_enabled: true, + two_factor_enabled: true, + }), + ); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await waitFor(() => expect(screen.getByRole('button', { name: /^SMS verification/ })).toBeVisible()); + expect(screen.queryByRole('button', { name: /^Authenticator app/ })).toBeNull(); + expect(screen.queryByRole('button', { name: /^Backup codes/ })).toBeNull(); + }); + + it('lists only phones that are not already used for SMS verification', async () => { + const reserved = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + const other = fapiPhoneNumber({ ...phone, id: 'phone_2', phone_number: '+15555550202' }); + await renderMfa(fapiUser({ id: 'user_1', phone_numbers: [reserved, other], two_factor_enabled: true })); + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Add verification method' })); + await user.click(screen.getByRole('button', { name: /^SMS verification/ })); + await user.click(screen.getByRole('combobox', { name: /Phone number/ })); + const options = await screen.findAllByRole('option'); + expect(options.map(option => option.textContent)).toEqual(['+1 (555) 555-0202']); + }); + + it('lets either factor be removed when a verified SMS phone backs up the authenticator', async () => { + const environment = mfaEnvironment(); + environment.user_settings.sign_up.mfa = { required: true }; + const reserved = fapiPhoneNumber({ ...phone, reserved_for_second_factor: true, default_second_factor: true }); + await renderMfa( + fapiUser({ id: 'user_1', phone_numbers: [reserved], totp_enabled: true, two_factor_enabled: true }), + environment, + ); + expect(screen.getByRole('button', { name: 'Manage Authenticator app' })).toBeVisible(); + expect(screen.getByRole('button', { name: 'Manage SMS verification +1 (555) 555-0101' })).toBeVisible(); + }); + + it('does not count an unverified SMS phone toward required verification', async () => { + const environment = mfaEnvironment(); + environment.user_settings.sign_up.mfa = { required: true }; + const unverified = fapiPhoneNumber({ + ...phone, + reserved_for_second_factor: true, + verification: { ...phone.verification, status: 'unverified' }, + }); + await renderMfa( + fapiUser({ id: 'user_1', phone_numbers: [unverified], totp_enabled: true, two_factor_enabled: true }), + environment, + ); + expect(screen.getByText('Authenticator app')).toBeVisible(); + expect(screen.queryByText('SMS verification')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Manage Authenticator app' })).toBeNull(); + }); + + it('hides existing backup codes when the instance turns them off', async () => { + const environment = mfaEnvironment(); + environment.user_settings.attributes.backup_code.used_for_second_factor = false; + await renderMfa( + fapiUser({ id: 'user_1', totp_enabled: true, backup_code_enabled: true, two_factor_enabled: true }), + environment, + ); + expect(screen.getByText('Authenticator app')).toBeVisible(); + expect(screen.queryByText('Backup codes')).toBeNull(); + }); + it('shows a localized incorrect TOTP code and allows a retry with the same secret', async () => { const fapi = await renderMfa(); const user = userEvent.setup(); diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx index 1c57b8bc19c..c3a3ff3d531 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx @@ -78,10 +78,53 @@ describe('the user profile profile section', () => { const dialog = screen.getByRole('dialog', { name: 'Edit name' }); await actor.clear(within(dialog).getByLabelText('First name')); await actor.type(within(dialog).getByLabelText('First name'), 'Alicia'); + await actor.clear(within(dialog).getByLabelText('Last name')); + await actor.type(within(dialog).getByLabelText('Last name'), 'Jones'); await actor.click(within(dialog).getByRole('button', { name: 'Save changes' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - expect(screen.getByText('Alicia Smith')).toBeInTheDocument(); + expect(screen.getByText('Alicia Jones')).toBeInTheDocument(); + }); + + it('puts a last name the server rejects on the last name field', async () => { + const { actor } = await renderSection(); + failsWith( + '/v1/me', + { + code: 'form_param_format_invalid', + message: 'Invalid', + long_message: 'Last name contains invalid characters.', + meta: { param_name: 'last_name' }, + }, + 422, + ); + + await actor.click(screen.getByRole('button', { name: 'Edit name' })); + const dialog = screen.getByRole('dialog', { name: 'Edit name' }); + await actor.type(within(dialog).getByLabelText('Last name'), '!'); + await actor.click(within(dialog).getByRole('button', { name: 'Save changes' })); + + const lastName = within(dialog).getByLabelText('Last name'); + await waitFor(() => expect(lastName).toHaveAccessibleDescription('Last name contains invalid characters.')); + expect(lastName).toHaveAttribute('aria-invalid', 'true'); + expect(within(dialog).getByLabelText('First name')).not.toHaveAttribute('aria-invalid', 'true'); + }); + + it('keeps Save disabled while a required half of the name is empty', async () => { + const { actor } = await renderSection( + signedIn(fapiEnvironment({ attributes: { first_name: { required: true }, last_name: { required: true } } })), + ); + + await actor.click(screen.getByRole('button', { name: 'Edit name' })); + const dialog = screen.getByRole('dialog', { name: 'Edit name' }); + const save = within(dialog).getByRole('button', { name: 'Save changes' }); + await actor.clear(within(dialog).getByLabelText(/^First name/)); + expect(save).toHaveAttribute('aria-disabled', 'true'); + await actor.type(within(dialog).getByLabelText(/^First name/), 'Alicia'); + await actor.clear(within(dialog).getByLabelText(/^Last name/)); + expect(save).toHaveAttribute('aria-disabled', 'true'); + await actor.type(within(dialog).getByLabelText(/^Last name/), 'Jones'); + expect(save).not.toHaveAttribute('aria-disabled', 'true'); }); it('leaves the name out when the instance collects neither half of it', async () => { @@ -223,6 +266,36 @@ describe('the user profile profile section', () => { expect(profile()).toHaveTextContent('alicia'); }); + it('keeps Save disabled while a required username is empty', async () => { + const { actor } = await renderSection(withUsername('alicesmith', { required: true })); + + await actor.click(within(profile()).getByRole('button', { name: 'Edit username' })); + const dialog = screen.getByRole('dialog', { name: 'Edit username' }); + await actor.clear(within(dialog).getByLabelText(/^Username/)); + + expect(within(dialog).getByRole('button', { name: 'Save changes' })).toHaveAttribute('aria-disabled', 'true'); + }); + + it('tells the user the username length the instance allows', async () => { + const { actor } = await renderSection(withUsername('alicesmith')); + failsWith( + '/v1/me', + { code: 'form_username_invalid_length', message: 'Invalid length', meta: { param_name: 'username' } }, + 422, + ); + + await actor.click(within(profile()).getByRole('button', { name: 'Edit username' })); + const dialog = screen.getByRole('dialog', { name: 'Edit username' }); + await actor.type(within(dialog).getByLabelText('Username'), '2'); + await actor.click(within(dialog).getByRole('button', { name: 'Save changes' })); + + await waitFor(() => + expect(within(dialog).getByLabelText('Username')).toHaveAccessibleDescription( + 'Your username must be between 4 and 64 characters long.', + ), + ); + }); + it('offers to set a username the user has not picked yet', async () => { await renderSection(withUsername(null)); From 421fabb8649242e8978c8794efa4a83cdd1f34a0 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 27 Oct 2026 20:25:34 -0400 Subject: [PATCH 2/2] test(mosaic): check the username length message uses the instance's limits --- .../user-profile-profile-section.feature.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx index c3a3ff3d531..4e168c77d1c 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-profile-section/__tests__/user-profile-profile-section.feature.test.tsx @@ -277,7 +277,10 @@ describe('the user profile profile section', () => { }); it('tells the user the username length the instance allows', async () => { - const { actor } = await renderSection(withUsername('alicesmith')); + const { actor } = await renderSection({ + ...withUsername('alicesmith'), + environment: fapiEnvironment({ user_settings: { username_settings: { min_length: 5, max_length: 20 } } }), + }); failsWith( '/v1/me', { code: 'form_username_invalid_length', message: 'Invalid length', meta: { param_name: 'username' } }, @@ -291,7 +294,7 @@ describe('the user profile profile section', () => { await waitFor(() => expect(within(dialog).getByLabelText('Username')).toHaveAccessibleDescription( - 'Your username must be between 4 and 64 characters long.', + 'Your username must be between 5 and 20 characters long.', ), ); });