SecurityContext를 활용한 기능에 대한 테스트 작성

HiroPark·2022년 9월 22일
0

Spring

목록 보기
7/11

SecurityContext를 통하여 얻어낸 유저 정보를 글 , 댓글과 연결하고, 이를 테스트하는 과정을 기록했습니다.

1. 글, 댓글을 Post할때, SecurityContext에서 유저를 뽑아내서, 그걸가지고 requestDto에 유저를 주입해줍니다.

(CommentService, PostsService, UserDetailService)

CommentService의 CommentSave 기능

    @Transactional
    public Long commentSave(CommentSaveRequestDto requestDto, Long id) {
        Posts post = postsRepository.findById(id)
                .orElseThrow(() -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다"));
        requestDto.setPosts(post);

        User user = userDetailService.returnUser();
        requestDto.setUser(user);

        return commentRepository.save(requestDto.toEntity()).getId();
    }

PostsService의 save 기능

    @Transactional
    public Long save(PostsSaveRequestDto requestDto) {
        User user = userDetailService.returnUser();
        requestDto.setUser(user);

        return postsRepository.save(requestDto.toEntity()).getId();
    }

UserDetailService의 returnUser 기능

    public User returnUser() {
        Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        String userName;

        if (principal instanceof UserDetails) {
            userName = ((UserDetails) principal).getUsername();
        } else {
            userName = principal.toString();
        }

        int start = userName.indexOf("email")+6;
        int end = userName.indexOf(".com,")+4;
        String email = userName.substring(start, end);

        User user = userRepository.findByEmail(email).orElse(null);

        return user;
    }
  • 시큐리티 컨텍스트에서 principal을 뽑아내서, UserDetail로 변환합니다.
  • 여기서 getUsername()을 통하여 유저에 대한 정보를 얻어내고, substring() 을 통해 얻어낸 이메일 정보를 통해 유저를 find합니다..

2. 이에 대한 테스트를 작성하려는데. 문제는, 테스트에 SecurityContext를 어떻게 적용해주냔거였습니다.(PostsApiControllerTest, CommentsApiControllerTest)

  • 기존에는 테스트에 @WithMockUser 애노테이션을 활용하고 있었는데, 이것만 가지고는 principal객체를 만들 수 없었습니다.

3. 공식문서를 통해 @WithMockCustomUser를 활용하면 된다는 것을 확인했습니다. 공식문서 참고

3-1. mock커스텀 유저를 지정하는 WithMockCustomUser 애노테이션을 생성

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class, setupBefore = TestExecutionEvent.TEST_EXECUTION)
public @interface WithMockCustomUser {
    String name() default "testName";

    String email() default "testEmail@naver.com";

    String role() default "USER";
}

3-2. 이 애노테이션은 @WithSecurityContext 애노테이션을 통해서 SecurityContextFactory를 공급받아야 합니다.

3-3. @WithSecurityContext에 컨텍스트를 제공해주기 위해 WithMockCustomUserSecurityContextFactory 클래스를 생성, 여기서 context를 리턴해주려합니다.

public class WithMockCustomUserSecurityContextFactory implements WithSecurityContextFactory<WithMockCustomUser> {
    @Override
    public SecurityContext createSecurityContext(WithMockCustomUser customUser) {
        SecurityContext context = SecurityContextHolder.createEmptyContext();

        CustomUserDetails principal = new CustomUserDetails();
        Authentication auth =
                new UsernamePasswordAuthenticationToken(principal, "password", principal.getAuthorities());
        context.setAuthentication(auth);
        return context;
    }
}

3-4. 근데 context를 만들기 위해서 getAuthorities() 메서드를 구현한 principal 객체가 필요합니다.

    CustomUserDetails principal = new CustomUserDetails();
  • 이부분

3-5. 그래서 getAuthorities()를 구현해야 하는 UserDetails인터페이스를 구현한 CustomUserDetails를 만들었습니다.

@Getter
class CustomUserDetails implements UserDetails {

    public  String name = "fakeUser";
    public String email = "1park5@naver.com";

    public Role role  = Role.USER;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        ArrayList<GrantedAuthority> auth = new ArrayList<GrantedAuthority>();
        auth.add(new SimpleGrantedAuthority((role.toString())));

        return auth;
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return "email="+email+",";
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }
}

3-6. getUsername() 메서드에서 이메일을 꺼내서 유저를 찾기 때문에, 이 메서드가 이메일을 리턴하게 합니다.

@Override
public String getUsername() {
    return "email="+email+",";
}

UserDetails로 형변환 principal이 email=이메일, 의 형태로 이메일을 리턴하기 때문에, 이와 구조를 맞춰주었습니다.

4. CustomUserDetails까지 완성됐으니 WithMockCustomUserSecurityContextFactory가 context를 리턴할 수 있고, 이거를 이제 WithMockCustomUser가 사용할 수 있게 됐습니다.

5. 이제 테스트에 @WithMockCustomUser 달면 되겠지?? -> 안됩니다.

    @Test
    @WithMockCustomUser
    public void posts_등록() throws Exception {
        //given
        String title = "postTitle";
        String content = "postContent";


        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .build();

        String url = "http://localhost:" + port + "api/v1/posts";

        //when



      mvc.perform(post(url)
               .contentType(MediaType.APPLICATION_JSON_UTF8)
                      .content(new ObjectMapper().writeValueAsString(requestDto))) 
              .andDo(print())
                              .andExpect(status().isOk());

        //then
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }

6. 왜 안돼지...? 고민하던 찰나에 200을 예상했는데 403이 뜨는게 뭔가 이상해서 곰곰히 생각해보았습니다.

java.lang.AssertionError: Status 
Expected :200
Actual   :403
<Click to see difference>

MockMvc를 사용하고 있었는데, andDo() 로 프린트 찍어보면

 Body = {"title":"postTitle","content":"postContent","user":null}

유저 자체도 null이 뜹니다.

7. 403이 뜨는 이유는 SecurityConfig에서 USER 롤이 아닌 사용자는 댓글, 글 등록에 대한 접근을 막아뒀기 때문입니다.

.antMatchers("/api/v1/**").hasRole(Role.USER.name())

8. 생각해보면 @WithMockCustomUser 가 아예 적용이 안되는건 아니었습니다.

      int start = userName.indexOf("email")+6;
        int end = userName.indexOf(".com,")+4;
        String email = userName.substring(start, end);

        System.out.println("이메일 : " + email);

이렇게 sout찍어가면서 어디까지 가다 막히는지 확인했는데, requestDto에 setUser하는 부분까지는 적용이 되다가, mvc.perform 부분에서 갑자기 403이 떴었습니다.

9. MockMvc로 post만 하려면 403이 뜬다..? 이건 MockMvc문제다 싶어서 MockMVC를 제거하고 직접 컨트롤러의 메서드를 호출하는 방식으로 테스트 변경.

    @Test
    @WithMockCustomUser
    public void posts_등록() throws Exception {
        //given
        String title = "postTitle";
        String content = "postContent";


        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .build();

//        String url = "http://localhost:" + port + "api/v1/posts";

        //when

        postsApiController.save(requestDto);

//        mvc.perform(post(url)
//                .contentType(MediaType.APPLICATION_JSON_UTF8)
//                        .content(new ObjectMapper().writeValueAsString(requestDto))) 
//                .andDo(print())
//                                .andExpect(status().isOk());

        //then
        List<Posts> all = postsRepository.findAll();
        assertThat(all.get(0).getTitle()).isEqualTo(title);
        assertThat(all.get(0).getContent()).isEqualTo(content);
    }

CommentsApiControllerTest도 변경해줍니다

@Test
    @WithMockCustomUser
    @Transactional // 프록시 객체에 실제 데이터를 불러올 수 있게 영속성 컨텍스트에서 관리
    public void comment_등록() throws Exception {
        // given
        String title = "title";
        String content = "content";
        User user = userRepository.save(User.builder()
                .name("name")
                .email("fake@naver.com")
                .picture("fakePic.com")
                .role(Role.USER)
                .build());

        PostsSaveRequestDto requestDto = PostsSaveRequestDto.builder()
                .title(title)
                .content(content)
                .user(user)
                .build();
        postsRepository.save(requestDto.toEntity());

        String comment = "comment";
        Posts posts = postsRepository.findAll().get(0);

        CommentSaveRequestDto saveRequestDto = CommentSaveRequestDto.builder()
                .comment(comment)
                .posts(posts)
                .build();

        Long id = posts.getId();

//        String url = "http://localhost:"+ port + "/api/v1/posts/" + id + "/comments";

        //when

        commentsApiController.save(saveRequestDto,id);

        //then

        assertThat(commentRepository.findAll().get(0).getComment()).isEqualTo("comment");

//        mvc.perform(post(url)
//                        .contentType(MediaType.APPLICATION_JSON_UTF8)
//                        .content(objectMapper.writeValueAsString(saveRequestDto)))
//                .andDo(print())
//                .andExpect(status().isOk());

    }

10. 이번엔 자꾸 user가 null이 뜹니다.

11. 다시한번 코드를 뜯어보니 테스트시에는 미리 db에 저장된 유저가 없다보니 UserDetailService에서 null을 리턴했기 때문입니다.

  • UserDetailService에서 유저를 찾는 코드

      User user = userRepository.findByEmail(email).orElse(null);

12. 테스트 시작전에 유저를 미리 만들어놓는 것으로 이를 해결

   @Before
    public void setUser() {
        User user = new User("fakeUser","1park5@naver.com","fakePic.com",Role.USER);
        // 유저가 있어야 UserDetailService의 returnUser가 유저 가져올 수 있음

        userRepository.deleteAll();
        userRepository.save(user);
    }

13. 원하던대로, SecurityContext를 통해 유저를 뽑아내서 기능을 수행하는데 성공

기존에 보고 따라하던 책에서 MockMvc사용하여 테스트를 진행해서 이를 활용해서 테스트를 작성하다보니, 이걸 사용하는데 있어서 경각심을 못느꼈습니다.

  • MockMVC를 사용한 이유는 , 기존에 사용하던 @WithMockUser(roles="USER") 애노테이션이 MockMvc에서만 작동하기 때문이었습니다.

MockMvc는 말그대로 테스트용(mock) mvc환경을 만들어서, 애플리케이션 서버에 배포하지 않고도 mvc동작을 재현할 수 있는 객체입니다.

그리고 이를 위해 @Before로 생성하던 MockMvc 인스턴스는

    @Before
    public void setup() {
        mvc = MockMvcBuilders
                .webAppContextSetup(context)
                .apply(springSecurity())
                .apply(sharedHttpSession())
                .build();
    }

SpringSecurityContext에 대해 알지못하고, 자체적인 mvc환경에서 테스트를 하다보니 계속 403(거절) 이 떴던듯 합니다.

더이상 @WithMockUser 를 사용하지 않으니, 인증에 대한 허가를 받을 수 없었던 것입니다.

profile
https://de-vlog.tistory.com/ 이사중입니다

0개의 댓글