컨트롤러 테스트 시 Unauthorized(401), Forbidden(403)이 발생하는 이유
스프링부트와 코틀린을 사용해서 API테스트 코드를 만들고있는데 계속 알 수 없는 이유로 403에러가 발생하기 시작했다. API에는 어떠한 검증 로직이 없었기 때문에 도무지 이해가 되지 않아서 헬스체크 api를 만들어서 해당 api의 테스트코드를 만들어보았다. 이번에는 401이 발생하기 시작했다. 뭐지??
이리저리 검색하다보니 원인은 스프링 시큐리티였다. 나는 컨트롤러만 테스트할 계획이었기 때문에 @WebMvcTest를 사용해서 테스트코드를 만들었는데 이 어노테이션과 스프링 시큐리티와의 상관성 때문에 발생한 문제였다.
먼저 내 코드를 살펴보자
@RestController
class JwtTokenController(
private val jwtTokenUtil: JwtTokenUtil
) {
@PostMapping("/generate-token")
fun generateToken(
@Valid @RequestBody dto: GenerateTokenReqDTO
): ResultDTO {
val token = jwtTokenUtil.generateToken(dto.accessId!!)
return Success(data = token)
}
}
class GenerateTokenReqDTO(
@field:NotBlank(message = "아이디를 입력해주세요")
val accessId: String?
)
@WebMvcTest(controllers = [JwtTokenController::class])
@ContextConfiguration(
classes = [JwtTokenController::class, JwtTokenUtil::class, CommonControllerAdvice::class]
)
class JwtTokenControllerTest : ExpectSpec() {
@Autowired
private lateinit var mockMvc: MockMvc
init {
context("JWT 토큰 API 테스트") {
expect("파라미터에 accessId가 없으면 실패해야 한다.") {
val param = GenerateTokenReqDTO(null)
val jsonParam = ObjectMapper().writeValueAsString(param)
mockMvc.perform(
post("/generate-token")
.content(jsonParam)
.contentType(MediaType.APPLICATION_JSON)
).andExpectAll(
status().isBadRequest,
jsonPath("$.message").value("아이디를 입력해주세요")
)
}
}
}
}
해당 테스트코드는 파라미터 검증을 위한 테스트코드이다. accessId가 없으면 @Valid에 의해 예외를 발생시키는 테스트이다.
실 테스트는 당연히 성공했고 코드에서도 이상한 점을 찾을 수가 없는데 이상하게 계속 403에러가 발생하고 있었다. 조금 더 자세하게 확인하기 위해서 andDo()메소드를 사용해서 어떤 결과를 보내고있는지를 살펴봤다.
...
MockHttpServletResponse:
Status = 403
Error message = Forbidden
Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
Content type = null
Body =
Forwarded URL = null
Redirected URL = null
Cookies = []
...
위와 같은 결과를 발생시키고 있었다. 코드 그 어디에도 검증코드가 없었기에 그저 당황스러울 뿐이었다.
뭔가 알 수 없는 이유가 있을까 싶어서 헬스체크만 하는 아주 단순한 api를 만들어서 테스트코드를 작성해보았다.
@RestController
class HealthCheckController {
@GetMapping("/health-check")
fun healthCheck(): String {
return "OK"
}
}
@WebMvcTest(controllers = [HealthCheckController::class])
@ContextConfiguration(classes = [HealthCheckController::class])
class HealthCheckControllerTest : ExpectSpec() {
@Autowired
private lateinit var mockMvc: MockMvc
init {
context("살아있는지 체크한다.") {
expect("OK가 반환되어야 한다.") {
mockMvc.perform(
get("/health-check")
).andExpectAll(
status().isOk,
content().string("OK")
)
}
}
}
}
정말 아무것도 없는 단순한 코드이다. 결과는
MockHttpServletResponse:
Status = 401
Error message = Unauthorized
Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", WWW-Authenticate:"Basic realm="Realm"", X-Content-Type-Options:"nosniff", X-XSS-Protection:"0", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
Content type = null
Body =
Forwarded URL = null
Redirected URL = null
Cookies = []
이번에는 401이 발생하고 있었다. 흠..?
이건 정말 말이 안되기 때문에 곰곰히 생각해봤는데 내 코드중에 인증,인가와 관련된 코드가 스프링 시큐리티가 있었다. 하지만 스프링 시큐리티를 적극적으로 활용하고있는것도 아니었고 단순히 패스워드 인코딩과 로그아웃을 위해서만 사용하고 있었기 때문에 다른 기능은 모두 비활성화 해놓은 상태였다. 그래서 조금 더 찾아본 결과 정답은 @WebMvcTest에 있었다.
@WebMvcTest는 가볍게 테스트를 하기위해 사용하는 어노테이션이므로 스프링의 모든 빈을 활성화시키지 않고 @Controller, @RestController와 같은 어노테이션이 선언된 빈만 활성화 시킨다. 그렇다보니 시큐리티 관련 설정을 해놓은 클래스도 빈으로 등록되지 않는것이다. 그렇다고 스프링 시큐리티가 비활성화 되는것은 아니다. 의존성은 선언되어있기 때문에 아무 설정도 하지 않았을 때의 기본설정으로 시큐리티가 설정되서 사용되는 것이다.
시큐리티의 기본설정은 모든 요청에 대해 권한을 가져야하기 때문에 테스트코드에서 어떤 요청도 해주지 않은 위의 코드들은 실패할 수 밖에 없는것이다. 조금 더 정확하게 하자면 401상태는 권한때문에 발생하는 문제고 403상태는 CSRF때문에 발생하는 문제였다.
문제를 알았으니 해결방법은 간단하다. 테스트코드에 권한과 CSRF관련된 코드를 함께 넣어주거나 내가 선언한 스프링 시큐리티 코드를 빈으로 띄울 수 있게 만들어주면 된다.
1. 내가 선언한 스프링 시큐리티 코드 등록하기
이건 아주 간단하다. @ContextConfiguration 어노테이션에 시큐리티 설정 클래스만 추가해주면 된다.
2. 권한과 CSRF코드 함께 넣어주기
해당 작업을 하기 위해서는 코드안에 with을 사용해서 SecurityMockMvcRequestPostProcessors를 추가해주면 된다.
mockMvc.perform(
get("/health-check")
.with(SecurityMockMvcRequestPostProcessors.user("user").roles("USER"))
.with(SecurityMockMvcRequestPostProcessors.csrf())
).andExpectAll(
status().isOk,
content().string("OK")
)
이렇게 해주면 시큐리티가 나를 통과시켜준다.
해당 코드보다 조금 더 간단하게 하는 방법이 있다. 먼저 시큐리티 테스트 의존성을 넣어주어야 한다.
testImplementation("org.springframework.security:spring-security-test")
해당 의존성을 넣으면 시큐리티와 관련된 어노테이션을 사용할 수 있다.
이제 권한과 관련된 코드는 @WithMockUser어노테이션을 사용해서 해결할 수 있다. 이 어노테이션 내부에 들어가면 권한과 관련된 코드들이 작성되있으므로 내가 별도로 코드를 작성해주지 않아도 된다.
csrf관련해서는
.with(csrf())
를 추가해주면 된다. 어노테이션은 따로 없고 간단하게 이렇게만 선언해주면 된다.
결론적으로 완성된 코드를 확인해보면
@WebMvcTest(controllers = [JwtTokenController::class])
@ContextConfiguration(
classes = [JwtTokenController::class, JwtTokenUtil::class, CommonControllerAdvice::class]
)
@WithMockUser
class JwtTokenControllerTest : ExpectSpec() {
@Autowired
private lateinit var mockMvc: MockMvc
init {
context("JWT 토큰 API 테스트") {
expect("파라미터에 accessId가 없으면 실패해야 한다.") {
val param = GenerateTokenReqDTO(null)
val jsonParam = ObjectMapper().writeValueAsString(param)
mockMvc.perform(
post("/generate-token")
.content(jsonParam)
.contentType(MediaType.APPLICATION_JSON)
.with(csrf())
).andExpectAll(
status().isBadRequest,
jsonPath("$.message").value("아이디를 입력해주세요")
)
}
}
}
}
이렇게 될것이다. 테스트는 아주 잘 통과한다.
앞에서 어떤 테스트는 401이 발생하고 어떤 테스트는 403이 발생하였다. 그 이유를 설명하지 않았는데 바로 get요청과 post요청의 차이이다.
get요청은 csrf를 검증하지 않는다. 그렇기 때문에 401이 발생하는 것이었고 post요청은 csrf를 검증하기 때문에 403이 발생하는 것이었다.
'테스트코드' 카테고리의 다른 글
Mockito를 사용한 단위테스트 (0) | 2022.05.31 |
---|