@@ -6,12 +6,45 @@ import { describe, expect, it, vi } from 'vitest';
66import { fapiUrl , holdRequests , serveFapi , worker } from '../../../__tests__/feature/fake-fapi' ;
77import { fapiClient , fapiPhoneNumber , fapiSession , fapiUser } from '../../../__tests__/feature/fapi' ;
88import { renderWithClerk } from '../../../__tests__/feature/render' ;
9- import { UserProfileMfaSection , useUserProfileMfaSlot } from '../user-profile-mfa-section/user-profile-mfa-section' ;
9+ import { mfaSectionNode , UserProfileMfaSection } from '../user-profile-mfa-section/user-profile-mfa-section' ;
10+ import { useUserProfileMfaModel } from '../user-profile-mfa-section/user-profile-mfa-section.model' ;
1011import { useUserProfilePasswordSlot } from '../user-profile-password-section/user-profile-password-section' ;
1112import { UserProfileSecurityPanelView } from '../user-profile-security-panel.view' ;
1213import { mfaEnvironment , phone , renderMfa } from './mfa-feature-setup' ;
1314
1415describe ( 'User profile MFA management' , ( ) => {
16+ it ( 'renders a plain MFA node in Authentication over legacy methods' , async ( ) => {
17+ serveFapi ( {
18+ environment : mfaEnvironment ( ) ,
19+ client : fapiClient ( [ fapiSession ( { id : 'sess_1' , user : fapiUser ( { id : 'user_1' } ) } ) ] ) ,
20+ } ) ;
21+ await renderWithClerk (
22+ < UserProfileSecurityPanelView
23+ mfaSlot = { < div > Connected MFA</ div > }
24+ mfaMethods = { [ { id : 'injected' , type : 'authenticator' } ] }
25+ /> ,
26+ ) ;
27+
28+ const authentication = screen . getByRole ( 'region' , { name : 'Authentication' } ) ;
29+ expect ( within ( authentication ) . getByText ( 'Connected MFA' ) ) . toBeVisible ( ) ;
30+ expect ( within ( authentication ) . queryByRole ( 'heading' , { name : '2-step verification' } ) ) . toBeNull ( ) ;
31+ } ) ;
32+
33+ it ( 'suppresses injected MFA when the slot is explicitly null' , async ( ) => {
34+ serveFapi ( {
35+ environment : mfaEnvironment ( ) ,
36+ client : fapiClient ( [ fapiSession ( { id : 'sess_1' , user : fapiUser ( { id : 'user_1' } ) } ) ] ) ,
37+ } ) ;
38+ await renderWithClerk (
39+ < UserProfileSecurityPanelView
40+ mfaSlot = { null }
41+ mfaMethods = { [ { id : 'injected' , type : 'authenticator' } ] }
42+ /> ,
43+ ) ;
44+
45+ expect ( screen . queryByRole ( 'region' , { name : 'Authentication' } ) ) . toBeNull ( ) ;
46+ } ) ;
47+
1548 it ( 'removes SMS MFA while retaining the phone number' , async ( ) => {
1649 const reserved = fapiPhoneNumber ( { ...phone , reserved_for_second_factor : true , default_second_factor : true } ) ;
1750 const fapi = await renderMfa ( fapiUser ( { id : 'user_1' , phone_numbers : [ reserved ] , two_factor_enabled : true } ) ) ;
@@ -28,6 +61,26 @@ describe('User profile MFA management', () => {
2861 expect ( fapi . client . sessions [ 0 ] ?. user . phone_numbers ) . toHaveLength ( 1 ) ;
2962 } ) ;
3063
64+ it ( 'moves focus through remaining MFA rows and then to Add after sequential removals' , async ( ) => {
65+ const first = fapiPhoneNumber ( { ...phone , reserved_for_second_factor : true , default_second_factor : true } ) ;
66+ const second = fapiPhoneNumber ( { id : 'phone_2' , phone_number : '+15555550202' , reserved_for_second_factor : true } ) ;
67+ const fapi = await renderMfa ( fapiUser ( { id : 'user_1' , phone_numbers : [ first , second ] , two_factor_enabled : true } ) ) ;
68+ const user = userEvent . setup ( ) ;
69+
70+ await user . click ( screen . getByRole ( 'button' , { name : 'Manage SMS verification +15555550101' } ) ) ;
71+ await user . click ( screen . getByRole ( 'menuitem' , { name : 'Remove method' } ) ) ;
72+ await user . click ( within ( screen . getByRole ( 'alertdialog' ) ) . getByRole ( 'button' , { name : 'Remove' } ) ) ;
73+ const remaining = await screen . findByRole ( 'button' , { name : 'Manage SMS verification +15555550202' } ) ;
74+ await waitFor ( ( ) => expect ( remaining ) . toHaveFocus ( ) ) ;
75+
76+ await user . keyboard ( '{Enter}' ) ;
77+ await user . click ( screen . getByRole ( 'menuitem' , { name : 'Remove method' } ) ) ;
78+ await user . click ( within ( screen . getByRole ( 'alertdialog' ) ) . getByRole ( 'button' , { name : 'Remove' } ) ) ;
79+ await waitFor ( ( ) => expect ( screen . getByRole ( 'button' , { name : 'Add verification method' } ) ) . toHaveFocus ( ) ) ;
80+ expect ( fapi . mfa . phoneUpdates ) . toContainEqual ( { id : 'phone_1' , reserved : false , default : undefined } ) ;
81+ expect ( fapi . mfa . phoneUpdates ) . toContainEqual ( { id : 'phone_2' , reserved : false , default : undefined } ) ;
82+ } ) ;
83+
3184 it ( 'shows second-factor reverification for removal with no addable methods' , async ( ) => {
3285 const environment = mfaEnvironment ( ) ;
3386 environment . user_settings . attributes . phone_number . used_for_second_factor = false ;
@@ -123,7 +176,7 @@ describe('User profile MFA management', () => {
123176
124177 it ( 'places a connected MFA slot after password in the security panel' , async ( ) => {
125178 function Panel ( ) {
126- const mfaSlot = useUserProfileMfaSlot ( ) ;
179+ const mfaSlot = mfaSectionNode ( useUserProfileMfaModel ( ) ) ;
127180 const passwordSlot = useUserProfilePasswordSlot ( ) ;
128181 return (
129182 < UserProfileSecurityPanelView
@@ -147,7 +200,7 @@ describe('User profile MFA management', () => {
147200
148201 it ( 'uses the connected slot instead of duplicate injected MFA props' , async ( ) => {
149202 function Panel ( ) {
150- const mfaSlot = useUserProfileMfaSlot ( ) ;
203+ const mfaSlot = mfaSectionNode ( useUserProfileMfaModel ( ) ) ;
151204 return (
152205 < UserProfileSecurityPanelView
153206 mfaSlot = { mfaSlot }
@@ -166,7 +219,7 @@ describe('User profile MFA management', () => {
166219
167220 it ( 'suppresses injected MFA when the connected slot is hidden' , async ( ) => {
168221 function Panel ( ) {
169- const mfaSlot = useUserProfileMfaSlot ( ) ;
222+ const mfaSlot = mfaSectionNode ( useUserProfileMfaModel ( ) ) ;
170223 return (
171224 < UserProfileSecurityPanelView
172225 mfaSlot = { mfaSlot }
@@ -524,6 +577,42 @@ describe('User profile MFA management', () => {
524577 expect ( screen . getAllByText ( 'SMS verification' ) [ 0 ] ?. closest ( 'li' ) ) . toHaveTextContent ( 'Default' ) ;
525578 } ) ;
526579
580+ it ( 'shows a failed default change and clears its error on retry' , async ( ) => {
581+ const first = fapiPhoneNumber ( { ...phone , reserved_for_second_factor : true , default_second_factor : true } ) ;
582+ const second = fapiPhoneNumber ( { id : 'phone_2' , phone_number : '+15555550202' , reserved_for_second_factor : true } ) ;
583+ const fapi = await renderMfa ( fapiUser ( { id : 'user_1' , phone_numbers : [ first , second ] , two_factor_enabled : true } ) ) ;
584+ let attempts = 0 ;
585+ worker . use (
586+ http . post ( fapiUrl ( '/v1/me/phone_numbers/phone_2' ) , ( { request } ) => {
587+ if ( new URL ( request . url ) . searchParams . get ( '_method' ) !== 'PATCH' ) {
588+ return undefined ;
589+ }
590+ attempts += 1 ;
591+ return attempts === 1
592+ ? HttpResponse . json (
593+ { errors : [ { code : 'unexpected_error' , message : 'Unable to update default method.' } ] } ,
594+ { status : 503 } ,
595+ )
596+ : undefined ;
597+ } ) ,
598+ ) ;
599+ const user = userEvent . setup ( ) ;
600+ const selected = screen . getByRole ( 'button' , { name : 'Manage SMS verification +15555550202' } ) ;
601+
602+ await user . click ( selected ) ;
603+ await user . click ( screen . getByRole ( 'menuitem' , { name : 'Set as default' } ) ) ;
604+ await waitFor ( ( ) => expect ( screen . getByRole ( 'alert' ) ) . toHaveTextContent ( 'Unable to update default method.' ) ) ;
605+ expect ( screen . getAllByText ( 'Default' ) ) . toHaveLength ( 1 ) ;
606+
607+ await user . click ( selected ) ;
608+ await user . click ( screen . getByRole ( 'menuitem' , { name : 'Set as default' } ) ) ;
609+ await waitFor ( ( ) => expect ( screen . queryByRole ( 'alert' ) ) . toBeNull ( ) ) ;
610+ await waitFor ( ( ) =>
611+ expect ( fapi . mfa . phoneUpdates ) . toContainEqual ( { id : 'phone_2' , default : true , reserved : undefined } ) ,
612+ ) ;
613+ expect ( attempts ) . toBe ( 2 ) ;
614+ } ) ;
615+
527616 it ( 'keeps method menus available but hides default changes while one is pending' , async ( ) => {
528617 const first = fapiPhoneNumber ( { ...phone , reserved_for_second_factor : true , default_second_factor : true } ) ;
529618 const second = fapiPhoneNumber ( { id : 'phone_2' , phone_number : '+15555550202' , reserved_for_second_factor : true } ) ;
0 commit comments