diff --git a/src/main/java/org/runimo/runimo/auth/repository/SignupTokenRepository.java b/src/main/java/org/runimo/runimo/auth/repository/SignupTokenRepository.java index 73c8a980..405b76a1 100644 --- a/src/main/java/org/runimo/runimo/auth/repository/SignupTokenRepository.java +++ b/src/main/java/org/runimo/runimo/auth/repository/SignupTokenRepository.java @@ -12,4 +12,6 @@ public interface SignupTokenRepository extends JpaRepository :createdAtAfter" ) Optional findByIdAndCreatedAtAfter(String token, LocalDateTime createdAtAfter); + + void deleteByToken(String token); } \ No newline at end of file diff --git a/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java b/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java index d066eaef..cf352570 100644 --- a/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java +++ b/src/main/java/org/runimo/runimo/auth/service/SignUpUsecaseImpl.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.runimo.runimo.auth.domain.SignupToken; +import org.runimo.runimo.auth.exceptions.SignUpException; import org.runimo.runimo.auth.jwt.JwtResolver; import org.runimo.runimo.auth.jwt.JwtTokenFactory; import org.runimo.runimo.auth.jwt.SignupTokenPayload; @@ -13,6 +14,7 @@ import org.runimo.runimo.user.domain.AppleUserToken; import org.runimo.runimo.user.domain.SocialProvider; import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.enums.UserHttpResponseCode; import org.runimo.runimo.user.repository.AppleUserTokenRepository; import org.runimo.runimo.user.service.UserRegisterService; import org.runimo.runimo.user.service.dto.command.UserRegisterCommand; @@ -36,6 +38,7 @@ public class SignUpUsecaseImpl implements SignUpUsecase { public SignupUserResponse register(UserSignupCommand command) { SignupTokenPayload payload = jwtResolver.getSignupTokenPayload(command.registerToken()); SignupToken signupToken = findUnExpiredSignupToken(payload.token()); + userRegisterService.validateExistingUser(payload.providerId(), payload.socialProvider()); String imgUrl = fileStorageService.storeFile(command.profileImage()); User savedUser = userRegisterService.registerUser(new UserRegisterCommand( command.nickname(), @@ -47,14 +50,19 @@ public SignupUserResponse register(UserSignupCommand command) { if (payload.socialProvider() == SocialProvider.APPLE) { createAppleUserToken(savedUser.getId(), signupToken); } + removeSignupToken(payload.token()); return new SignupUserResponse(savedUser, jwtTokenFactory.generateTokenPair(savedUser)); } + private void removeSignupToken(String token) { + signupTokenRepository.deleteByToken(token); + } + private SignupToken findUnExpiredSignupToken(String token) { LocalDateTime cutOffTime = LocalDateTime.now().minusMinutes(REGISTER_CUTOFF_MIN); return signupTokenRepository. findByIdAndCreatedAtAfter(token, cutOffTime) - .orElseThrow(IllegalAccessError::new); + .orElseThrow(() -> new SignUpException(UserHttpResponseCode.TOKEN_INVALID)); } private void createAppleUserToken(Long userId, SignupToken signupToken) { diff --git a/src/main/java/org/runimo/runimo/user/repository/OAuthInfoRepository.java b/src/main/java/org/runimo/runimo/user/repository/OAuthInfoRepository.java index 083983c0..5acbb0c6 100644 --- a/src/main/java/org/runimo/runimo/user/repository/OAuthInfoRepository.java +++ b/src/main/java/org/runimo/runimo/user/repository/OAuthInfoRepository.java @@ -1,5 +1,6 @@ package org.runimo.runimo.user.repository; +import jakarta.validation.constraints.NotNull; import java.util.Optional; import org.runimo.runimo.user.domain.OAuthInfo; import org.runimo.runimo.user.domain.SocialProvider; @@ -11,17 +12,20 @@ @Repository public interface OAuthInfoRepository extends JpaRepository { - @Query(""" - SELECT o FROM OAuthInfo o - WHERE o.deletedAt is null AND o.provider = :provider AND o.providerId = :providerId - """) - Optional findByProviderAndProviderId(SocialProvider provider, String providerId); + @Query(""" + SELECT o FROM OAuthInfo o + WHERE o.deletedAt is null AND o.provider = :provider AND o.providerId = :providerId + """) + Optional findByProviderAndProviderId(SocialProvider provider, String providerId); - SocialProvider user(User user); + SocialProvider user(User user); - @Query(""" - select o from OAuthInfo o - where o.deletedAt is null AND o.user.id = :userId - """) - Optional findByUserId(Long userId); + @Query(""" + select o from OAuthInfo o + where o.deletedAt is null AND o.user.id = :userId + """) + Optional findByUserId(Long userId); + + boolean existsByProviderIdAndProvider(@NotNull String providerId, + @NotNull SocialProvider socialProvider); } diff --git a/src/main/java/org/runimo/runimo/user/service/UserRegisterService.java b/src/main/java/org/runimo/runimo/user/service/UserRegisterService.java index a7e6a9c5..0951fe6b 100644 --- a/src/main/java/org/runimo/runimo/user/service/UserRegisterService.java +++ b/src/main/java/org/runimo/runimo/user/service/UserRegisterService.java @@ -1,8 +1,12 @@ package org.runimo.runimo.user.service; import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.exceptions.SignUpException; import org.runimo.runimo.rewards.service.eggs.EggGrantService; +import org.runimo.runimo.user.domain.SocialProvider; import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.user.repository.OAuthInfoRepository; import org.runimo.runimo.user.service.dto.command.UserCreateCommand; import org.runimo.runimo.user.service.dto.command.UserRegisterCommand; import org.springframework.stereotype.Service; @@ -15,6 +19,7 @@ public class UserRegisterService { private final UserCreator userCreator; private final UserItemCreator userItemCreator; private final EggGrantService eggGrantService; + private final OAuthInfoRepository oAuthInfoRepository; @Transactional public User registerUser(UserRegisterCommand command) { @@ -26,4 +31,11 @@ public User registerUser(UserRegisterCommand command) { eggGrantService.grantGreetingEggToUser(savedUser); return savedUser; } + + public void validateExistingUser(String providerId, SocialProvider socialProvider) { + if(oAuthInfoRepository.existsByProviderIdAndProvider( + providerId, socialProvider)) { + throw new SignUpException(UserHttpResponseCode.SIGNIN_FAIL_ALREADY_EXIST); + } + } } diff --git a/src/test/java/org/runimo/runimo/auth/controller/AuthAcceptanceTest.java b/src/test/java/org/runimo/runimo/auth/controller/AuthAcceptanceTest.java new file mode 100644 index 00000000..b22bf184 --- /dev/null +++ b/src/test/java/org/runimo/runimo/auth/controller/AuthAcceptanceTest.java @@ -0,0 +1,145 @@ +package org.runimo.runimo.auth.controller; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.runimo.runimo.CleanUpUtil; +import org.runimo.runimo.auth.controller.request.AuthSignupRequest; +import org.runimo.runimo.auth.domain.SignupToken; +import org.runimo.runimo.auth.repository.SignupTokenRepository; + +import org.runimo.runimo.auth.service.apple.AppleTokenVerifier; +import org.runimo.runimo.auth.service.kakao.KakaoLoginHandler; +import org.runimo.runimo.auth.service.kakao.KakaoTokenVerifier; +import org.runimo.runimo.external.FileStorageService; +import org.runimo.runimo.user.domain.Gender; +import org.runimo.runimo.user.domain.SocialProvider; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class AuthAcceptanceTest { + + @LocalServerPort + private int port; + @MockitoBean + private FileStorageService fileStorageService; + @MockitoBean + private KakaoTokenVerifier kakaoTokenVerifier; + @MockitoBean + private AppleTokenVerifier appleTokenVerifier; + + @Autowired + private SignupTokenRepository signupTokenRepository; + + @Autowired + private KakaoLoginHandler kakaoLoginHandler; + + private String token; + @Autowired + private ObjectMapper objectMapper; + @Autowired + private CleanUpUtil cleanUpUtil; + + @BeforeEach + void setUp() { + RestAssured.port = port; + // Save a valid signup token in the database + SignupToken signupToken = new SignupToken( + "valid-token", + "refresh-token", + "provider-id", + org.runimo.runimo.user.domain.SocialProvider.KAKAO + ); + token = kakaoLoginHandler.createTemporalSignupToken("temp-id", SocialProvider.KAKAO); + signupTokenRepository.save(signupToken); + } + + @AfterEach + void tearDown() { + cleanUpUtil.cleanUpUserInfos(); + } + + @Test + void 회원가입_성공_201응답() throws JsonProcessingException { + + AuthSignupRequest request = new AuthSignupRequest(token, "username", Gender.UNKNOWN); + + given() + .contentType(ContentType.MULTIPART) + .multiPart("request", objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/signup") + .then() + .statusCode(HttpStatus.CREATED.value()) + .log().all() + .body("payload.nickname", equalTo("username")) + .body("payload.token_pair.access_token", notNullValue()) + .body("payload.token_pair.refresh_token", notNullValue()); + } + + @Test + void 토큰_오류_회원가입_실패_401응답() throws JsonProcessingException { + AuthSignupRequest request = new AuthSignupRequest(token, "username", Gender.UNKNOWN); + + given() + .contentType(ContentType.MULTIPART) + .multiPart("request", objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/signup") + .then() + .statusCode(HttpStatus.CREATED.value()) + .log().all() + .body("payload.nickname", equalTo("username")) + .body("payload.token_pair.access_token", notNullValue()) + .body("payload.token_pair.refresh_token", notNullValue()); + + given() + .contentType(ContentType.MULTIPART) + .multiPart("request", objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/signup") + .then() + .statusCode(HttpStatus.UNAUTHORIZED.value()); + } + + @Test + void 중복_유저_회원가입_409응답() throws JsonProcessingException { + AuthSignupRequest request = new AuthSignupRequest(token, "username", Gender.UNKNOWN); + + given() + .contentType(ContentType.MULTIPART) + .multiPart("request", objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/signup") + .then() + .statusCode(HttpStatus.CREATED.value()) + .log().all() + .body("payload.nickname", equalTo("username")) + .body("payload.token_pair.access_token", notNullValue()) + .body("payload.token_pair.refresh_token", notNullValue()); + + token = kakaoLoginHandler.createTemporalSignupToken("temp-id", SocialProvider.KAKAO); + request = new AuthSignupRequest(token, "username", Gender.UNKNOWN); + + given() + .contentType(ContentType.MULTIPART) + .multiPart("request", objectMapper.writeValueAsString(request)) + .when() + .post("/api/v1/auth/signup") + .then() + .statusCode(HttpStatus.CONFLICT.value()); + } +} diff --git a/src/test/java/org/runimo/runimo/auth/service/SignUpUsecaseTest.java b/src/test/java/org/runimo/runimo/auth/service/SignUpUsecaseTest.java new file mode 100644 index 00000000..25df92b8 --- /dev/null +++ b/src/test/java/org/runimo/runimo/auth/service/SignUpUsecaseTest.java @@ -0,0 +1,79 @@ +package org.runimo.runimo.auth.service; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.runimo.runimo.auth.domain.SignupToken; +import org.runimo.runimo.auth.exceptions.SignUpException; +import org.runimo.runimo.auth.jwt.JwtResolver; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.jwt.SignupTokenPayload; +import org.runimo.runimo.auth.repository.SignupTokenRepository; +import org.runimo.runimo.auth.service.dto.UserSignupCommand; +import org.runimo.runimo.external.FileStorageService; +import org.runimo.runimo.user.domain.Gender; +import org.runimo.runimo.user.domain.SocialProvider; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.user.repository.AppleUserTokenRepository; +import org.runimo.runimo.user.service.UserRegisterService; + +class SignUpUsecaseTest { + + @Mock + private UserRegisterService userRegisterService; + @Mock + private FileStorageService fileStorageService; + @Mock + private JwtTokenFactory jwtTokenFactory; + @Mock + private SignupTokenRepository signupTokenRepository; + @Mock + private AppleUserTokenRepository appleUserTokenRepository; + @Mock + private JwtResolver jwtResolver; + + private SignUpUsecaseImpl sut; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + sut = new SignUpUsecaseImpl( + userRegisterService, + fileStorageService, + jwtTokenFactory, + signupTokenRepository, + appleUserTokenRepository, + jwtResolver + ); + } + + @Test + void 유저가_이미_존재하면_실패() { + // given + String registerToken = "dummy.token.value"; + SignupTokenPayload payload = new SignupTokenPayload(registerToken, "socialId123", + SocialProvider.KAKAO); + + when(jwtResolver.getSignupTokenPayload(registerToken)).thenReturn(payload); + when(signupTokenRepository.findByIdAndCreatedAtAfter(eq(registerToken), any())) + .thenReturn(Optional.of(new SignupToken( + registerToken, "refresh", "refresh", SocialProvider.KAKAO))); + + doThrow(new org.runimo.runimo.auth.exceptions.SignUpException( + UserHttpResponseCode.SIGNIN_FAIL_ALREADY_EXIST)) + .when(userRegisterService) + .validateExistingUser(payload.providerId(), payload.socialProvider()); + + assertThrows(SignUpException.class, () -> { + sut.register(new UserSignupCommand(registerToken, "nickname", null, Gender.UNKNOWN)); + }); + } +}