OAuth의 로그인 과정을 테스트 하는 것은 어렵다.
이는 외부 OAuth 서버와의 통신이 필요하고 우리의 서비스 로직에서 컨트롤할 수 있는 영역이 아니기 때문이다.
그렇다면 이러한 영역을 어떻게 테스트하면 좋을지 고민하다가, 한가지 해결 방법을 도출했다.
OAuth가 담당하는 부분을 Mocking하면 된다. 외부 서버와의 의존성을 없애기 위해 당연한 일이다.
하지만 무엇을 Mocking해야 하는 것일까?
OAuth 서버를 모킹하면 되겠다. -> 근데 OAuth 서버를 어떻게 Mocking하지? -> 너무 어렵다..
이러한 고민을 계속 반복했던 것 같다.
그래서 필자는, Spring Security가 OAuth를 어떻게 처리하는지 내부 과정을 토대로 테스트 코드를 작성하였다.
OAuth를 구현하기 위해 Spring Security를 사용한다는 것은 @EnalbeWebSecurity
와 @Configuration
이 붙은 SecurityConfig 정보에서 FilterChain에 다음과 같이 oauth2Login옵션을 설정해줌으로써 시작된다.
HttpSecurity.oauth2Login(oauth2 -> oauth2
.clientRegistrationRepository(clientRegistrationRepository)
.userInfoEndpoint(it -> it.userService(oAuthService))
.successHandler(authenticationSuccessHandler)
.failureHandler(oAuthAuthenticationFailureHandler))
필자는 위와 같이 설정했다. 순서대로 설명하면 다음과 같다.
ClientRegistrationRepository에서 OAuth관련 Config 정보들을 설정한다.
OAuth서버 (인가 서버)가 Config 정보를 토대로 인가된 정보를 EndPoint로 내려준다. 여기에서 OAuth2UserRequest
객체에 이를 포함시켜준다. Spring Security에서 제공하는 OAuth관련 Service 객체의 구현체가 이러한 객체를 인자로 받게된다.
해당 Service 구현체에서 반환한 Authentication 정보가 유효한 정보인지 확인하고 성공하면 SuccessHandler로,
실패하면 FailureHandler로 넘긴다.
그렇다면 mocking해야할 부분은 어디일까? oAuthService부터이다. 그 이후부터는 우리 서비스의 로직으로 컨트롤하는 부분이기 때문이다.
실제 구현 코드를 보자.
@Slf4j
@Service
@RequiredArgsConstructor
public class OAuthService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final MemberRepository memberRepository;
@Override
@Transactional
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
String email = oAuth2User.getAttribute("email");
return getCustomUserDetails(oAuth2User, email);
}
@NotNull
public CustomUserDetails getCustomUserDetails(OAuth2User oAuth2User, String email) {
String nickname = UUID.randomUUID().toString().substring(0,15);
if(memberRepository.findNonSocialMemberByEmail(email).isPresent()){
return exceptionHandlingUserDetails(oAuth2User);
}
Optional<SocialMember> socialMember = memberRepository.findSocialMemberByEmail(email);
if(socialMember.isEmpty()){
SocialMember savedSocialMember = SocialMember.createSocialMember(email, nickname);
SaveMemberResponseDto savedResponse = memberRepository.save(savedSocialMember);
String roles = Role.addRole(Role.getIncludingRoles(savedResponse.getRole()), Role.OAUTH_FIRST_JOIN);// 최초 회원가입을 위한 임시 role 추가
return new CustomUserDetails(String.valueOf(savedResponse.getId()), roles, oAuth2User.getAttributes());
}
else{
return new CustomUserDetails(String.valueOf(socialMember.get().getUserId()), Role.getIncludingRoles(socialMember.get().getRole()), oAuth2User.getAttributes());
}
}
@NotNull
private static CustomUserDetails exceptionHandlingUserDetails(OAuth2User oAuth2User) {
return new CustomUserDetails("0", Role.EXCEPTION.getRoles(), oAuth2User.getAttributes());
}
}
여기에서 OAuth 서버에서 받아온 OAuth2UserRequest
로부터 OAuth2UserService
객체의 loadUser
메서드로 인증 객체 OAuth2User
를 생성한다.
그 후, 이 객체에 우리 서비스의 로직이 담긴 getCustomUserDetails
메서드로 인증 객체의 정보를 수정하고 이를 반환한다.
그렇다면 여기서, Mocking할수 있는 것은 OAuth2UserRequest
일 것이다. 해당 객체를 Mocking하고 우리 서비스에서 유효한 CustomUserDetails가 반환되는지 확인하면 될 것이다.
거기까진 OK이다. 하지만, 인증 후의 이루어지는 과정 또한 테스트가 되어야, OAuth 로그인의 로직이 완벽히 테스트 될 수 있을 것이라고 생각했다.
우리 서비스에서는, OAuth를 통한 회원 가입과, OAuth를 통한 로그인 두가지 케이스에 따라 Redirection URL을 따로 두었다. (물론 둘다 AccessToken과 RefreshToken을 반환한다.)
그래서 마지막 성공 후 로직을 검증하기 위해서는 AuthenticationSuccessHandler가 잘 동작하는지 확인해야 한다.
@Component
@RequiredArgsConstructor
@Slf4j
public class AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
private final TokenProvider tokenProvider;
@Value("${jwt.domain}") private String domain;
@Value("${oauth-signup-uri}") private String signUpURI;
@Value("${oauth-signin-uri}") private String signInURI;
@Value("${oauth-login-uri}") private String loginURI;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
String accessToken = tokenProvider.createAccessToken(authentication);
String refreshToken = tokenProvider.createRefreshToken(authentication);
// 일반 로그인 성공 로직이 있었음 (생략)
resolveResponseCookieByOrigin(request, response, accessToken, refreshToken);
response.sendRedirect(redirectUriByFirstJoinOrNot(authentication));
}
// 생략한 함수들 많아요.
private String redirectUriByFirstJoinOrNot(Authentication authentication){
return getRedirectUri(authentication);
}
@NotNull
public String getRedirectUri(Authentication authentication) {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
Collection<? extends GrantedAuthority> authorities = oAuth2User.getAuthorities();
if(authorities.stream().filter(o -> o.getAuthority().equals(Role.OAUTH_FIRST_JOIN.getRoles())).findAny().isPresent()){
return UriComponentsBuilder.fromHttpUrl(signUpURI)
.path(authentication.getName())
.build().toString();
}
else if (authorities.stream().filter(o->o.getAuthority().equals(Role.EXCEPTION.getRoles())).findAny().isPresent()){
return UriComponentsBuilder.fromHttpUrl(loginURI)
.build().toString();
}
else{ // non social 로그인의 경우 회원가입한 유저이므로 else문으로 항상 들어감.
return UriComponentsBuilder.fromHttpUrl(signInURI)
.build().toString();
}
}
}
onAuthenticationSuccess()
메서드에서 OAuth로그인시 확인해야 할 것은 RedirectUri 이다.
즉, 유효한 Authnetication 객체가 인자로 왔다고 치고, 이에 따른 RedirectUri를 확인하면 되는것이다.
그러면 여기에서는 Authentication객체를 Mocking하는 것이 중요하겠다.
@SpringBootTest
@Transactional
class MemberIntegrationTest {
// 주입 생략
@Nested
@DisplayName("회원가입")
class registerUser{
@Test
@DisplayName("OAuth 회원가입시 authentication 객체 확인")
void registerByOAuth(){
String userEmail = "test@gmail.com";
OAuth2User mockOAuth2User = mock(OAuth2User.class);
when(mockOAuth2User.getAttributes()).thenReturn(Map.of());
assertThat(oAuthService.getCustomUserDetails(mockOAuth2User,userEmail).
getAuthorities().stream().map(auth->auth.getAuthority()))
.contains(Role.USER.getRoles(),Role.OAUTH_FIRST_JOIN.getRoles());
}
@Test
@DisplayName("OAuth 에 회원가입된 것으로 로그인시 authentication 객체 확인")
void registerByOAuthWhenAlreadyRegistered(){
String userEmail = "test@gmail.com";
memberRepository.save(SocialMember.createSocialMember(userEmail,"nickname"));
OAuth2User mockOAuth2User = mock(OAuth2User.class);
when(mockOAuth2User.getAttributes()).thenReturn(Map.of());
assertThat(oAuthService.getCustomUserDetails(mockOAuth2User,userEmail).
getAuthorities().stream().map(auth->auth.getAuthority()))
.contains(Role.USER.getRoles());
}
@Test
@DisplayName("OAuth 에 일반 가입된 이메일로 로그인시 authentication 객체 확인")
void registerByOAuthWhenAlreadyRegisteredByNonSocial(){
String userEmail = "test@gmail.com";
memberRepository.save(NonSocialMember.createNonSocialMember(new MemberSaveDto("name",LoginType.NON_SOCIAL,userEmail,"a1234567@")));
OAuth2User mockOAuth2User = mock(OAuth2User.class);
when(mockOAuth2User.getAttributes()).thenReturn(Map.of());
assertThat(oAuthService.getCustomUserDetails(mockOAuth2User,userEmail).getAuthorities().stream().map(o->o.getAuthority())).contains(Role.EXCEPTION.getRoles());
}
}
}
공통적으로 OAuth2User 객체를 모킹한 것을 확인할 수 있다.
그 후, 세가지 테스트 코드를 거쳤다.
OAuth로 회원가입시, 알맞은 Authentication 객체가 나오는지?
OAuth로 로그인시, 알맞은 Authentication 객체가 나오는지?
일반 로그인으로 가입 된 회원인데, OAuth로그인시 잘못된 Authentication 객체가 나오는지?
@SpringBootTest
class AuthenticationSuccessHandlerTest {
@Value("${oauth-signup-uri}") String signUpURI;
@Value("${oauth-signin-uri}") String signInURI;
@Autowired
AuthenticationSuccessHandler authenticationSuccessHandler;
@Test
@DisplayName("OAuth 유저 최초로그인시 redirect uri 확인")
void firstLoginThenRedirectToNicknamePage() throws IOException {
Authentication mockAuthentication = mock(Authentication.class);
when(mockAuthentication.getPrincipal()).thenReturn(new CustomUserDetails(String.valueOf(1L), Role.addRole(Role.getIncludingRoles(Role.USER.toString()), Role.OAUTH_FIRST_JOIN), Map.of()));
when(mockAuthentication.getName()).thenReturn("name");
Assertions.assertThat(authenticationSuccessHandler.getRedirectUri(mockAuthentication)).isEqualTo(signUpURI+"name");
}
@Test
@DisplayName("OAuth 유저 로그인시 redirect uri 확인")
void LoginThenRedirectToMainPage() throws IOException {
Authentication mockAuthentication = mock(Authentication.class);
when(mockAuthentication.getPrincipal()).thenReturn(new CustomUserDetails(String.valueOf(1L), Role.USER.toString(), Map.of()));
when(mockAuthentication.getName()).thenReturn("name");
Assertions.assertThat(authenticationSuccessHandler.getRedirectUri(mockAuthentication)).isEqualTo(signInURI);
}
}
여기서는 공통적으로 Authentication 클래스를 모킹하였다.
그 후, 두가지 테스트를 거쳤다.
회원가입을 거친 Authentication클래스를 모킹한 후, SuccessHandler에서 알맞은 URL로 이동하는지?
로그인을 거친 Authentication클래스를 모킹한 후, SuccessHandler에서 알맞은 URL로 이동하는지?
테스트코드를 통해 확인한 flow는 다음과 같다.
OAuth 서버에서 받아야하는 객체를 Mocking하고 OAuthService에서 알맞은 Authentication 객체를 내리는지 확인
(1)에서 도출된 Authentication객체를 모킹하여, 이를 기반으로 알맞은 URI로 이동하는지 확인
하지만 여기서 조금 아쉬운 점은,
만약 유효한 Authentication의 정보가 바뀐다고 하면, 1,2의 테스트 코드를 둘다 손봐야 한다는 점
1->2로 이루어지는 과정을 하나의 테스트로 포함시키지 못한 점
사실 2번을 하기 위해 고민하다가 해결하지 못했다.
통합테스트를 하기에는, OAuth서버와의 직접적인 통신을 할 수 없기 때문에 불가능하고,
Service와 SuccessHandler를 단위테스트에 모두 포함시킬 수 없으니.. 결국 모킹이 필요하다는 결론에 이르렀다.
어떻게 하는 베스트일지는 모르겠지만, 나름 유효한 테스트코드라고 생각한다.