Issue
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:
Changing @WithMockUser to @WithUserDetails
Using both @WithMockUser and @WithUserDetails on both class- and method-level
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
}
Answered By - Ken Chan
Answer Checked By - Marie Seifert (JavaFixing Admin)