Search code examples
javaspringspring-securityspring-testspring-security-test

Spring Security testing: .getPrincipal() returning different objects in app and tests


I'm making a Reddit clone as one of the projects for my portfolio.

The problem I'm unable to solve (I'm a beginner) is this:

I have a CommentController (REST) that's handling all the api calls regarding comments. There's an endpoint for creating a comment:

@PostMapping
public ResponseEntity<Comment> createComment(@Valid @RequestBody CommentDto commentDto, BindingResult bindingResult) {
    if (bindingResult.hasErrors()) throw new DtoValidationException(bindingResult.getAllErrors());
    URI uri = URI.create(ServletUriComponentsBuilder.fromCurrentContextPath().path("/api/comments/").toUriString());
    Comment comment = commentService.save(commentDto);
    return ResponseEntity.created(uri).body(comment);
}

In my CommentService class, this is the method for saving a comment made by currently logged in user:

@Override
public Comment save(CommentDto commentDto) {
    Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    Optional<User> userOptional = userRepository.findUserByUsername((String) principal);
    if (userOptional.isPresent()) {
        User user = userOptional.get();
        Optional<Post> postOptional = postRepository.findById(commentDto.getPostId());
        if (postOptional.isPresent()) {
            Post post = postOptional.get();
            Comment comment = new Comment(user, commentDto.getText(), post);
            user.getComments().add(comment);
            post.getComments().add(comment);
            post.setCommentsCounter(post.getCommentsCounter());
            return comment;
        } else {
            throw new PostNotFoundException(commentDto.getPostId());
        }
    } else {
        throw new UserNotFoundException((String) principal);
    }
}

The app is running normally with no exceptions, and comment is saved to the database.

I'm writing an integration test for that controller, I used @WithMockUser(username = "janedoe", password = "password") on a test class, and I kept getting this exception:

ClassCastException: UserDetails can not be converted to String

I realized that the problem is with this two lines in save method:

Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
Optional<User> userOptional = userRepository.findUserByUsername((String) principal);

What I don't get is why are those to lines throwing exception only in tests. When the app is running, everything is okay.

I guess that for some reason in tests the .getPrincipal() method is not returning a String (username), but the whole UserDetails object. I'm not sure how to make it return username in tests.

What have I tried to solve it:

  1. Changing @WithMockUser to @WithUserDetails

  2. Using both @WithMockUser and @WithUserDetails on both class- and method-level

  3. Creating custom @WithMockCustomUser annotation:

    @Retention(RetentionPolicy.RUNTIME)
    @WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
    public @interface WithMockCustomUser {
    
    String username() default "janedoe";
    String principal() default "janedoe";
    

}


Which just gives me the same ClassCastException with different text:
class com.reddit.controller.CustomUserDetails cannot be cast to class java.lang.String (com.reddit.controller.CustomUserDetails is in unnamed module of loader 'app'; java.lang.String is in module java.base of loader 'bootstrap')

Any help is appreciated :)

Solution

  • You right. It is because the authentication logic in your production codes are different from the test. In production codes it configure a string type principal to the Authentication while @WithMockUser / @WithUserDetails configure a non-string type principal.

    Implement a custom @WithMockCustomUser should work as long as you configure a string type principal to Authentication.

    The following implementation should solve your problem :

    @Target({ ElementType.METHOD, ElementType.TYPE })
    @Retention(RetentionPolicy.RUNTIME)
    @Inherited
    @WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
    public @interface WithMockCustomUser {
    
        String[] authorities() default {};
    
        String principal() default "foo-principal";
    
    }
    
    public class WithMockCustomUserSecurityContextFactory
            implements WithSecurityContextFactory<WithMockCustomUser> {
    
        @Override
        public SecurityContext createSecurityContext(WithMockCustomUser withUser) {
    
            List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
            for (String authority : withUser.authorities()) {
                grantedAuthorities.add(new SimpleGrantedAuthority(authority));
            }
    
            Authentication authentication = UsernamePasswordAuthenticationToken.authenticated(withUser.principal(),
                    "somePassword", grantedAuthorities);
            SecurityContext context = SecurityContextHolder.createEmptyContext();
            context.setAuthentication(authentication);
            return context;
        }
    }
    

    And use it in your test :

    @Test
    @WithMockCustomUser(principal="janedoe")
    public void test() {
        // your test code
    }