diff --git a/src/main/java/org/runimo/runimo/auth/controller/LogOutController.java b/src/main/java/org/runimo/runimo/auth/controller/LogOutController.java new file mode 100644 index 00000000..d0183cc5 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/controller/LogOutController.java @@ -0,0 +1,44 @@ +package org.runimo.runimo.auth.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.common.response.SuccessResponse; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.auth.service.logout.LogOutUsecase; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "LOG-OUT", description = "로그아웃 API") +@RestController +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +public class LogOutController { + + private final LogOutUsecase logOutUsecase; + + @Operation(summary = "로그아웃", description = "사용자를 로그아웃 처리합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그아웃 성공"), + @ApiResponse(responseCode = "200", description = "로그아웃 성공 (이미 로그아웃된 사용자)"), + @ApiResponse(responseCode = "401", description = "토큰 검증 실패"), + @ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없음") + }) + @PostMapping("/log-out") + public ResponseEntity> logOut( + @RequestHeader("Authorization") String accessTokenHeader + ) { + String token = accessTokenHeader.replace("Bearer ", ""); + boolean logoutProcessed = logOutUsecase.execute(token); + + UserHttpResponseCode responseCode = logoutProcessed ? UserHttpResponseCode.LOG_OUT_SUCCESS + : UserHttpResponseCode.ALREADY_LOG_OUT_SUCCESS; + return ResponseEntity.ok() + .body(SuccessResponse.of(responseCode, null)); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java b/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java index b622653a..00dad9b1 100644 --- a/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java +++ b/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java @@ -29,8 +29,14 @@ public UserDetail getUserDetailFromJwtToken(String token) throws JWTVerification } public String getUserIdFromJwtToken(String token) throws JWTVerificationException { - DecodedJWT jwt = verifyJwtToken(token); - return jwt.getSubject(); + String userPublicId; + try { + DecodedJWT jwt = verifyJwtToken(token); + userPublicId = jwt.getSubject(); + } catch (Exception e) { + throw UserJwtException.of(UserHttpResponseCode.TOKEN_INVALID); + } + return userPublicId; } public SignupTokenPayload getSignupTokenPayload(String token) diff --git a/src/main/java/org/runimo/runimo/auth/repository/DatabaseTokenRepository.java b/src/main/java/org/runimo/runimo/auth/repository/DatabaseTokenRepository.java index f9f01810..55c38543 100644 --- a/src/main/java/org/runimo/runimo/auth/repository/DatabaseTokenRepository.java +++ b/src/main/java/org/runimo/runimo/auth/repository/DatabaseTokenRepository.java @@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; @Profile({"prod", "dev"}) @Repository @@ -20,9 +21,8 @@ public class DatabaseTokenRepository implements JwtTokenRepository { /** - * @param userId 사용자 ID - * 만료되지않은 refreshToken을 조회합니다. - * */ + * @param userId 사용자 ID 만료되지않은 refreshToken을 조회합니다. + */ @Override public Optional findRefreshTokenByUserId(final Long userId) { LocalDateTime REPLACE_CUTOFF_TIME = LocalDateTime.now() @@ -32,10 +32,9 @@ public Optional findRefreshTokenByUserId(final Long userId) { } /** - * @param userId 사용자 ID - * @param refreshToken refreshToken - * refreshToken 엔티티를 UPSERT합니다. - * */ + * @param userId 사용자 ID + * @param refreshToken refreshToken refreshToken 엔티티를 UPSERT합니다. + */ @Override public void saveRefreshTokenWithUserId(final Long userId, final String refreshToken) { @@ -52,5 +51,13 @@ public void saveRefreshTokenWithUserId(final Long userId, final String refreshTo refreshTokenJpaRepository.save(updatedRefreshToken); } + /** + * @param userId 사용자 ID 사용자의 refreshToken을 DELETE 합니다. + */ + @Override + @Transactional + public void deleteRefreshTokenByUserId(Long userId) { + refreshTokenJpaRepository.deleteByUserId(userId); + } } diff --git a/src/main/java/org/runimo/runimo/auth/repository/InMemoryTokenRepository.java b/src/main/java/org/runimo/runimo/auth/repository/InMemoryTokenRepository.java index dce9c942..4b41ea4b 100644 --- a/src/main/java/org/runimo/runimo/auth/repository/InMemoryTokenRepository.java +++ b/src/main/java/org/runimo/runimo/auth/repository/InMemoryTokenRepository.java @@ -3,11 +3,13 @@ import java.time.Duration; import java.util.Optional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.runimo.runimo.common.cache.InMemoryCache; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Repository; +@Slf4j @Profile({"test", "local"}) @Repository @RequiredArgsConstructor @@ -27,4 +29,9 @@ public Optional findRefreshTokenByUserId(Long userId) { public void saveRefreshTokenWithUserId(Long userId, String refreshToken) { refreshTokenCache.put(userId, refreshToken, Duration.ofMillis(refreshTokenExpiry)); } + + @Override + public void deleteRefreshTokenByUserId(Long userId) { + refreshTokenCache.remove(userId); + } } diff --git a/src/main/java/org/runimo/runimo/auth/repository/JwtTokenRepository.java b/src/main/java/org/runimo/runimo/auth/repository/JwtTokenRepository.java index 0ebfc013..935e2547 100644 --- a/src/main/java/org/runimo/runimo/auth/repository/JwtTokenRepository.java +++ b/src/main/java/org/runimo/runimo/auth/repository/JwtTokenRepository.java @@ -7,4 +7,6 @@ public interface JwtTokenRepository { Optional findRefreshTokenByUserId(Long userId); void saveRefreshTokenWithUserId(Long userId, String refreshToken); + + void deleteRefreshTokenByUserId(Long userId); } diff --git a/src/main/java/org/runimo/runimo/auth/repository/RefreshTokenJpaRepository.java b/src/main/java/org/runimo/runimo/auth/repository/RefreshTokenJpaRepository.java index e2a52b64..620e5c2a 100644 --- a/src/main/java/org/runimo/runimo/auth/repository/RefreshTokenJpaRepository.java +++ b/src/main/java/org/runimo/runimo/auth/repository/RefreshTokenJpaRepository.java @@ -15,4 +15,6 @@ public interface RefreshTokenJpaRepository extends JpaRepository :cutOffDateTime") Optional findByUserIdAfterCutoffTime(Long userId, LocalDateTime cutOffDateTime); + + void deleteByUserId(Long userId); } diff --git a/src/main/java/org/runimo/runimo/auth/service/TokenRefreshService.java b/src/main/java/org/runimo/runimo/auth/service/TokenRefreshService.java index 3cf5c76e..0ab219cb 100644 --- a/src/main/java/org/runimo/runimo/auth/service/TokenRefreshService.java +++ b/src/main/java/org/runimo/runimo/auth/service/TokenRefreshService.java @@ -27,20 +27,13 @@ public void putRefreshToken(String userPublicId, String refreshToken) { } public TokenPair refreshAccessToken(String refreshToken) { - String userPublicId; - try { - jwtResolver.verifyJwtToken(refreshToken); - userPublicId = jwtResolver.getUserIdFromJwtToken(refreshToken); - } catch (Exception e) { - throw UserJwtException.of(UserHttpResponseCode.TOKEN_REFRESH_FAIL); - } + String userPublicId = jwtResolver.getUserIdFromJwtToken(refreshToken); User user = userFinder.findUserByPublicId(userPublicId) .orElseThrow(() -> UserJwtException.of(UserHttpResponseCode.TOKEN_REFRESH_FAIL)); // Check if the refresh token is expired - String storedToken = jwtTokenRepository.findRefreshTokenByUserId(user.getId()) - .orElseThrow(() -> UserJwtException.of(UserHttpResponseCode.REFRESH_EXPIRED)); + String storedToken = getStoredRefreshToken(user.getId()); if (!storedToken.equals(refreshToken)) { throw UserJwtException.of(UserHttpResponseCode.TOKEN_INVALID); } @@ -48,4 +41,24 @@ public TokenPair refreshAccessToken(String refreshToken) { String newAccessToken = jwtTokenFactory.generateAccessToken(user); return new TokenPair(newAccessToken, refreshToken); } + + /** + * 해당 사용자의 refresh token 조회 + * + * @param userId 사용자 식별자 + * @return refresh 토큰 + */ + public String getStoredRefreshToken(Long userId) { + return jwtTokenRepository.findRefreshTokenByUserId(userId) + .orElseThrow(() -> UserJwtException.of(UserHttpResponseCode.REFRESH_EXPIRED)); + } + + /** + * 해당 사용자의 refresh token 삭제 + * + * @param userId 사용자 식별자 + */ + public void removeRefreshToken(Long userId) { + jwtTokenRepository.deleteRefreshTokenByUserId(userId); + } } diff --git a/src/main/java/org/runimo/runimo/auth/service/logout/LogOutUsecase.java b/src/main/java/org/runimo/runimo/auth/service/logout/LogOutUsecase.java new file mode 100644 index 00000000..55f103f8 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/logout/LogOutUsecase.java @@ -0,0 +1,6 @@ +package org.runimo.runimo.auth.service.logout; + +public interface LogOutUsecase { + + boolean execute(String refreshToken); +} diff --git a/src/main/java/org/runimo/runimo/auth/service/logout/LogOutUsecaseImpl.java b/src/main/java/org/runimo/runimo/auth/service/logout/LogOutUsecaseImpl.java new file mode 100644 index 00000000..32ab5118 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/service/logout/LogOutUsecaseImpl.java @@ -0,0 +1,38 @@ +package org.runimo.runimo.auth.service.logout; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.exceptions.UserJwtException; +import org.runimo.runimo.auth.jwt.JwtResolver; +import org.runimo.runimo.auth.service.TokenRefreshService; +import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.user.exception.UserException; +import org.runimo.runimo.user.service.UserFinder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class LogOutUsecaseImpl implements LogOutUsecase { + + private final UserFinder userFinder; + private final TokenRefreshService tokenRefreshService; + private final JwtResolver jwtResolver; + + @Override + public boolean execute(String accessToken) { + String userPublicId = jwtResolver.getUserIdFromJwtToken(accessToken); + User user = userFinder.findUserByPublicId(userPublicId).orElseThrow(() -> UserException.of( + UserHttpResponseCode.USER_NOT_FOUND)); + + try { + tokenRefreshService.getStoredRefreshToken(user.getId()); + } catch (UserJwtException ue) { + if (ue.getErrorCode() == UserHttpResponseCode.REFRESH_EXPIRED) { + return false; + } + } + + tokenRefreshService.removeRefreshToken(user.getId()); + return true; + } +} diff --git a/src/main/java/org/runimo/runimo/checker/controller/HealthCheckController.java b/src/main/java/org/runimo/runimo/checker/controller/HealthCheckController.java new file mode 100644 index 00000000..91729693 --- /dev/null +++ b/src/main/java/org/runimo/runimo/checker/controller/HealthCheckController.java @@ -0,0 +1,25 @@ +package org.runimo.runimo.checker.controller; + +import org.runimo.runimo.common.log.ServiceLog; +import org.runimo.runimo.common.response.SuccessResponse; +import org.runimo.runimo.exceptions.code.ExampleErrorCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/checker") +public class HealthCheckController { + + /** + * health check api + */ + @ServiceLog + @GetMapping("/health-check") + public ResponseEntity> healthCheck() { + return ResponseEntity.ok( + SuccessResponse.of(ExampleErrorCode.SUCCESS, "Health check success !")); + } + +} diff --git a/src/main/java/org/runimo/runimo/common/GlobalConsts.java b/src/main/java/org/runimo/runimo/common/GlobalConsts.java index 7d9fcfb8..3b860d06 100644 --- a/src/main/java/org/runimo/runimo/common/GlobalConsts.java +++ b/src/main/java/org/runimo/runimo/common/GlobalConsts.java @@ -14,7 +14,8 @@ public final class GlobalConsts { "/api/v1/auth", "/swagger-ui", "/v3/api-docs", - "/actuator" + "/actuator", + "/checker" ); public static final String EMPTYFIELD = "EMPTY"; diff --git a/src/main/java/org/runimo/runimo/config/SecurityConfig.java b/src/main/java/org/runimo/runimo/config/SecurityConfig.java index 2ec43c2f..3c283873 100644 --- a/src/main/java/org/runimo/runimo/config/SecurityConfig.java +++ b/src/main/java/org/runimo/runimo/config/SecurityConfig.java @@ -29,6 +29,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ) .authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/v1/auth/**").permitAll() + .requestMatchers("/checker/**").permitAll() .requestMatchers(("/error")).permitAll() .anyRequest().authenticated() ) @@ -52,6 +53,7 @@ public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exce .permitAll() .requestMatchers(("/error")).permitAll() .requestMatchers("/api/v1/users/**").hasAnyRole("USER", "ADMIN") + .requestMatchers("/checker/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java b/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java index ebc8bfdb..7c249f4a 100644 --- a/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java +++ b/src/main/java/org/runimo/runimo/exceptions/GlobalExceptionHandler.java @@ -10,6 +10,7 @@ import org.runimo.runimo.external.ExternalServiceException; import org.runimo.runimo.hatch.exception.HatchException; import org.runimo.runimo.runimo.exception.RunimoException; +import org.runimo.runimo.user.exception.UserException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; @@ -48,6 +49,13 @@ public ResponseEntity handleUserJwtException(UserJwtException e) .body(ErrorResponse.of(e.getErrorCode())); } + @ExceptionHandler(UserException.class) + public ResponseEntity handleUserJwtException(UserException e) { + log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e); + return ResponseEntity.status(e.getHttpStatusCode()) + .body(ErrorResponse.of(e.getErrorCode())); + } + @ExceptionHandler(SignUpException.class) public ResponseEntity handleSignUpException(SignUpException e) { log.warn("{} {}", ERROR_LOG_HEADER, e.getMessage(), e); diff --git a/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java index ddbd6a6e..157ac11d 100644 --- a/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java +++ b/src/main/java/org/runimo/runimo/user/enums/UserHttpResponseCode.java @@ -22,7 +22,12 @@ public enum UserHttpResponseCode implements CustomResponseCode { JWT_TOKEN_BROKEN(HttpStatus.BAD_REQUEST, "JWT 토큰이 손상되었습니다", "JWT 토큰이 손상되었습니다"), TOKEN_REFRESH_FAIL(HttpStatus.FORBIDDEN, "토큰 재발급 실패", "Refresh 토큰이 유효하지 않습니다."), TOKEN_INVALID(HttpStatus.UNAUTHORIZED, "인증 실패", "JWT 토큰 인증 실패"), - REFRESH_EXPIRED(HttpStatus.FORBIDDEN, "리프레시 토큰 만료", "리프레시 토큰 만료"); + REFRESH_EXPIRED(HttpStatus.FORBIDDEN, "리프레시 토큰 만료", "리프레시 토큰 만료"), + TOKEN_DELETE_REFRESH_FAIL(HttpStatus.FORBIDDEN, "토큰 삭제 실패", + "사용자가 유효하지 않습니다. Refresh 토큰 삭제에 실패했습니다"), + LOG_OUT_SUCCESS(HttpStatus.OK, "로그아웃 성공", "로그아웃 성공"), + ALREADY_LOG_OUT_SUCCESS(HttpStatus.OK, "로그아웃 성공 (이미 로그아웃된 사용자)", "로그아웃 성공 (이미 로그아웃된 사용자)"), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없음", "사용자를 찾을 수 없음"); private final HttpStatus code; private final String clientMessage; diff --git a/src/main/java/org/runimo/runimo/user/exception/UserException.java b/src/main/java/org/runimo/runimo/user/exception/UserException.java new file mode 100644 index 00000000..1c1a6e12 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/exception/UserException.java @@ -0,0 +1,19 @@ +package org.runimo.runimo.user.exception; + +import org.runimo.runimo.exceptions.BusinessException; +import org.runimo.runimo.exceptions.code.CustomResponseCode; + +public class UserException extends BusinessException { + + protected UserException(CustomResponseCode errorCode) { + super(errorCode); + } + + public UserException(CustomResponseCode errorCode, String logMessage) { + super(errorCode, logMessage); + } + + public static UserException of(CustomResponseCode errorCode) { + return new UserException(errorCode, errorCode.getLogMessage()); + } +} diff --git a/src/main/java/org/runimo/runimo/user/service/WithdrawService.java b/src/main/java/org/runimo/runimo/user/service/WithdrawService.java index a49f63a8..ec0272a4 100644 --- a/src/main/java/org/runimo/runimo/user/service/WithdrawService.java +++ b/src/main/java/org/runimo/runimo/user/service/WithdrawService.java @@ -3,6 +3,7 @@ import java.util.NoSuchElementException; import lombok.RequiredArgsConstructor; import org.runimo.runimo.auth.service.EncryptUtil; +import org.runimo.runimo.auth.service.TokenRefreshService; import org.runimo.runimo.auth.service.login.apple.AppleTokenVerifier; import org.runimo.runimo.user.domain.AppleUserToken; import org.runimo.runimo.user.domain.OAuthInfo; @@ -23,6 +24,7 @@ public class WithdrawService { private final AppleTokenVerifier appleTokenVerifier; private final AppleUserTokenRepository appleUserTokenRepository; private final EncryptUtil encryptUtil; + private final TokenRefreshService tokenRefreshService; @Transactional public void withdraw(Long userId) { @@ -34,6 +36,7 @@ public void withdraw(Long userId) { } oAuthInfoRepository.delete(oAuthInfo); userRepository.delete(user); + tokenRefreshService.removeRefreshToken(user.getId()); } private void withdrawAppleUser(User user) { diff --git a/src/test/java/org/runimo/runimo/checker/controller/HealthCheckControllerTest.java b/src/test/java/org/runimo/runimo/checker/controller/HealthCheckControllerTest.java new file mode 100644 index 00000000..608c9c5f --- /dev/null +++ b/src/test/java/org/runimo/runimo/checker/controller/HealthCheckControllerTest.java @@ -0,0 +1,39 @@ +package org.runimo.runimo.checker.controller; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class HealthCheckControllerTest { + + @LocalServerPort + int port; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @Test + void healthCheck() { + + // when & then + given() + .contentType(ContentType.JSON) + .when() + .get("/checker/health-check") + .then() + .log().all() + .statusCode(200) + .body("payload", equalTo("Health check success !")); + } +} \ No newline at end of file diff --git a/src/test/java/org/runimo/runimo/user/api/LogOutAcceptanceFailTest.java b/src/test/java/org/runimo/runimo/user/api/LogOutAcceptanceFailTest.java new file mode 100644 index 00000000..423db43c --- /dev/null +++ b/src/test/java/org/runimo/runimo/user/api/LogOutAcceptanceFailTest.java @@ -0,0 +1,78 @@ +package org.runimo.runimo.user.api; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; + +import com.auth0.jwt.interfaces.DecodedJWT; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.runimo.runimo.auth.jwt.JwtResolver; +import org.runimo.runimo.exceptions.code.CustomResponseCode; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class LogOutAcceptanceFailTest { + + @LocalServerPort + int port; + + @MockitoSpyBean + private JwtResolver jwtResolver; + + @BeforeEach + void setUp() { + RestAssured.port = port; + } + + @Test + void 로그아웃_실패_토큰_인증_불가() { + CustomResponseCode responseCode = UserHttpResponseCode.TOKEN_INVALID; + + given() + .header("Authorization", "--invalid token value--") + .contentType(ContentType.JSON) + + .when() + .post("/api/v1/auth/log-out") + + .then() + .log().all() + .statusCode(responseCode.getHttpStatusCode().value()) + + .body("code", equalTo(responseCode.getCode())) + .body("message", equalTo(responseCode.getClientMessage())); + } + + @Test + void 로그아웃_실패_사용자_찾을_수_없음() { + CustomResponseCode responseCode = UserHttpResponseCode.USER_NOT_FOUND; + doReturn("wrong user public id").when(jwtResolver).getUserIdFromJwtToken(any()); + + given() + .header("Authorization", "--some token value--") + .contentType(ContentType.JSON) + + .when() + .post("/api/v1/auth/log-out") + + .then() + .log().all() + .statusCode(responseCode.getHttpStatusCode().value()) + + .body("code", equalTo(responseCode.getCode())) + .body("message", equalTo(responseCode.getClientMessage())); + } + +} \ No newline at end of file diff --git a/src/test/java/org/runimo/runimo/user/api/LogOutAcceptanceSuccessTest.java b/src/test/java/org/runimo/runimo/user/api/LogOutAcceptanceSuccessTest.java new file mode 100644 index 00000000..5995926e --- /dev/null +++ b/src/test/java/org/runimo/runimo/user/api/LogOutAcceptanceSuccessTest.java @@ -0,0 +1,217 @@ +package org.runimo.runimo.user.api; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.ArgumentMatchers.any; +import static org.runimo.runimo.TestConsts.TEST_USER_UUID; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import io.restassured.response.ValidatableResponse; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.runimo.runimo.CleanUpUtil; +import org.runimo.runimo.TokenUtils; +import org.runimo.runimo.auth.controller.request.AppleLoginRequest; +import org.runimo.runimo.auth.controller.request.KakaoLoginRequest; +import org.runimo.runimo.auth.jwt.JwtTokenFactory; +import org.runimo.runimo.auth.service.TokenRefreshService; +import org.runimo.runimo.auth.service.dto.AuthResult; +import org.runimo.runimo.auth.service.dto.AuthStatus; +import org.runimo.runimo.auth.service.dto.TokenPair; +import org.runimo.runimo.auth.service.login.apple.AppleLoginHandler; +import org.runimo.runimo.auth.service.login.kakao.KakaoLoginHandler; +import org.runimo.runimo.exceptions.code.CustomResponseCode; +import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.enums.UserHttpResponseCode; +import org.runimo.runimo.user.repository.UserRepository; +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.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.jdbc.Sql; + + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class LogOutAcceptanceSuccessTest { + + @LocalServerPort + int port; + + @MockitoBean + private KakaoLoginHandler kakaoLoginHandler; + @MockitoBean + private AppleLoginHandler appleLoginHandler; + + @Autowired + private JwtTokenFactory jwtTokenFactory; + @Autowired + private UserRepository userRepository; + @Autowired + private TokenRefreshService tokenRefreshService; + + @Autowired + private CleanUpUtil cleanUpUtil; + + @Autowired + private TokenUtils tokenUtils; + + @Autowired + private ObjectMapper objectMapper; + + private String token; + + @BeforeEach + void setUp() { + RestAssured.port = port; + token = tokenUtils.createTokenByUserPublicId(TEST_USER_UUID); + } + + @AfterEach + void tearDown() { + cleanUpUtil.cleanUpUserInfos(); + } + + @Test + @Sql(scripts = "/sql/log_out_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 로그아웃_성공_이미_로그아웃된_사용자_200() { + CustomResponseCode responseCode = UserHttpResponseCode.ALREADY_LOG_OUT_SUCCESS; + + given() + .header("Authorization", token) + .contentType(ContentType.JSON) + + .when() + .post("/api/v1/auth/log-out") + + .then() + .log().all() + .statusCode(responseCode.getHttpStatusCode().value()) + + .body("code", equalTo(responseCode.getCode())) + .body("message", equalTo(responseCode.getClientMessage())); + } + + + @Test + @Sql(scripts = "/sql/log_out_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 카카오_로그인_후_로그아웃_성공_200() throws JsonProcessingException { + // OIDC 토큰 처리 mocking + AuthResult authResult = createAuthResultOfTestUser(1L); + + Mockito.when(kakaoLoginHandler.validateAndLogin(any())) + .thenReturn(authResult); + + // 로그인 + KakaoLoginRequest loginReq = new KakaoLoginRequest("test-oidc-token-1"); + + UserHttpResponseCode loginSuccessCode = UserHttpResponseCode.LOGIN_SUCCESS; + ValidatableResponse loginRes = given() + .body(objectMapper.writeValueAsString(loginReq)) + .contentType(ContentType.JSON) + + .when() + .post("/api/v1/auth/kakao") + + .then() + .log().all() + .statusCode(loginSuccessCode.getHttpStatusCode().value()) + + .body("code", equalTo(loginSuccessCode.getCode())) + .body("message", equalTo(loginSuccessCode.getClientMessage())) + .body("payload.access_token", notNullValue()) + .body("payload.refresh_token", notNullValue()) + .body("payload.img_url", notNullValue()); + + String accessToken = loginRes.extract().body().path("payload.access_token"); + + // 로그아웃 + CustomResponseCode logOutSuccessCode = UserHttpResponseCode.LOG_OUT_SUCCESS; + given() + .header("Authorization", accessToken) + .contentType(ContentType.JSON) + + .when() + .post("/api/v1/auth/log-out") + + .then() + .log().all() + .statusCode(logOutSuccessCode.getHttpStatusCode().value()) + + .body("code", equalTo(logOutSuccessCode.getCode())) + .body("message", equalTo(logOutSuccessCode.getClientMessage())); + } + + @Test + @Sql(scripts = "/sql/log_out_test_data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) + void 애플_로그인_후_로그아웃_성공_200() throws JsonProcessingException { + // OIDC 토큰 처리 mocking + AuthResult authResult = createAuthResultOfTestUser(2L); + + Mockito.when(appleLoginHandler.validateAndLogin(any(), any())) + .thenReturn(authResult); + + // 로그인 + AppleLoginRequest loginReq = new AppleLoginRequest("test-auth-code-1", + "test-auth-verifier-1"); + + UserHttpResponseCode loginSuccessCode = UserHttpResponseCode.LOGIN_SUCCESS; + ValidatableResponse loginRes = given() + .body(objectMapper.writeValueAsString(loginReq)) + .contentType(ContentType.JSON) + + .when() + .post("/api/v1/auth/apple") + + .then() + .log().all() + .statusCode(loginSuccessCode.getHttpStatusCode().value()) + + .body("code", equalTo(loginSuccessCode.getCode())) + .body("message", equalTo(loginSuccessCode.getClientMessage())) + .body("payload.access_token", notNullValue()) + .body("payload.refresh_token", notNullValue()) + .body("payload.img_url", notNullValue()); + + String accessToken = loginRes.extract().body().path("payload.access_token"); + + // 로그아웃 + CustomResponseCode logOutSuccessCode = UserHttpResponseCode.LOG_OUT_SUCCESS; + given() + .header("Authorization", accessToken) + .contentType(ContentType.JSON) + + .when() + .post("/api/v1/auth/log-out") + + .then() + .log().all() + .statusCode(logOutSuccessCode.getHttpStatusCode().value()) + + .body("code", equalTo(logOutSuccessCode.getCode())) + .body("message", equalTo(logOutSuccessCode.getClientMessage())); + } + + private AuthResult createAuthResultOfTestUser(Long userId) { + User user = getTestUser(userId); + TokenPair tokenPair = getTokenPair(user); + return AuthResult.success(AuthStatus.LOGIN_SUCCESS, user, tokenPair); + } + + private TokenPair getTokenPair(User user) { + TokenPair tokenPair = jwtTokenFactory.generateTokenPair(user); + tokenRefreshService.putRefreshToken(user.getPublicId(), tokenPair.refreshToken()); + return tokenPair; + } + + private User getTestUser(Long userId) { + return userRepository.findById(userId).orElseThrow(); + } +} \ No newline at end of file diff --git a/src/test/resources/sql/log_out_test_data.sql b/src/test/resources/sql/log_out_test_data.sql new file mode 100644 index 00000000..7fe1187b --- /dev/null +++ b/src/test/resources/sql/log_out_test_data.sql @@ -0,0 +1,13 @@ +-- 사용자 +SET FOREIGN_KEY_CHECKS = 0; +TRUNCATE TABLE users; +INSERT INTO users (id, public_id, nickname, img_url, total_distance_in_meters, total_time_in_seconds, created_at, + updated_at) +VALUES (1, 'test-user-uuid-1', 'Daniel', 'https://example.com/images/user1.png', 10000, 3600, NOW(), NOW()), + (2, 'test-user-uuid-2', 'Daniel2', 'https://example.com/images/user2.png', 20000, 2600, NOW(), NOW()); +SET FOREIGN_KEY_CHECKS = 1; + +TRUNCATE TABLE oauth_account; +INSERT INTO oauth_account (id, user_id, provider, provider_id, created_at, updated_at) +VALUES (1, 1, 'KAKAO', 'test-oidc-token-1', NOW(), NOW()), + (2, 2, 'APPLE', 'test-oidc-token-2', NOW(), NOW()); \ No newline at end of file