Inject @AuthenticationPrincipal when unit testing a Spring REST controller
Asked Answered
L

7

31

I am having trouble trying to test a REST endpoint that receives an UserDetails as a parameter annotated with @AuthenticationPrincipal.

It seems like the user instance created in the test scenario is not being used, but an attempt to instantiate using the default constructor is made instead: org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.andrucz.app.AppUserDetails]: No default constructor found;

REST endpoint:

@RestController
@RequestMapping("/api/items")
class ItemEndpoint {

    @Autowired
    private ItemService itemService;

    @RequestMapping(path = "/{id}",
                    method = RequestMethod.GET,
                    produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
    public Callable<ItemDto> getItemById(@PathVariable("id") String id, @AuthenticationPrincipal AppUserDetails userDetails) {
        return () -> {
            Item item = itemService.getItemById(id).orElseThrow(() -> new ResourceNotFoundException(id));
            ...
        };
    }
}

Test class:

public class ItemEndpointTests {

    @InjectMocks
    private ItemEndpoint itemEndpoint;

    @Mock
    private ItemService itemService;

    private MockMvc mockMvc;

    @Before
    public void setup() {
        MockitoAnnotations.initMocks(this);
        mockMvc = MockMvcBuilders.standaloneSetup(itemEndpoint)
                .build();
    }

    @Test
    public void findItem() throws Exception {
        when(itemService.getItemById("1")).thenReturn(Optional.of(new Item()));

        mockMvc.perform(get("/api/items/1").with(user(new AppUserDetails(new User()))))
                .andExpect(status().isOk());
    }

}

How can I solve that problem without having to switch to webAppContextSetup? I want to write tests having total control of service mocks, so I am using standaloneSetup.

Lovemaking answered 12/7, 2016 at 13:40 Comment(8)
You need to follow these instructions.Bronwen
So there is no way to use standaloneSetup combined with authentication?Lovemaking
Where does it say that?Bronwen
I am not sure, but how could I get a FilterChainProxy, which is required?Lovemaking
You can also use webAppContextSetup, while still retaining complete control of the beans in that context with @ContextConfiguration.Bronwen
You can get a FilterChainProxy like this.Bronwen
Nice. Could you please show me an example of I could mock the service layer in controller test while using webAppContextSetup? Should I create a test config class that creates mock instances and declare it in @ContextConfiguration?Lovemaking
Yes, that is what you should do. I suggest using FactoryBeans to skip any unnecessary auto-wiring.Bronwen
T
14

This can be done by injection a HandlerMethodArgumentResolver into your Mock MVC context or standalone setup. Assuming your @AuthenticationPrincipal is of type ParticipantDetails:

private HandlerMethodArgumentResolver putAuthenticationPrincipal = new HandlerMethodArgumentResolver() {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().isAssignableFrom(ParticipantDetails.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        return new ParticipantDetails(…);
    }
};

This argument resolver can handle the type ParticipantDetails and just creates it out of thin air, but you see you get a lot of context. Later on, this argument resolver is attached to the mock MVC object:

@BeforeMethod
public void beforeMethod() {
    mockMvc = MockMvcBuilders
            .standaloneSetup(…)
            .setCustomArgumentResolvers(putAuthenticationPrincipal)
            .build();
}

This will result in your @AuthenticationPrincipal annotated method arguments to be populated with the details from your resolver.

Trudge answered 30/1, 2017 at 15:21 Comment(1)
This is nice approach just be careful! There is ~ 20 argument resolvers registered by default which take care of interesting types. In my case an Authentication parameter was handled by ServletRequestMethodArgumentResolver and my handler was ignored.Verbalism
C
7

For some reason Michael Piefel's solution didn't work for me so I came up with another one.

First of all, create abstract configuration class:

@RunWith(SpringRunner.class)
@SpringBootTest
@TestExecutionListeners({
    DependencyInjectionTestExecutionListener.class,
    DirtiesContextTestExecutionListener.class,
    WithSecurityContextTestExecutionListener.class})
public abstract MockMvcTestPrototype {

    @Autowired
    protected WebApplicationContext context;

    protected MockMvc mockMvc;

    protected org.springframework.security.core.userdetails.User loggedUser;

    @Before
    public voivd setUp() {
         mockMvc = MockMvcBuilders
            .webAppContextSetup(context)
            .apply(springSecurity())
            .build();

        loggedUser =  (User)  SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    } 
}

Then you can write tests like this:

public class SomeTestClass extends MockMvcTestPrototype {

    @Test
    @WithUserDetails("[email protected]")
    public void someTest() throws Exception {
        mockMvc.
                perform(get("/api/someService")
                    .withUser(user(loggedUser)))
                .andExpect(status().isOk());

    }
}

And @AuthenticationPrincipal should inject your own User class implementation into controller method

public class SomeController {
...
    @RequestMapping(method = POST, value = "/update")
    public String update(UdateDto dto, @AuthenticationPrincipal CurrentUser user) {
        ...
        user.getUser(); // works like a charm!
       ...
    }
}
Cooker answered 27/2, 2017 at 12:55 Comment(0)
C
6

I know the question is old but for folks still looking, what worked for me to write a Spring Boot test with @AuthenticationPrincipal (and this may not work with all instances), was annotating the test @WithMockUser("testuser1")

@Test
@WithMockUser("testuser1")
public void successfullyMockUser throws Exception {
    mvc.perform(...));
}

Here is a link to the Spring documentation on @WithMockUser

Champion answered 11/4, 2018 at 18:49 Comment(1)
The new Spring link: docs.spring.io/spring-security/site/docs/current/api/org/…Bolan
H
4

It's not well documented but there's a way to inject the Authentication object as parameter of your MVC method in a standalone MockMvc. If you set the Authentication in the SecurityContextHolder, the filter SecurityContextHolderAwareRequestFilter is usually instantiated by Spring Security and makes the injection of the auth for you.

You simply need to add that filter to your MockMvc setup, like this:

@Before
public void before() throws Exception {
    SecurityContextHolder.getContext().setAuthentication(myAuthentication);
    SecurityContextHolderAwareRequestFilter authInjector = new SecurityContextHolderAwareRequestFilter();
    authInjector.afterPropertiesSet();
    mvc = MockMvcBuilders.standaloneSetup(myController).addFilters(authInjector).build();
}
Hindrance answered 14/11, 2018 at 10:10 Comment(0)
L
1

My @AuthenticationPrincipal is expecting a Jwt, so had to implement a custom argument resolver that implements HandlerMethodArgumentResolver.

public class JwtArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().isAssignableFrom(Jwt.class);
    }
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {

        var jwtToken = Jwt.withTokenValue("token")
                .header("alg", "none")
                .claim("sub", "user")
                .claim("scope", "read").build();
        return jwtToken;
    }
}

In test:

@BeforeAll
public void setup() {
    MockitoAnnotations.initMocks(this);
    mockMvc =  MockMvcBuilders.standaloneSetup(myController).setCustomArgumentResolvers(new JwtArgumentResolver()).build();
}

And the mockMvc.perform can be used without passing a RequestPostProcessor.

Logomachy answered 16/12, 2022 at 9:2 Comment(0)
C
0

Simplification of @pzeszko answer:

@ExtendWith(SpringExtension.class)
@SpringBootTest
@Transactional
@AutoConfigureMockMvc
public class ControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithUserDetails(value = "[email protected]")
    void get() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get(URL))
                .andExpect(status().isOk())
                .andDo(print());
    }

See:

Culture answered 2/4, 2021 at 13:51 Comment(0)
S
0

This solution worked for me and I found it really handy.

Create a TestIUserDetails service that implements UserDetailsServce in test package:

@Service
@Primary
@Profile("test")
public class TestIUserDetails implements UserDetailsService {
public static final String ADMIN_USERNAME = "[email protected]";
    public static final String USERNAME = "[email protected]";

    private User getUser() {
        User user = new User();
        user.setEmail(USERNAME);
        user.setId(1L);
        return user;
    }
    ...
    
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if (Objects.equals(username, ADMIN_USERNAME))
            return getAdminUser();
        else if (Objects.equals(username, USERNAME))
            return getUser();
        return getPublicUser();
    }
}

Now, on your test:

@SpringMockWebEnvTestConfig
class AbcControllerTest {
    @Autowired
    private MockMvc mvc;
    @Autowired
    UserDetailsService userDetailsService;
    private User user;

    @BeforeEach
    void setUp() {
        user = (User) userDetailsService.loadUserByUsername(TestUserDetailsImpl.USERNAME);
    }

   @Test
   public void testAbc(){
     this.mvc.perform(post(endpoint).with(user(user))
     ...
     .andExpect(status().isCreated())...
   }
}
Skiascope answered 9/7, 2021 at 19:22 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.