diff --git a/pennyway-app-external-api/build.gradle b/pennyway-app-external-api/build.gradle index fedb57393..6e36e0521 100644 --- a/pennyway-app-external-api/build.gradle +++ b/pennyway-app-external-api/build.gradle @@ -36,4 +36,5 @@ dependencies { testImplementation "org.testcontainers:junit-jupiter:1.19.7" testImplementation "org.testcontainers:mysql:1.19.7" testImplementation "com.redis.testcontainers:testcontainers-redis-junit:1.6.4" + testImplementation "org.springframework.cloud:spring-cloud-contract-wiremock:4.1.2" } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java index bd80ab184..063e089c1 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/AuthApi.java @@ -63,6 +63,14 @@ public interface AuthApi { } """) })), + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "일반 회원가입 계정이 이미 존재함", value = """ + { + "code": "4004", + "message": "이미 회원가입한 유저입니다." + } + """) + })), @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = { @ExampleObject(name = "검증 실패", value = """ { diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java index 29be15dda..520eaa6ff 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/api/OauthApi.java @@ -3,8 +3,10 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.headers.Header; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; @@ -107,6 +109,14 @@ public interface OauthApi { } """) })), + @ApiResponse(responseCode = "400", content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "해당 provider로 로그인한 이력이 이미 존재함", value = """ + { + "code": "4004", + "message": "이미 해당 제공자로 가입된 사용자입니다." + } + """) + })), @ApiResponse(responseCode = "401", content = @Content(mediaType = "application/json", examples = { @ExampleObject(name = "인증코드 불일치", value = """ { @@ -130,11 +140,45 @@ public interface OauthApi { @Parameter(name = "provider", description = "소셜 제공자", examples = { @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") }, required = true, in = ParameterIn.QUERY) + @ApiResponse(responseCode = "200", description = "로그인 성공", + headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰", schema = @Schema(type = "string"), required = true), + @Header(name = "Authorization", description = "액세스 토큰", schema = @Schema(type = "string", format = "jwt"), required = true) + }, + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "성공", value = """ + { + "code": "2000", + "data": { + "user": { + "id": 1 + } + } + } + """) + })) ResponseEntity linkAuth(@RequestParam Provider provider, @RequestBody @Validated SignUpReq.SyncWithAuth request); @Operation(summary = "[4-2] 소셜 회원가입", description = "회원 정보 입력 후 회원가입") @Parameter(name = "provider", description = "소셜 제공자", examples = { @ExampleObject(name = "카카오", value = "kakao"), @ExampleObject(name = "애플", value = "apple"), @ExampleObject(name = "구글", value = "google") }, required = true, in = ParameterIn.QUERY) + @ApiResponse(responseCode = "200", description = "로그인 성공", + headers = { + @Header(name = "Set-Cookie", description = "리프레시 토큰", schema = @Schema(type = "string"), required = true), + @Header(name = "Authorization", description = "액세스 토큰", schema = @Schema(type = "string", format = "jwt"), required = true) + }, + content = @Content(mediaType = "application/json", examples = { + @ExampleObject(name = "성공", value = """ + { + "code": "2000", + "data": { + "user": { + "id": 1 + } + } + } + """) + })) ResponseEntity signUp(@RequestParam Provider provider, @RequestBody @Validated SignUpReq.Oauth request); } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java index 49e4c71d3..9ad3d4c99 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserOauthSignMapper.java @@ -42,7 +42,6 @@ public User saveUser(SignUpReq.OauthInfo request, Pair isSignUp if (isSignUpUser.getLeft().equals(Boolean.TRUE)) { user = userService.readUserByUsername(isSignUpUser.getRight()) .orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); - Oauth.of(provider, oauthId, user); } else { user = User.builder() .username(request.username()) @@ -51,9 +50,11 @@ public User saveUser(SignUpReq.OauthInfo request, Pair isSignUp .role(Role.USER) .profileVisibility(ProfileVisibility.PUBLIC).build(); userService.createUser(user); - Oauth.of(provider, oauthId, user); } + Oauth oauth = Oauth.of(provider, oauthId, user); + oauthService.createOauth(oauth); + return user; } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java index f12e17ef8..c90183720 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/mapper/UserSyncMapper.java @@ -57,19 +57,16 @@ public Pair isGeneralSignUpAllowed(String phone) { public Pair isOauthSignUpAllowed(Provider provider, String phone) { Optional user = userService.readUserByPhone(phone); - // user 정보 없으면 Pair.of(Boolean.FALSE, null) 반환 if (user.isEmpty()) { log.info("회원가입 이력이 없는 사용자입니다. phone: {}", phone); return Pair.of(Boolean.FALSE, null); } - // 같은 provider로 가입한 정보가 있는지 확인 if (oauthService.isExistOauthAccount(user.get().getId(), provider)) { log.info("이미 동일한 Provider로 가입된 사용자입니다. phone: {}, provider: {}", phone, provider); return null; } - // user 정보 있으면 Pair.of(Boolean.TRUE, user.get().getUsername()) 반환 return Pair.of(Boolean.TRUE, user.get().getUsername()); } } diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java index 401b8d432..29d982c53 100644 --- a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/auth/usecase/OauthUseCase.java @@ -63,6 +63,11 @@ public Pair signUp(Provider provider, SignUpReq.OauthInfo request) { phoneVerificationMapper.isValidCode(PhoneVerificationDto.VerifyCodeReq.from(request), PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); Pair isSignUpUser = checkSignUpUserNotOauthByProvider(provider, request.phone()); + if (isSignUpUser.getLeft().equals(Boolean.FALSE) && request.username() == null) + throw new OauthException(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST); + if (isSignUpUser.getLeft().equals(Boolean.TRUE) && request.username() != null) + throw new OauthException(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST); + OidcDecodePayload payload = oauthOidcHelper.getPayload(provider, request.idToken()); User user = userOauthSignMapper.saveUser(request, isSignUpUser, provider, payload.sub()); phoneVerificationService.delete(request.phone(), PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java index ad124abf5..913b01e4a 100644 --- a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/AuthControllerIntegrationTest.java @@ -1,23 +1,38 @@ package kr.co.pennyway.api.apis.auth.controller; import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; import kr.co.pennyway.api.config.ExternalApiDBTestConfig; import kr.co.pennyway.api.config.ExternalApiIntegrationTest; import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithAnonymousUser; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.context.WebApplicationContext; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -25,15 +40,23 @@ @ExternalApiIntegrationTest @AutoConfigureMockMvc +@TestClassOrder(ClassOrderer.OrderAnnotation.class) public class AuthControllerIntegrationTest extends ExternalApiDBTestConfig { + private final String expectedUsername = "jayang"; + private final String expectedPhone = "010-1234-5678"; + private final String expectedCode = "123456"; + private final String expectedOauthId = "oauthId"; + @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper objectMapper; - - @Autowired + @SpyBean private PhoneVerificationService phoneVerificationService; + @SpyBean + private UserService userService; + @Autowired + private OauthService oauthService; @BeforeEach void setUp(WebApplicationContext webApplicationContext) { @@ -43,33 +66,275 @@ void setUp(WebApplicationContext webApplicationContext) { .build(); } - @Test - @DisplayName("컨테이너 실행 테스트") - void containerTest() { - System.out.println("컨테이너 실행 테스트"); + /** + * 일반 회원가입 유저 생성 + */ + private User createGeneralSignedUser() { + return User.builder() + .name("페니웨이") + .username(expectedUsername) + .password("dkssudgktpdy1") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .build(); + } + + /** + * OAuth로 가입한 유저 생성 (password가 NULL) + */ + private User createOauthSignedUser() { + return User.builder() + .name("페니웨이") + .username(expectedUsername) + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .build(); + } + + /** + * User에 연결된 Oauth 생성 + */ + private Oauth createOauthAccount(User user) { + return Oauth.of(Provider.KAKAO, expectedOauthId, user); + } + + @Nested + @Order(1) + @DisplayName("[2] 전화번호 검증 테스트") + class GeneralSignUpPhoneVerifyTest { + @Test + @WithAnonymousUser + @DisplayName("일반 회원가입 이력이 있는 경우 400 BAD_REQUEST를 반환하고, 인증 코드 캐시 데이터가 제거된다.") + void generalSignUpFailBecauseAlreadyGeneralSignUp() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.of(createGeneralSignedUser())); + + // when + ResultActions resultActions = performPhoneVerificationRequest(expectedCode); + + // then + resultActions + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(UserErrorCode.ALREADY_SIGNUP.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(UserErrorCode.ALREADY_SIGNUP.getExplainError())) + .andDo(print()); + assertThrows(IllegalArgumentException.class, () -> phoneVerificationService.readByPhone(expectedPhone, PhoneVerificationType.SIGN_UP)); + } + + @Test + @WithAnonymousUser + @DisplayName("인증 번호가 일치하지 않는 경우 401 UNAUTHORIZED를 반환한다.") + void generalSignUpFailBecauseInvalidCode() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.empty()); + String invalidCode = "111111"; + + // when + ResultActions resultActions = performPhoneVerificationRequest(invalidCode); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @DisplayName("일치하는 전화번호 혹은 인증 번호가 없는 경우 404 NOT_FOUND를 반환한다.") + void generalSignUpFailBecauseNotFound() throws Exception { + // given + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.empty()); + + // when + ResultActions resultActions = performPhoneVerificationRequest(expectedCode); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @DisplayName("소셜 로그인 이력이 없는 경우, 200 OK를 반환하고 oauth 필드가 false이다.") + void generalSignUpSuccess() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.empty()); + + // when + ResultActions resultActions = performPhoneVerificationRequest(expectedCode); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sms.code").value(true)) + .andExpect(jsonPath("$.data.sms.oauth").value(false)) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @DisplayName("소셜 로그인 이력이 있는 경우, 200 OK를 반환하고 oauth 필드가 true고 username 필드가 존재한다.") + void generalSignUpSuccessWithOauth() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.of(createOauthSignedUser())); + + // when + ResultActions resultActions = performPhoneVerificationRequest(expectedCode); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.sms.code").value(true)) + .andExpect(jsonPath("$.data.sms.oauth").value(true)) + .andExpect(jsonPath("$.data.sms.username").value(expectedUsername)) + .andDo(print()); + } + + @AfterEach + void tearDown() { + phoneVerificationService.delete(expectedPhone, PhoneVerificationType.SIGN_UP); + } + + private ResultActions performPhoneVerificationRequest(String expectedCode) throws Exception { + PhoneVerificationDto.VerifyCodeReq request = new PhoneVerificationDto.VerifyCodeReq(expectedPhone, expectedCode); + return mockMvc.perform( + post("/v1/auth/phone/verification") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + } + } + + @Nested + @Order(2) + @DisplayName("[3-1] 일반 회원가입 테스트") + class GeneralSignUpTest { + @Test + @WithAnonymousUser + @DisplayName("인증번호가 일치하지 않는 경우 401 UNAUTHORIZED를 반환한다.") + void generalSignUpFailBecauseInvalidCode() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + String invalidCode = "111111"; + + // when + ResultActions resultActions = performGeneralSignUpRequest(invalidCode); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("인증번호가 일치하는 경우 200 OK를 반환하고, 회원가입이 완료된다.") + void generalSignUpSuccess() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + given(userService.readUserByPhone(expectedPhone)).willReturn(Optional.empty()); + + // when + ResultActions resultActions = performGeneralSignUpRequest(expectedCode); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").value(1)) + .andDo(print()); + } + + private ResultActions performGeneralSignUpRequest(String code) throws Exception { + SignUpReq.General request = new SignUpReq.General(expectedUsername, "pennyway", "dkssudgktpdy1", expectedPhone, code); + return mockMvc.perform( + post("/v1/auth/sign-up") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + } + + @AfterEach + void tearDown() { + phoneVerificationService.delete(expectedPhone, PhoneVerificationType.SIGN_UP); + } } - @Test - @WithAnonymousUser - @DisplayName("회원가입 통합 테스트") - void controllerTest() throws Exception { - // given - SignUpReq.General request = new SignUpReq.General("pennyway", "jayang", "dkssudgktpdy1", "010-1234-5678", "050505"); - phoneVerificationService.create("010-1234-5678", "050505", PhoneVerificationType.SIGN_UP); - - // when - ResultActions resultActions = mockMvc.perform( - post("/v1/auth/sign-up") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ); - - // then - resultActions - .andExpect(status().isOk()) - .andExpect(header().exists("Set-Cookie")) - .andExpect(header().exists("Authorization")) - .andExpect(jsonPath("$.data.user.id").value(1)) - .andDo(print()); + @Nested + @Order(3) + @DisplayName("[3-2] 소셜 계정 연동 회원가입 테스트") + class SyncWithOauthSignUpTest { + @Test + @WithAnonymousUser + @DisplayName("인증번호가 일치하지 않는 경우 401 UNAUTHORIZED를 반환한다.") + void syncWithOauthSignUpFailBecauseInvalidCode() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + String invalidCode = "111111"; + + // when + ResultActions resultActions = performSyncWithOauthSignUpRequest(invalidCode); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("인증번호가 일치하는 경우 200 OK를 반환하고, 기존의 소셜 계정과 연동된 회원가입이 완료된다.") + void syncWithOauthSignUpSuccess() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.SIGN_UP); + User user = createOauthSignedUser(); + userService.createUser(user); + oauthService.createOauth(createOauthAccount(user)); + + // when + ResultActions resultActions = performSyncWithOauthSignUpRequest(expectedCode); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").value(user.getId())) + .andDo(print()); + assertNotNull(oauthService.readOauthByOauthIdAndProvider("oauthId", Provider.KAKAO)); + assertNotNull(user.getPassword()); + } + + private ResultActions performSyncWithOauthSignUpRequest(String code) throws Exception { + SignUpReq.SyncWithOauth request = new SignUpReq.SyncWithOauth("dkssudgktpdy1", expectedPhone, code); + return mockMvc.perform( + post("/v1/auth/link-oauth") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ); + } + + @AfterEach + void tearDown() { + phoneVerificationService.delete(expectedPhone, PhoneVerificationType.SIGN_UP); + } } } \ No newline at end of file diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java new file mode 100644 index 000000000..09acb9e9c --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/auth/controller/OAuthControllerIntegrationTest.java @@ -0,0 +1,615 @@ +package kr.co.pennyway.api.apis.auth.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import kr.co.pennyway.api.apis.auth.dto.PhoneVerificationDto; +import kr.co.pennyway.api.apis.auth.dto.SignInReq; +import kr.co.pennyway.api.apis.auth.dto.SignUpReq; +import kr.co.pennyway.api.apis.auth.helper.OauthOidcHelper; +import kr.co.pennyway.api.common.exception.PhoneVerificationErrorCode; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.ExternalApiIntegrationTest; +import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationService; +import kr.co.pennyway.domain.common.redis.phone.PhoneVerificationType; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.exception.OauthErrorCode; +import kr.co.pennyway.domain.domains.oauth.service.OauthService; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import kr.co.pennyway.infra.common.oidc.OidcDecodePayload; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithAnonymousUser; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.ResourceUtils; +import org.springframework.web.context.WebApplicationContext; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExternalApiIntegrationTest +@AutoConfigureMockMvc +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +@AutoConfigureWireMock(port = 0) +@TestPropertySource(properties = "oauth2.client.provider.kakao.jwks-uri=http://localhost:${wiremock.server.port}") +public class OAuthControllerIntegrationTest extends ExternalApiDBTestConfig { + private final String expectedUsername = "jayang"; + + private final String expectedOauthId = "testOauthId"; + private final String expectedIdToken = "testIdToken"; + private final String expectedPhone = "010-1234-5678"; + private final String expectedCode = "123456"; + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + @MockBean + private OauthOidcHelper oauthOidcHelper; + @SpyBean + private PhoneVerificationService phoneVerificationService; + @Autowired + private UserService userService; + @Autowired + private OauthService oauthService; + + /** + * 일반 회원가입 유저 생성 + */ + private User createGeneralSignedUser() { + return User.builder() + .name("페니웨이") + .username(expectedUsername) + .password("dkssudgktpdy1") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .build(); + } + + /** + * OAuth로 가입한 유저 생성 (password가 NULL) + */ + private User createOauthSignedUser() { + return User.builder() + .name("페니웨이") + .username(expectedUsername) + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .build(); + } + + /** + * User에 연결된 Oauth 생성 + */ + private Oauth createOauthAccount(User user, Provider provider) { + return Oauth.of(provider, expectedOauthId, user); + } + + @BeforeEach + void setUp(WebApplicationContext webApplicationContext) throws IOException { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(webApplicationContext) + .defaultRequest(post("/**").with(csrf())) + .build(); + Path path = ResourceUtils.getFile("classpath:payload/oidc-response.json").toPath(); + stubFor( + get(urlPathEqualTo("/.well-known/jwks.json")) + .willReturn( + aResponse() + .withStatus(HttpStatus.OK.value()) + .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .withBody(Files.readAllBytes(path)) + ) + ); + } + + @Nested + @Order(1) + @DisplayName("[1] 소셜 로그인") + class OauthSignInTest { + @Test + @WithAnonymousUser + @Transactional + @DisplayName("provider로 로그인한 소셜 계정이 있으면 로그인에 성공한다.") + void signInWithOauth() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + userService.createUser(user); + oauthService.createOauth(createOauthAccount(user, provider)); + + // when + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + + // then + result + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").value(user.getId())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("다른 provider로 로그인한 소셜 계정이 있으면 user id가 -1로 반환된다.") + void signInWithDifferentProvider() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + userService.createUser(user); + oauthService.createOauth(createOauthAccount(user, Provider.GOOGLE)); + + // when + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.id").value(-1)) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("일반 회원가입 이력만 존재하는 경우에는 user id가 -1로 반환된다.") + void signInWithGeneralSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createGeneralSignedUser(); + + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + userService.createUser(user); + + // when + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.id").value(-1)) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("회원 가입 이력이 없는 사용자의 경우에 user id가 -1로 반환된다.") + void signInWithNoSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.user.id").value(-1)) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("OAuth id와 payload의 sub가 다른 경우에는 NOT_MATCHED_OAUTH_ID 에러가 발생한다.") + void signInWithNotMatchedOauthId() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", "differentOauthId", "email")); + userService.createUser(user); + oauthService.createOauth(createOauthAccount(user, provider)); + + // when + ResultActions result = performOauthSignIn(provider, expectedOauthId, expectedIdToken); + + // then + result + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.NOT_MATCHED_OAUTH_ID.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.NOT_MATCHED_OAUTH_ID.getExplainError())) + .andDo(print()); + } + + private ResultActions performOauthSignIn(Provider provider, String oauthId, String idToken) throws Exception { + SignInReq.Oauth request = new SignInReq.Oauth(oauthId, idToken); + + return mockMvc.perform(post("/v1/auth/oauth/sign-in") + .param("provider", provider.name()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } + } + + @Nested + @Order(2) + @DisplayName("[3] 소셜 회원가입 전화번호 인증") + class OauthSignUpPhoneVerificationTest { + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 일반 회원가입 이력이 있으면, existsUser가 true고 username이 반환된다.") + void signUpWithGeneralSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createGeneralSignedUser(); + + userService.createUser(user); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + + // when + ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andExpect(jsonPath("$.data.sms.code").value(true)) + .andExpect(jsonPath("$.data.sms.existsUser").value(true)) + .andExpect(jsonPath("$.data.sms.username").value(expectedUsername)) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 다른 provider OAuth 회원가입 이력이 있으면, existsUser가 true고 username이 반환된다.") + void signUpWithDifferentProvider() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + Oauth oauth = createOauthAccount(user, Provider.GOOGLE); + + userService.createUser(user); + oauthService.createOauth(oauth); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(Provider.KAKAO)); + + // when + ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andExpect(jsonPath("$.data.sms.code").value(true)) + .andExpect(jsonPath("$.data.sms.existsUser").value(true)) + .andExpect(jsonPath("$.data.sms.username").value(expectedUsername)) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 회원가입 이력이 없으면 existsUser가 false고 username 필드가 없다.") + void signUpWithNoSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + + // when + ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("2000")) + .andExpect(jsonPath("$.data.sms.code").value(true)) + .andExpect(jsonPath("$.data.sms.existsUser").value(false)) + .andExpect(jsonPath("$.data.sms.username").doesNotExist()) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("같은 provider로 OAuth 회원가입 이력이 있으면 400 에러가 발생한다.") + void signUpWithSameProvider() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + Oauth oauth = createOauthAccount(user, provider); + + userService.createUser(user); + oauthService.createOauth(oauth); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + + // when + ResultActions result = performOauthSignUpPhoneVerification(provider, expectedCode); + + // then + result + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("인증 코드를 요청한 provider와 다른 provider로 인증 코드를 입력하면 404 에러가 발생한다.") + void signUpWithDifferentProviderCode() throws Exception { + // given + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(Provider.KAKAO)); + + // when + ResultActions result = performOauthSignUpPhoneVerification(Provider.GOOGLE, expectedCode); + + // then + result + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(PhoneVerificationErrorCode.EXPIRED_OR_INVALID_PHONE.getExplainError())) + .andDo(print()); + } + + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("인증 코드가 틀리면 401 에러가 발생한다.") + void signUpWithInvalidCode() throws Exception { + // given + Provider provider = Provider.KAKAO; + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + + // when + ResultActions result = performOauthSignUpPhoneVerification(provider, "123457"); + + // then + result + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.code").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(PhoneVerificationErrorCode.IS_NOT_VALID_CODE.getExplainError())) + .andDo(print()); + } + + private ResultActions performOauthSignUpPhoneVerification(Provider provider, String code) throws Exception { + PhoneVerificationDto.VerifyCodeReq request = new PhoneVerificationDto.VerifyCodeReq(expectedPhone, code); + return mockMvc.perform(post("/v1/auth/oauth/phone/verification") + .param("provider", provider.name()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } + } + + @Nested + @Order(3) + @DisplayName("[4-1] 소셜 회원가입 계정 연동") + class OauthSignUpAccountLinkingTest { + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 일반 회원가입 이력이 있으면, 해당 user entity에 OAuth 정보가 추가되고 로그인에 성공한다.") + void signUpWithGeneralSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createGeneralSignedUser(); + + userService.createUser(user); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); + + // then + result + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").value(user.getId())) + .andDo(print()); + Oauth savedOauth = oauthService.readOauthByOauthIdAndProvider(expectedOauthId, provider).get(); + assertEquals(savedOauth.getUser().getId(), user.getId()); + System.out.println("oauth : " + savedOauth); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 다른 provider OAuth 회원가입 이력이 있으면, user entity에 OAuth 정보가 추가되고 로그인에 성공한다.") + void signUpWithDifferentProvider() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + Oauth oauth = createOauthAccount(user, Provider.GOOGLE); + + userService.createUser(user); + oauthService.createOauth(oauth); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); + + // then + result + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").value(user.getId())) + .andDo(print()); + Oauth savedOauth = oauthService.readOauthByOauthIdAndProvider(expectedOauthId, provider).get(); + assertEquals(savedOauth.getUser().getId(), user.getId()); + System.out.println("oauth : " + savedOauth); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 회원가입 이력이 없으면, 동기화 요청 실패 후 400 에러가 발생한다.") + void signUpWithNoSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); + + // then + result + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("같은 provider로 OAuth 회원가입 이력이 있으면 400 에러가 발생한다.") + void signUpWithSameProvider() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + Oauth oauth = createOauthAccount(user, provider); + + userService.createUser(user); + oauthService.createOauth(oauth); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUpAccountLinking(provider, expectedCode); + + // then + result + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.ALREADY_SIGNUP_OAUTH.getExplainError())) + .andDo(print()); + } + + private ResultActions performOauthSignUpAccountLinking(Provider provider, String code) throws Exception { + SignUpReq.SyncWithAuth request = new SignUpReq.SyncWithAuth(expectedIdToken, expectedPhone, code); + return mockMvc.perform(post("/v1/auth/oauth/link-auth") + .param("provider", provider.name()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } + } + + @Nested + @Order(4) + @DisplayName("[4-2] 소셜 회원가입") + class OauthSignUpTest { + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 회원가입 이력이 없으면 새로운 회원가입이 성공하고 로그인 응답을 반환한다.") + void signUpWithNoSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUp(provider, expectedCode); + + // then + result + .andExpect(status().isOk()) + .andExpect(header().exists("Set-Cookie")) + .andExpect(header().exists("Authorization")) + .andExpect(jsonPath("$.data.user.id").isNumber()) + .andDo(print()); + Oauth savedOauth = oauthService.readOauthByOauthIdAndProvider(expectedOauthId, provider).get(); + assertNotNull(savedOauth.getUser().getId()); + System.out.println("oauth : " + savedOauth); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("기존의 일반 회원가입 이력이 있으면, 400 BAD_REQUEST 응답을 반환한다.") + void signUpWithGeneralSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createGeneralSignedUser(); + + userService.createUser(user); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUp(provider, expectedCode); + + // then + result + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST.getExplainError())) + .andDo(print()); + } + + @Test + @WithAnonymousUser + @Transactional + @DisplayName("OAuth로 가입한 유저가 이미 존재하면 400 에러가 발생한다.") + void signUpWithOauthSignedUser() throws Exception { + // given + Provider provider = Provider.KAKAO; + User user = createOauthSignedUser(); + Oauth oauth = createOauthAccount(user, Provider.GOOGLE); + + userService.createUser(user); + oauthService.createOauth(oauth); + phoneVerificationService.create(expectedPhone, expectedCode, PhoneVerificationType.getOauthSignUpTypeByProvider(provider)); + given(oauthOidcHelper.getPayload(provider, expectedIdToken)).willReturn(new OidcDecodePayload("iss", "aud", expectedOauthId, "email")); + + // when + ResultActions result = performOauthSignUp(provider, expectedCode); + + // then + result + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST.causedBy().getCode())) + .andExpect(jsonPath("$.message").value(OauthErrorCode.INVALID_OAUTH_SYNC_REQUEST.getExplainError())) + .andDo(print()); + } + + private ResultActions performOauthSignUp(Provider provider, String code) throws Exception { + SignUpReq.Oauth request = new SignUpReq.Oauth(expectedIdToken, "jayang", expectedUsername, expectedPhone, code); + return mockMvc.perform(post("/v1/auth/oauth/sign-up") + .param("provider", provider.name()) + .contentType("application/json") + .content(objectMapper.writeValueAsString(request))); + } + } +} diff --git a/pennyway-app-external-api/src/test/resources/payload/oidc-response.json b/pennyway-app-external-api/src/test/resources/payload/oidc-response.json new file mode 100644 index 000000000..c000e6d8b --- /dev/null +++ b/pennyway-app-external-api/src/test/resources/payload/oidc-response.json @@ -0,0 +1,20 @@ +{ + "keys": [ + { + "kid": "3f96980381e451efad0d2ddd30e3d3", + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": "q8zZ0b_MNaLd6Ny8wd4cjFomilLfFIZcmhNSc1ttx_oQdJJZt5CDHB8WWwPGBUDUyY8AmfglS9Y1qA0_fxxs-ZUWdt45jSbUxghKNYgEwSutfM5sROh3srm5TiLW4YfOvKytGW1r9TQEdLe98ork8-rNRYPybRI3SKoqpci1m1QOcvUg4xEYRvbZIWku24DNMSeheytKUz6Ni4kKOVkzfGN11rUj1IrlRR-LNA9V9ZYmeoywy3k066rD5TaZHor5bM5gIzt1B4FmUuFITpXKGQZS5Hn_Ck8Bgc8kLWGAU8TzmOzLeROosqKE0eZJ4ESLMImTb2XSEZuN1wFyL0VtJw", + "e": "AQAB" + }, + { + "kid": "9f252dadd5f233f93d2fa528d12fea", + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "n": "qGWf6RVzV2pM8YqJ6by5exoixIlTvdXDfYj2v7E6xkoYmesAjp_1IYL7rzhpUYqIkWX0P4wOwAsg-Ud8PcMHggfwUNPOcqgSk1hAIHr63zSlG8xatQb17q9LrWny2HWkUVEU30PxxHsLcuzmfhbRx8kOrNfJEirIuqSyWF_OBHeEgBgYjydd_c8vPo7IiH-pijZn4ZouPsEg7wtdIX3-0ZcXXDbFkaDaqClfqmVCLNBhg3DKYDQOoyWXrpFKUXUFuk2FTCqWaQJ0GniO4p_ppkYIf4zhlwUYfXZEhm8cBo6H2EgukntDbTgnoha8kNunTPekxWTDhE5wGAt6YpT4Yw", + "e": "AQAB" + } + ] +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java index eec8ad267..b3fbc64a4 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java @@ -59,4 +59,16 @@ public static Oauth of(Provider provider, String oauthId, User user) { .user(user) .build(); } + + @Override + public String toString() { + return "Oauth{" + + "id=" + id + + ", provider=" + provider + + ", oauthId='" + oauthId + '\'' + + ", createdAt=" + createdAt + + ", deletedAt=" + deletedAt + + ", user=" + user + + '}'; + } } diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java index 017897b7c..b372d38ed 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java @@ -12,6 +12,7 @@ public enum OauthErrorCode implements BaseErrorCode { /* 400 Bad Request */ ALREADY_SIGNUP_OAUTH(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "이미 해당 제공자로 가입된 사용자입니다."), + INVALID_OAUTH_SYNC_REQUEST(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "Oauth 동기화 요청이 잘못되었습니다."), /* 401 Unauthorized */ NOT_MATCHED_OAUTH_ID(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "OAuth ID가 일치하지 않습니다."), diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java index 967d632f1..f340c371b 100644 --- a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java @@ -14,6 +14,11 @@ public class OauthService { private final OauthRepository oauthRepository; + @Transactional + public void createOauth(Oauth oauth) { + oauthRepository.save(oauth); + } + @Transactional(readOnly = true) public Optional readOauthByOauthIdAndProvider(String oauthId, Provider provider) { return oauthRepository.findByOauthIdAndProvider(oauthId, provider); diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml index 6f00d4f7a..afa6c4277 100644 --- a/pennyway-domain/src/main/resources/application-domain.yml +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -74,7 +74,7 @@ spring: generate-ddl: true hibernate: ddl-auto: create - show-sql: false + show-sql: true properties: hibernate: dialect: org.hibernate.dialect.MySQLDialect \ No newline at end of file diff --git a/pennyway-infra/src/main/resources/application-infra.yml b/pennyway-infra/src/main/resources/application-infra.yml index 3750504ed..78e2b188a 100644 --- a/pennyway-infra/src/main/resources/application-infra.yml +++ b/pennyway-infra/src/main/resources/application-infra.yml @@ -28,14 +28,14 @@ oauth2: client: provider: kakao: - jwks-uri: ${KAKAO_JWKS_URI:https://kakao.com} + jwks-uri: ${KAKAO_JWKS_URI:http://localhost} secret: ${KAKAO_CLIENT_SECRET:liuhil5068l2j5o0912} google: - jwks-uri: ${GOOGLE_JWKS_URI:https://google.com} + jwks-uri: ${GOOGLE_JWKS_URI:http://localhost} secret: ${GOOGLE_CLIENT_SECRET:123456789012-67hm9vokrt6ukmiwtvd8ak67oflecm.apps.googleusercontent.com} issuer: ${GOOGLE_ISSUER:https://google.com} apple: - jwks-uri: ${APPLE_JWKS_URI:https://apple.com} + jwks-uri: ${APPLE_JWKS_URI:http://localhost} secret: ${APPLE_CLIENT_SECRET:pennyway-jayang-was} ---