오늘의 문제점
테스트 코드를 작성할 때 Security에서 제공하는 @WithMockUser 어노테이션을 사용해서 인증 테스트를 수행했습니다.
하지만 Authentication의 getPrincipal() 메소드를 사용할 때 null이 반환되는 현상이 발생해서 테스트를 통과 하지 못했습니다.
기존의 방식
@GetMapping("/mycomments")
public Response<Page<CommentResponse>> getMyComments(Authentication authentication, Pageable pageable) {
String username = authentication.getName()
Page<CommentResponse> response = postService.getMyComments(username, pageable).map(CommentResponse::fromComment);
return Response.success(response);
}
authentication 클래스에서 getName()을 통해 서비스단에 유저의 이름을 전달하는 로직이었습니다.
테스트 코드
@Test
@WithMockUser(username = "username")
@DisplayName("내 댓글 조회 성공")
void 내_댓글_조회_성공() throws Exception {
//Given
String username = "username";
//When
when(postService.getMyComments(eq(username), any(Pageable.class))).thenReturn(Page.empty());
//Then
mvc.perform(get("/api/v1/post/mycomments"))
.andExpect(status().isOk());
}
기존의 authentication.getName() 메소드를 사용할 때는 정상적으로 통과하는 것을 볼 수 있습니다.
변경 코드
@GetMapping("/mycomments")
public Response<Page<CommentResponse>> getMyComments(Authentication authentication, Pageable pageable) {
Member member = getSafeCastInstance(authentication);
Page<CommentResponse> response = postService.getMyComments(member.getName(), pageable).map(CommentResponse::fromComment);
return Response.success(response);
}
getSafeCastInstance메소드에서 authentication.getPrincipal() 메소드를 이용해서 사용자 객체를 추출하는 방식으로 변경했습니다.
테스트 결과
테스트가 실패했습니다. 결과를 보면 Cating이 실패 된 것을 볼 수 있습니다.
원인 찾기
우선 어떤 방식으로 인증 객체가 생성되는지 확인할 필요가 있었습니다.
@WithMockUser 클래스 확인
이 클래스의 @WithSecurityContext를 통해 WithMockUserSecurityContextFactory.class
클래스를 이용해 인증 설정을 하는것을 확인할 수 있습니다.
그러면 이번에는 factory 클래스를 한번 확인해 보겠습니다.
WithMockUserSecurityContextFactory.class 확인
final class WithMockUserSecurityContextFactory implements WithSecurityContextFactory<WithMockUser> {
private SecurityContextHolderStrategy securityContextHolderStrategy = SecurityContextHolder
.getContextHolderStrategy();
@Override
public SecurityContext createSecurityContext(WithMockUser withUser) {
String username = StringUtils.hasLength(withUser.username()) ? withUser.username() : withUser.value();
Assert.notNull(username, () -> withUser + " cannot have null username on both username and value properties");
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
for (String authority : withUser.authorities()) {
grantedAuthorities.add(new SimpleGrantedAuthority(authority));
}
if (grantedAuthorities.isEmpty()) {
for (String role : withUser.roles()) {
Assert.isTrue(!role.startsWith("ROLE_"), () -> "roles cannot start with ROLE_ Got " + role);
grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + role));
}
}
else if (!(withUser.roles().length == 1 && "USER".equals(withUser.roles()[0]))) {
throw new IllegalStateException("You cannot define roles attribute " + Arrays.asList(withUser.roles())
+ " with authorities attribute " + Arrays.asList(withUser.authorities()));
}
User principal = new User(username, withUser.password(), true, true, true, true, grantedAuthorities);
Authentication authentication = UsernamePasswordAuthenticationToken.authenticated(principal,
principal.getPassword(), principal.getAuthorities());
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authentication);
return context;
},
대략적으로 확인해보면
- 어노테이션에서 설정해주는 username, password, role 등 다양한 역할을 적용해 줍니다.
- User 객체에 설정해주는 인증 정보들을 넣어 줍니다.
- UsernamePasswordAuthenticationToken 방식의 authentication 에 객체, 비밀번호, 권한을 설정해줘 Authentication을 생성합니다.
- Security 프레임 워크에서 해당 객체를 인식할 수 있게 setAuthentication 해줍니다.
분석한 코드에서 문제점 발견
제가 사용하고 있는 객체 클래스는 Member.class 이지만 @WithmockUser에서 사용하고 있는 클래스는 User.class 였습니다.
해결방법
User.class를 이용한 테스트가 아닌 Member.class를 이용한 테스트 어노테이션을 만들기로 했습니다.
- WithCustomMember 어노테이션 만들기
- WithCustomMember 동작을 하는 Factory 생성하기
- WithCustomAnonymouse 어노테이션 만들기
- WithCustomAnonymouse 동작을 하는 Factory 생성하기
이렇게 Member 클래스로 테스트 할 수 있는 어노테이션과 익명의 방문자로 테스트할 수 있는 어노테이션 2개를 만들기로 했습니다.
WithCustom Meber 클래스
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomSecurityContextFactory.class)
public @interface WithMockCustomMember {
String username = "username";
String password = "password";
}
RetentionPolicy를 통해 실행중에 이 어노테이션이 적용될 수 있게 설정해주었습니다.
@WithSecurityContext에서는 제가 Custom으로 만든 Factory를 적용해 줄 겁니다.
WithMockCustomSecurityContextFactory 클래스
public class WithMockCustomSecurityContextFactory implements WithSecurityContextFactory<WithMockCustomMember> {
@Override
public SecurityContext createSecurityContext(WithMockCustomMember annotation) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
Member member = Member.fromEntity(EntityFixture.of());
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(member, null, member.getAuthorities());
context.setAuthentication(usernamePasswordAuthenticationToken);
return context;
}
}
- 인증 검사를 하기위한 SecurityContext를 생성합니다.
- 인증 객체에 사용할 Member객체를 생성해 줍니다.
- UsernamePasswordAuthenticationToken 방식으로 인증 정보를 생성해주고
- context에 적용해줍니다.
이렇게 하고 적용된 테스트 코드를 한번 확인해 보겠습니다.
변경된 테스트 코드
@Test
@WithMockCustomMember
@DisplayName("내 댓글 조회 성공")
void 내_댓글_조회_성공() throws Exception {
//Given
String username = "username";
//When
when(postService.getMyComments(eq(username), any(Pageable.class))).thenReturn(Page.empty());
//Then
mvc.perform(get("/api/v1/post/mycomments"))
.andExpect(status().isOk());
}
성공적으로 테스트 코드가 통과하는것을 확인할 수 있습니다👏👏👏👏👏
WIthCustomAnonymouse 코드는 @WithAnonymouse 어노테이션 코드를 참고하면서 만들었습니다. @WithCustomMember 코드를 작성하는것과 많이 유사합니다.
WithMockCustomAnonymouseSecurityContextFactory
public class WithMockCustomAnonymouseSecurityContextFactory implements WithSecurityContextFactory<WithCustomAnonymouse> {
@Override
public SecurityContext createSecurityContext(WithCustomAnonymouse annotation) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
List<GrantedAuthority> authorities = AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS");
AnonymousAuthenticationToken anonymouseToken = new AnonymousAuthenticationToken("key","anonymouse", authorities);
context.setAuthentication(anonymouseToken);
return context;
}
}
AnonymouseAuthenticationToken 객체를 이용해서 인증 객체를 만들었습니다.
이제 새로 생성된 @WithCustomAnonymouse, @WithCustomMember 어노테이션을 필요한 장소에 붙혀주었습니다.
마지막으로 모든 테스트 코드를 테스트하고 마무리 하겠습니다.
자세한 코드를 보고싶으시다면 깃허브를 참고해주세요!!
Github Repository
긴글 봐주셔서 감사합니다. (_ _)
'SpringBoot' 카테고리의 다른 글
주문내역을 내려줄 때 가격 멱등성에 대한 고민 (0) | 2023.10.29 |
---|---|
프레임워크, 라이브러리의 차이 (0) | 2023.07.19 |
[Spring Boot] Redis 캐싱 서버 적용하기 (0) | 2023.05.01 |
[SpringBoot] Transaction 커밋 적용 (0) | 2023.04.20 |
[SpringBoot] 스프링부트 터미널 build 실행 오류 (1) | 2023.04.04 |