20241114 TIL : 테스트에서의 Spring Security

MCS·2024년 11월 14일

TIL

목록 보기
4/45

오늘 진행한 학습

  • 테스트에서의 Spring Security
    • Controller 테스트 코드 작성
    • 트러블슈팅
    • @TestConfiguration

Spring Security의 테스트

Controller 테스트 코드 작성

UserController에 대한 테스트 코드를 작성했다.

    @Test
    @DisplayName("일반 사용자 회원가입")
    void signup() throws Exception {
        // given
        SignupRequestDto requestDto = SignupRequestDto.builder()
                .username("test")
                .password("password")
                .email("test@email.com")
                .phoneNumber("01012345678")
                .role(UserRoleEnum.CUSTOMER)
                .imgUrl(null)
                .build();

        // when
        when(userService.signup(any(SignupRequestDto.class), eq(null)))
                .thenReturn(new UsernameResponseDto("test"));

        // then
        mvc.perform(post(BASE_URL + "/users/signup")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(mapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.username").value("test"));
    }

일반 사용자로의 회원가입을 진행했고, isCreated(201) 상태가 return 될 것으로 .andExpect를 통해 예측했다. 그런데 메서드를 실행했을 때 401 에러가 발생했다.

트러블슈팅

처음에는 401이 발생한 이유를 전혀 알지 못했다. /users/signup URI로의 요청은 WebSecurityConfig에서 인증되지 않은 사용자만 접근 가능하도록 허용해놨기 때문에 절대 401이 발생해서는 안 됐다.

  • 첫 번째 트러블슈팅
    팀원분께서 @MockUser라는 어노테이션을 만들어주셨다.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = MockSecurityContextFactory.class)
public @interface MockUser {
    String username() default "user1";
    String email() default "email1@email.com";
    String password() default "password";
    String phoneNumber() default "01011110000";
    UserRoleEnum role() default UserRoleEnum.CUSTOMER;
    boolean publicProfile() default true;

}
public class MockSecurityContextFactory implements WithSecurityContextFactory<MockUser> {
    @Override
    public SecurityContext createSecurityContext(MockUser annotation) {
        User user = User.builder()
                .username(annotation.username())
                .email(annotation.email())
                .password(annotation.password())
                .phoneNumber(annotation.phoneNumber())
                .role(annotation.role())
                .build();

        UserDetails userDetails = new UserDetailsImpl(user);

        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
                userDetails, null, userDetails.getAuthorities());
        authentication.setDetails(userDetails);

        SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
        securityContext.setAuthentication(authentication);
        return securityContext;
    }
}

그래서 @WithMockUser 대신에 이 어노테이션을 사용할 수 있었다. 일단 401이 발생하는 문제 때문에 @MockUser를 추가했다.

	@Test
    @DisplayName("일반 사용자 회원가입")
    @MockUser
    void signup() throws Exception { ...

어노테이션을 추가한 결과 201이 나올 것으로 기대했지만, 403이 발생했다.

두 번째 트러블슈팅

로그를 확인해 보니, csrf 문제로 403 에러가 발생하는 것을 확인했다. 그런데 분명히 WebSecurityConfig에서 csrf를 disable 시켜 놨는데, 왜 이런 문제가 발생하는지 이해할 수 없었다.
일단 당장 문제 해결을 위해서 with(csrf())를 추가했다.

// then
        mvc.perform(post(BASE_URL + "/users/signup")
        				.with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(mapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.username").value("test"));

.with(csrf())를 추가한 결과 테스트가 통과하는 것을 확인했다.

세 번째 트러블슈팅

@Secured로 설정한 컨트롤러 메서드에 대해 role이 맞지 않는, 즉 권한이 부족해 실패하는 테스트를 작성했다.

	@Test
    @DisplayName("관리자 회원가입 실패 - 권한 부족")
    @MockUser(role = UserRoleEnum.CUSTOMER)
    void signupManagerByNotAdmin() throws Exception {
        // given
        SignupRequestDto requestDto = SignupRequestDto.builder()
                .username("test")
                .password("password")
                .email("test@email.com")
                .phoneNumber("01012345678")
                .role(UserRoleEnum.MANAGER)
                .imgUrl(null)
                .build();

        // when - then
        mvc.perform(post(BASE_URL + "/users/signup/master")
        				.with(csrf())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(mapper.writeValueAsString(requestDto)))
                .andDo(print())
                .andExpect(status().isForbidden())
                .andExpect(jsonPath("$.errorMessage").value(ExceptionMessage.NOT_ALLOWED_API.getMessage()));
    }

그런데 실패가 아닌 201 Created가 발생하는 것을 확인했다. 분명히 @MockUser로 role까지 지정해 줬는데, 201이 발생한다는 건 이상했다.
메인 메서드를 실행하고 postman으로 테스트 해봤을때는 정상적으로 403이 발생했다. 테스트 환경에서만 오류가 나고 있다.
일단 로그를 찍어 보았다.

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    log.info("Authentication: " + authentication.getAuthorities());

INFO 18848 --- [ main] c.s.b.controller.UserControllerTest : Authentication: [ROLE_CUSTOMER]

MockUser로 설정된 User의 role은 분명히 CUSTOMER였다. 그럼 도대체 무엇이 문제일까?

GPT를 통해 @Import(WebSecurityConfig.class)를 테스트 클래스에 추가해보라는 답을 받았다. 하지만 WebSecurityCofig는 다른 Component들에 의존성을 가지고 있기 때문에 이렇게 가져오는 것은 불가능하다. 그렇다고 컴포넌트들을 모두 추가하는 것은 테스트 코드를 작성하는 의미가 없다고 생각했다.

@TestConfiguration

결론을 찾아 냈다. 테스트용 SecurityConfig 클래스를 구현하고, @TestConfiguration 어노테이션을 달아 주어야 한다.

현재 테스트가 실행되면 Spring Security가 WebSecurityConfig를 받아올 수는 없다. 그렇기 때문에 기본 SecurityConfig를 생성하게 된다.

기본 설정의 특징

  • 모든 요청에 인증 필요 (anyRequest().authenticated())
  • form 로그인 활성화
  • HTTP Basic 인증 활성화
  • CSRF 보호 활성화

모든 요청에 인증이 필요하기 때문에 회원가입 테스트에서 @MockUser가 없으면 401이 발생했던 것이고, csrf가 활성화되어 있기 때문에 csrf 에러가 발생했던 것이었다! @Secured가 동작하지 않았던 이유도, @EnableMethodSecurity(securedEnabled = true)가 SecurityConfig에 지정되어 있어야 하는데 기본 설정에는 이것이 지정되어 있지 않기 때문이었다.

그래서 다음과 같은 TestSecuriyConfig 클래스를 작성했다.

@TestConfiguration
@EnableMethodSecurity(securedEnabled = true)
public class TestSecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf((csrf) -> csrf.disable())
                .sessionManagement((sessionManagement) ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .authorizeHttpRequests((authorizeHttpRequests) ->
                        authorizeHttpRequests
                                .requestMatchers("/api/v1/users/signup").anonymous()
                                .anyRequest().authenticated()
                );

        return http.build();
    }
}

또한 모든 with(csrf())를 제거하고, 일반 사용자 회원가입 테스트에서 @MockUser를 제거했다. 그리고 모든 테스트가 통과하는 것을 확인했다.

이제 테스트에서 Security를 다루는 법을 어느 정도 알게 되었다. 테스트 코드를 작성하는 데 있어 한결 수월해 질 것 같다.

profile
백엔드를 잘 하고 싶은 사람

0개의 댓글