@WithMockUser
restDocs 를 뽑기 위해 통합 테스트 코드를 작성하던 중 시큐리티 관련한 설정이 필요해졌다.
docs를 찾아보니 @WithMockUser라는 아주 간단한 친구가 존재한다.
이 글에선 이에 대해 잠깐 언급한 후 @WithMockUser가 갖는 한계를 극복한 방법까지 논하겠다.
@WithMockUser는 언제 사용하는 게 좋고, 어떻게 사용하면 되는 걸까?
공식문서에 따르면 아래와 같이 말한다.
“특정 사용자로 테스트를 가장 쉽게 실행할 수 있는 방법은 무엇입니까?"입니다. 대답은 @WithMockUser을 사용하는 것입니다.
즉, security 단에서 아래와 같이 role을 검사하는 경우 정도에 사용할 수 있다.
SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
// .. 이하 생략
.requestMatchers("/docs/**").permitAll()
.requestMatchers("/agent/**", "/user/**").hasRole("ADMIN")
.anyRequest().hasAnyRole("USER", "ADMIN");
Java
복사
위와 같이 특정 api들은 admin 권한이나 user 권한을 가져야 할 때 테스트 코드에서 @WithMockUser 만 붙여주면 쉽게 이를 통과할 수 있다.
IntegrationTest
@AutoConfigureMockMvc
@AutoConfigureRestDocs
@SpringBootTest
@Transactional
@ActiveProfiles("police-test")
@WithMockUser(username = "MockKim", roles = {"USER"})
public abstract class IntegrationTest {
// .. 이하 생략
Java
복사
AgentIntegrationTest
public class AgentIntegrationTest extends IntegrationTest {
@Nested
@DisplayName("현장요원 추가하기")
@WithMockUser(username = "MockLee", roles = {"ADMIN"})
class SaveAgent {
@Test
@DisplayName("현장요원을 추가할 수 있다.")
void Should_SaveAgent_success() throws Exception {
// .. 이하 생략
Java
복사
참고로 나는, IntegratioTest엔 USER를 적용해줘서 모든 api가 USER 권한을 갖고 실행되게 해주었다.
추가적으로 ADMIN 권한이 필요한 api들만 WithMockUser 어노테이션을 한 번 더 붙여주어 IntegratioTest의 어노테이션을 덮어씌우게 해줬다.
이게 번거롭다면 각 테스트 메서드에 @WithMockUser(username = "MockLee", roles = {"USER"}) 와 같이 붙여주기만 해도 무관한다.
시큐리티 관련해서 설정해야 하는 건 그럼 이게 다 아닌가요? 라고 생각할 수 도 있지만, 다음과 같은 경우엔 어떻게 해야 할까?
바로, api 내부 로직에서 현재 로그인 한 유저가 누구인지 알 필요가 있을 때이다. 이 경우 보통 인가 필터 단에서 SecurityContextHolder에 넣어둔 authentication 객체를, 필요할 때 빼서 쓰는 방식으로 로직을 구현한다.
빼오는 방법은 우리는 좀 특이하게 UserUtils를 사용했지만, 뭐 어떤 분들은 컨트롤러에서 @AuthenticationPrincipal CustomUserDetails userDetails 꼴로 가져오기도 하지만, 가져오는 방식은 뭐든 상관 없다.
아래 코드와 같이 구성된 api를 테스트할 땐 @WithMockUser 가 아닌 @WithMockCustomUser 라는 친구를 사용해 주어야 한다.
DocumentService
@Service
@RequiredArgsConstructor
@Transactional
public class DocumentService {
private final DocumentRepository documentRepository;
private final DocumentMapper documentMapper;
private final UserUtils userUtils;
// 현재 로그인한 유저의 document를 찾아오는 로직
public DocumentDownloadResponse downloadDocument(DocumentType type) {
User user = userUtils.getCurrentUser(); // 이 부분이 핵심이다.
Document document = documentRepository.findByTypeAndUser(type, user);
return documentMapper.toDocumentDownloadOneResponse(document);
}
}
Java
복사
UserUtils
@Component
public class UserUtils {
public User getCurrentUser() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); // securityContextHolder에서 authentication을 가져옴
if (authentication != null && authentication.getPrincipal() instanceof PrincipalDetails) {
return (User) ((PrincipalDetails) authentication.getPrincipal()).getUser();
}
return null;
}
}
Java
복사
@WithMockCustomUser
이 친구는 그럼 뭘까? 이 역시 공식문서에서 한 문장 가져와보겠다.
우리는 @WithSecurityContext를 사용하여 원하는 모든 SecurityContext을 생성하는 자체 어노테이션을 만들 수 있습니다. 예를 들어 @WithMockCustomUser라는 어노테이션을 만들 수 있습니다.
아까보단 무슨 소리인지 잘 안 와 닿을 거 같다. 바로 코드로 보여주겠다.
WithMockCustomUser
어노테이션을 만드는 거다. WithMockUser 와 비슷하게 생겼는데, 이제 커스텀해서 사용할 어노테이션이다.
WithMockUser의 필드값과 마찬가지로 username과 role을 넣어주었다.
한 가지 다르게 구성한 점은 WithMockUser 어노테이션은 role 값을 String으로 받는 반면,
나는 내가 코드 상에서 사용하는 enum 을 사용했단 점인데, 이는 편한 대로 사용하면 된다.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
String username() default "Kim";
UserAuthority role() default UserAuthority.USER;
}
Java
복사
WithMockCustomUserSecurityContextFactory.java
우리가 인가 필터 단에서 해주던 역할을 대신 해주는 친구다.
인가 필터에서 해주던 역할은 토큰 값에 담긴 정보로 유저 정보를 구성하고 이를 인증 객체 형태로 SecurityContextHolder에 저장해두는 역할을 말한다.
테스트 시엔 토큰 값을 사용하진 않고, WithMockCustomUser 라는 어노테이션에 담긴 정보로 유저 정보를 구성하고 이를 인증 객체 형태로 SecurityContextHolder에 저장해두는 것이다.
public class WithMockCustomUserSecurityContextFactory
implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
User user = User.createEmptyUser(customUser.username(), customUser.role());
PrincipalDetails principalDetails = new PrincipalDetails(user);
Authentication authentication = new UsernamePasswordAuthenticationToken(
principalDetails, null, principalDetails.getAuthorities());
context.setAuthentication(authentication); // ContextHolder에 authentication 객체를 넣는다
return context;
}
}
Java
복사
새로 만든 어노테이션을 사용해보기 전에 간단히 테스트해보자.
public class SecurityTest extends IntegrationTest {
@Test
@WithMockCustomUser(username = "Lee", role = UserAuthority.ADMIN)
void 닉네임으로_구분해_원하는_유저를_컨텍스트에_담을_수_있다() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
PrincipalDetails principalDetails = (PrincipalDetails)authentication.getPrincipal();
assertThat(principalDetails.getUsername()).isEqualTo("Lee");
}
}
Java
복사
그럼, 이 새로 커스텀해 만든 어노테이션을 사용해보자.
DocumentIntegrationTest
public class DocumentIntegrationTest extends IntegrationTest {
@Nested
@DisplayName("문서 추가하기")
@WithMockCustomUser(username = "Kim", role = UserAuthority.USER)
class SaveDocument {
@Test
@DisplayName("문서를 추가할 수 있다.")
void Should_SaveSchedule_Success() throws Exception {
// .. 이하 생략
Java
복사
정말 매우 간단한다. 만들어둔 어노테이션만 붙여주면 끝이기 때문이다.
나는 WithMockUser와 WithMockCustomUser를 혼용해서 적재적소에 사용해주어 테스트 코드를 작성했다.
이 글을 읽는 분들께도 도움이 되었길 바란다.