오늘 겪은 문제를 정리해보려한다.
Spring Security + JWT + OAuth2 인증,인가를 사용하여
정상적으로 API 작동까지 Postman으로 마친 상태이다.

문제는 Rest Docs를 적용하기 위해
Test 코드를 작성중 Spring Security 문제와 부딪히게 된 것 !!


📌 문제 발생 (403 Forbidden)

기존과 동일하게 POST 요청을 보내 보았다.

    @Test
    @DisplayName("질문 작성 API : createQuestion")
    void createQuestion() throws Exception {

        QuestionPostDto questionPostDto = (QuestionPostDto) StubData.MockQuestion.getRequestBody(HttpMethod.POST);
        Question question = StubData.MockQuestion.getSingleResponseBody(1L);
        List<Tag> tagList = StubData.MockTag.getSingleResponseBody();
        Member member = StubData.MockMember.getSingleResponseBody(1L);

        given(tagService.findTags(Mockito.any(QuestionPostDto.class))).willReturn(tagList);
        given(memberService.findByMember(Mockito.anyLong())).willReturn(member);
        given(questionService.postQuestion(Mockito.any(Question.class))).willReturn(question);
        String content = toJsonContent(questionPostDto);
        ResultActions actions = mockMvc.perform(postRequestBuilder(getUrlCreateQuestion(), content));

        actions
                .andExpect(status().isOk());
    }

복잡해보일 수 있겠지만… 단순히 메서드화 시켜서
여러군대에서 범용성있게 사용하려고 나타낸 것이다.

중요한 부분은 mockMvc.perform();에서 Json 객체를 담고
Url을 만들어 보내준다. 내용을 살펴 보면

public interface ControllerTestHelper<T> {

    ...

    default RequestBuilder postRequestBuilder(String url,
                                              String content) {
        return post(url)
                .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON)
                .content(content);
    }
    ...
}

이렇게 단순히 인터페이스로 만들어 사용하고 있다.
URL 같은 경우는 다른 클래스에서 getUrlCreateQuestion()
메서드를 이용해 만들어 넘기도록 했다.

메세지는 아래와 같이 요청과 응답이 왔다.

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /questions/ask/post
       Parameters = {}
          Headers = [Content-Type:"application/json;charset=UTF-8", Accept:"application/json", Content-Length:"187"]
             Body = {"email":"dhfif718@gmail.com","questionTitle":"질문 제목 입니다.","questionProblemBody":"질문 내용 1","questionTryOrExpectingBody":"질문 내용 2","tag":[{"tagName":"java"}]}
    Session Attrs = {org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository.CSRF_TOKEN=org.springframework.security.web.csrf.DefaultCsrfToken@25f14e93}
MockHttpServletResponse:
           Status = 403
    Error message = Forbidden
          Headers = [X-Content-Type-Options:"nosniff", X-XSS-Protection:"1; mode=block", 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 = []

여기서 이제 403 Forbidden 문제가 발생했고…
어떻게 해결을 할지 찾아 보던 중 @WithMockUser어노테이션을
이용해 인증을 통과시켜 줄 수 있고, mockMvc.perform() 메서드에
post호출시 .with(csrf();를 붙여주면 csrf 토큰을 생성할 수 있다.


📌 @WithMockUser, with(csrf()); 설정

    @Test
    @WithMockUser
    @DisplayName("질문 작성 API : createQuestion")
    void createQuestion() throws Exception {

        QuestionPostDto questionPostDto = (QuestionPostDto) StubData.MockQuestion.getRequestBody(HttpMethod.POST);
        Question question = StubData.MockQuestion.getSingleResponseBody(1L);
        List<Tag> tagList = StubData.MockTag.getSingleResponseBody();
        Member member = StubData.MockMember.getSingleResponseBody(1L);

        given(tagService.findTags(Mockito.any(QuestionPostDto.class))).willReturn(tagList);
        given(memberService.findByMember(Mockito.anyLong())).willReturn(member);
        given(questionService.postQuestion(Mockito.any(Question.class))).willReturn(question);
        String content = toJsonContent(questionPostDto);
        ResultActions actions = mockMvc.perform(postRequestBuilder(getUrlCreateQuestion(), content));

        actions
                .andExpect(status().isOk());
    }
public interface ControllerTestHelper<T> {

    ...

    default RequestBuilder postRequestBuilder(String url,
                                              String content) {
        return post(url)
                .accept(MediaType.APPLICATION_JSON)
                .contentType(MediaType.APPLICATION_JSON)
                .content(content)
                .with(csrf());
    }
    ...
}

위에 클래스에서 @WithMockUser를 테스트 메서드에 붙여주고
mockMvc.perform()post().with(csrf());를 붙여 주었다.

테스트를 실행 시켜 보면
아래와 같은 요청 응답 메세지가 만들어진다.

MockHttpServletRequest:
      HTTP Method = POST
      Request URI = /questions/ask/post
       Parameters = {_csrf=[b9be6017-6b52-464b-b5e1-6f480569099b]}
          Headers = [Content-Type:"application/json;charset=UTF-8", Accept:"application/json", Content-Length:"187"]
             Body = {"email":"dhfif718@gmail.com","questionTitle":"질문 제목 입니다.","questionProblemBody":"질문 내용 1","questionTryOrExpectingBody":"질문 내용 2","tag":[{"tagName":"java"}]}
    Session Attrs = {SPRING_SECURITY_CONTEXT=SecurityContextImpl [Authentication=UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=user, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]], Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER]]]}

Parameters를 확인해보면 csrf로 토큰이 생긴것을 확인할 수 있다.
설정하지 않고 하였을때는 위에의 Parameters를 보면 알 수 있듯이
비어있는 모습을 볼 수 있다.

하지만 여기서 또 하나의 문제가 발생한다.
UserDetails의 설정이 되지 않아

java.lang.ClassCastException

클래스 타입 관련 에러가 발생했고…

MockHttpServletResponse:
           Status = 500
    Error message = null
          Headers = [Vary:"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers", Content-Type:"application/json", X-Content-Type-Options:"nosniff", X-XSS-Protection:"1; mode=block", Cache-Control:"no-cache, no-store, max-age=0, must-revalidate", Pragma:"no-cache", Expires:"0", X-Frame-Options:"DENY"]
     Content type = application/json
             Body = {"status":500,"message":"Internal Server Error","fieldErrors":null,"violationErrors":null}
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

응답 메세지에는 Error Handler로 작업한
Body가 담겨져 500번 상태코드로 오류가 발생한 것을 볼 수있다..


📌 왜 예외가 발생했을까?

UserDetails를 테스트 코드 패키지에 설정하지 않으면

java.lang.ClassCastException: class org.springframework.security.core.userdetails.User cannot be cast to class com.example.stackoverflowclone.global.security.auth.dto.TokenPrincipalDto (org.springframework.security.core.userdetails.User and com.example.stackoverflowclone.global.security.auth.dto.TokenPrincipalDto are in unnamed module of loader 'app')

위와 같은 에러가 발생할 수 있다. 그 이유는
기존에 시큐리티 프로그램을 작성할 때

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberIdResolver());
        resolvers.add(new LoginMemberEmailResolver());
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry
                .addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("*");
    }
}

WebMvcConfigurer를 상속받아 addArgumentResolvers()
오버라이딩해 어노테이이션을 만들어 구현해주었었다.

public class LoginMemberIdResolver implements HandlerMethodArgumentResolver {

    ...
    
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();

        if (principal == "anonymousUser") {
            return -1L;
        }

        TokenPrincipalDto castedPrincipal = (TokenPrincipalDto) principal; // <---- ClassCast 예외 발생 지점

        return castedPrincipal.getId();
    }
}

해당 클래스에서 예외 발생지점은 위에 주석처리한 부분이다.
Test 코드를 실행할 경우에
기본적으로 User 객체가 담겨져서 오게된다.

org.springframework.security.core.userdetails.User [Username=user, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, credentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]]

와 같은 형태로 객체가 출력되었다.

나는 TokenPrincipalDto라는 클래스를 만들어

@Getter
@Setter
@AllArgsConstructor
public class TokenPrincipalDto {
    private long id;
    private String email;
}
Authentication authentication = new UsernamePasswordAuthenticationToken(new TokenPrincipalDto(id, email), null, authorities);

과 같은 방식으로 Principal을 만들어 주었는데
테스트 코드에서는 해당 부분이 설정되어 있지 않아 발생하는
ClassCastException 타입변환 예외 였다.


📌 Test 코드 작성시 UserDetails 하기

그렇다면 테스트 환경에서도 동일하게
Principal에 TokenPrincipalDto객체를 만들어 넣어주면 된다.

만드는 방법을 차근차근 알아보자

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

}

우선 어노테이션을 만들어 커스터 마이징해줬다.
우선 username에 대한 정보를 강제로 입력해 두었다.

public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithMockCustomUser> {
    @Override
    public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        List<GrantedAuthority> grantedAuthorities = new ArrayList();
        grantedAuthorities.add(new SimpleGrantedAuthority("USER"));
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                new TokenPrincipalDto(1L , customUser.username()), null, grantedAuthorities);
        authentication.setDetails(customUser.username());
        context.setAuthentication(authentication);
        return context;
    }
}

WithSecurityContextFactory를 상속받아
createSecurityContext();를 구현해준다음에

우리가 테스트하려고 했던 메서드에

    @Test
    @DisplayName("질문 작성 API : createQuestion")
    @WithMockCustomUser
//    @WithMockUser
    void createQuestion() throws Exception {

        QuestionPostDto questionPostDto = (QuestionPostDto) StubData.MockQuestion.getRequestBody(HttpMethod.POST);
        Question question = StubData.MockQuestion.getSingleResponseBody(1L);
        List<Tag> tagList = StubData.MockTag.getSingleResponseBody();
        Member member = StubData.MockMember.getSingleResponseBody(1L);

        log.info("questionPostDto = {}", questionPostDto);

        given(tagService.findTags(Mockito.any(QuestionPostDto.class))).willReturn(tagList);
        given(memberService.findByMember(Mockito.anyLong())).willReturn(member);
        given(questionService.postQuestion(Mockito.any(Question.class))).willReturn(question);
        String content = toJsonContent(questionPostDto);
        ResultActions actions = mockMvc.perform(postRequestBuilder(getUrlCreateQuestion(), content));

        actions
                .andExpect(status().isOk());
    }

기존에 쓰던 @WithMockUser를 주석처리해주고, 커스터마이징해서 만든
어노테이션은 @WithMockCustomUser를 테스트 메서드에 붙여주면
테스트가 정상적으로 진행되는 모습을 볼 수 있을 것이고

MockHttpServletRequest 요청에
Session Attrs에 아래와 같이 우리가 만든
TokenPrincipalDto 객체로 만들어진 모습을 볼 수 있다.

Session Attrs = {
        SPRING_SECURITY_CONTEXT=SecurityContextImpl 
        [Authentication=UsernamePasswordAuthenticationToken 
            [Principal=com.example.stackoverflowclone.global.security.auth.dto.TokenPrincipalDto@3dd591b9, Credentials=[PROTECTED],Authenticated=true, Details=dhfif718@naver.com, Granted Authorities=[USER]]
        ]
}




Test코드 작성시 UserDetails 참고 레퍼런스, csrf 설정 참고 레퍼런스