diff --git a/src/main/java/org/websoso/WSSServer/auth/application/AuthApplication.java b/src/main/java/org/websoso/WSSServer/auth/application/AuthApplication.java index 492b22618..4abcc7ffa 100644 --- a/src/main/java/org/websoso/WSSServer/auth/application/AuthApplication.java +++ b/src/main/java/org/websoso/WSSServer/auth/application/AuthApplication.java @@ -15,7 +15,6 @@ import org.websoso.WSSServer.auth.jwt.JWTUtil; import org.websoso.WSSServer.auth.jwt.JwtProvider; import org.websoso.WSSServer.auth.jwt.JwtValidationType; -import org.websoso.WSSServer.auth.repository.RefreshTokenRepository; import org.websoso.WSSServer.auth.client.AppleClient; import org.websoso.WSSServer.auth.client.AppleIdTokenVerifier; import org.websoso.WSSServer.auth.client.AppleKeyGenerator; @@ -36,7 +35,6 @@ public class AuthApplication { private final TokenService tokenService; private final JwtProvider jwtProvider; private final JWTUtil jwtUtil; - private final RefreshTokenRepository refreshTokenRepository; private final UserDeviceRepository userDeviceRepository; private final UserService userService; private final KakaoService kakaoService; @@ -138,8 +136,7 @@ public LoginResponse login(Long userId) { @Transactional public void logout(User user, LogoutRequest request) { - refreshTokenRepository.findByRefreshToken(request.refreshToken()) - .ifPresent(refreshTokenRepository::delete); + tokenService.deleteRefreshToken(request.refreshToken()); userDeviceRepository.deleteByUserAndDeviceIdentifier(user, request.deviceIdentifier()); diff --git a/src/main/java/org/websoso/WSSServer/auth/jwt/JwtProvider.java b/src/main/java/org/websoso/WSSServer/auth/jwt/JwtProvider.java index ef0707e09..19a5cc8aa 100644 --- a/src/main/java/org/websoso/WSSServer/auth/jwt/JwtProvider.java +++ b/src/main/java/org/websoso/WSSServer/auth/jwt/JwtProvider.java @@ -5,6 +5,7 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.util.Date; +import java.util.UUID; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Component; @@ -50,6 +51,11 @@ private Claims generateClaims(Authentication authentication, Long expirationTime .setExpiration(new Date(now + expirationTime)); claims.put(CLAIM_USER_ID, authentication.getPrincipal()); + // iat/exp는 초 단위로 잘리므로, 회전 대상인 Refresh Token은 jti로 발급마다 문자열 유일성을 보장한다. + if (tokenType == TokenType.REFRESH) { + claims.setId(UUID.randomUUID().toString()); + } + return claims; } } diff --git a/src/main/java/org/websoso/WSSServer/auth/service/TokenService.java b/src/main/java/org/websoso/WSSServer/auth/service/TokenService.java index b517d8496..7f1a603db 100644 --- a/src/main/java/org/websoso/WSSServer/auth/service/TokenService.java +++ b/src/main/java/org/websoso/WSSServer/auth/service/TokenService.java @@ -36,4 +36,11 @@ public RefreshToken findRefreshTokenOrThrow(String refreshToken) { return refreshTokenRepository.findByRefreshToken(refreshToken) .orElseThrow(() -> new CustomAuthException(INVALID_TOKEN, "given token is invalid token for reissue")); } + + // 리프레시 토큰을 삭제한다. 존재하지 않아도 예외를 던지지 않는다. + @Transactional + public void deleteRefreshToken(String refreshToken) { + refreshTokenRepository.findByRefreshToken(refreshToken) + .ifPresent(refreshTokenRepository::delete); + } } diff --git a/src/test/java/org/websoso/WSSServer/auth/application/AuthApplicationReissueRotationTest.java b/src/test/java/org/websoso/WSSServer/auth/application/AuthApplicationReissueRotationTest.java new file mode 100644 index 000000000..e50a4bb52 --- /dev/null +++ b/src/test/java/org/websoso/WSSServer/auth/application/AuthApplicationReissueRotationTest.java @@ -0,0 +1,136 @@ +package org.websoso.WSSServer.auth.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willAnswer; +import static org.websoso.WSSServer.exception.error.CustomAuthError.INVALID_TOKEN; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.websoso.WSSServer.auth.client.AppleClient; +import org.websoso.WSSServer.auth.client.AppleIdTokenVerifier; +import org.websoso.WSSServer.auth.client.AppleKeyGenerator; +import org.websoso.WSSServer.auth.client.KakaoService; +import org.websoso.WSSServer.auth.controller.dto.ReissueResponse; +import org.websoso.WSSServer.auth.domain.RefreshToken; +import org.websoso.WSSServer.auth.jwt.CustomAuthenticationToken; +import org.websoso.WSSServer.auth.jwt.JWTUtil; +import org.websoso.WSSServer.auth.jwt.JwtKeyProvider; +import org.websoso.WSSServer.auth.jwt.JwtProvider; +import org.websoso.WSSServer.auth.jwt.TestTokenFactory; +import org.websoso.WSSServer.auth.repository.RefreshTokenRepository; +import org.websoso.WSSServer.auth.service.AppleService; +import org.websoso.WSSServer.auth.service.TokenService; +import org.websoso.WSSServer.exception.exception.CustomAuthException; +import org.websoso.WSSServer.notification.repository.UserDeviceRepository; +import org.websoso.WSSServer.user.domain.User; +import org.websoso.WSSServer.user.service.UserService; + +/** + * 저장소를 토큰 문자열 키 기반 맵으로 흉내 내어, 실제 {@link TokenService} 위에서 회전 결과를 상태로 검증한다. + * {@link RefreshToken}의 @Id가 토큰 문자열이므로 delete/save가 같은 키를 가리키면 회전이 무효화된다. + */ +@ExtendWith(MockitoExtension.class) +class AuthApplicationReissueRotationTest { + + private static final Long USER_ID = 42L; + + private final JwtKeyProvider jwtKeyProvider = new JwtKeyProvider(TestTokenFactory.TEST_SECRET); + private final JwtProvider jwtProvider = new JwtProvider(jwtKeyProvider, + TestTokenFactory.ACCESS_TOKEN_EXPIRATION, TestTokenFactory.REFRESH_TOKEN_EXPIRATION); + private final JWTUtil jwtUtil = new JWTUtil(jwtKeyProvider); + private final Map refreshTokenStore = new HashMap<>(); + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private UserDeviceRepository userDeviceRepository; + + @Mock + private UserService userService; + + @Mock + private KakaoService kakaoService; + + @Mock + private AppleService appleService; + + @Mock + private AppleClient appleClient; + + @Mock + private AppleKeyGenerator appleKeyGenerator; + + @Mock + private AppleIdTokenVerifier appleIdTokenVerifier; + + @Mock + private User user; + + private TokenService tokenService; + private AuthApplication authApplication; + + @BeforeEach + void setUp() { + given(user.getUserId()).willReturn(USER_ID); + given(refreshTokenRepository.findByRefreshToken(anyString())) + .willAnswer(invocation -> Optional.ofNullable(refreshTokenStore.get(invocation.getArgument(0)))); + given(refreshTokenRepository.save(any(RefreshToken.class))) + .willAnswer(invocation -> { + RefreshToken saved = invocation.getArgument(0); + refreshTokenStore.put(saved.getRefreshToken(), saved); + return saved; + }); + willAnswer(invocation -> refreshTokenStore.remove( + invocation.getArgument(0).getRefreshToken())) + .given(refreshTokenRepository).delete(any(RefreshToken.class)); + + tokenService = new TokenService(refreshTokenRepository); + authApplication = new AuthApplication(tokenService, jwtProvider, jwtUtil, userDeviceRepository, + userService, kakaoService, appleService, appleClient, appleKeyGenerator, appleIdTokenVerifier); + } + + @DisplayName("재발급에 성공하면 기존 리프레시 토큰은 저장소에서 사라져 다시 재발급에 사용할 수 없다") + @Test + void reissue_afterRotation_rejectsReusedOldRefreshToken() { + String oldRefreshToken = jwtProvider.generateRefreshToken(CustomAuthenticationToken.create(USER_ID)); + tokenService.saveRefreshToken(user, oldRefreshToken); + + ReissueResponse response = authApplication.reissue(oldRefreshToken); + + assertThat(response.refreshToken()).isNotEqualTo(oldRefreshToken); + assertThat(refreshTokenStore).doesNotContainKey(oldRefreshToken); + assertThatThrownBy(() -> authApplication.reissue(oldRefreshToken)) + .isInstanceOf(CustomAuthException.class) + .extracting(throwable -> ((CustomAuthException) throwable).getICustomError()) + .isEqualTo(INVALID_TOKEN); + } + + @DisplayName("재발급된 리프레시 토큰은 저장되어 이어서 다시 재발급할 수 있다") + @Test + void reissue_newRefreshToken_isStoredAndUsableForNextReissue() { + String oldRefreshToken = jwtProvider.generateRefreshToken(CustomAuthenticationToken.create(USER_ID)); + tokenService.saveRefreshToken(user, oldRefreshToken); + + ReissueResponse first = authApplication.reissue(oldRefreshToken); + + assertThat(refreshTokenStore).containsOnlyKeys(first.refreshToken()); + assertThat(refreshTokenStore.get(first.refreshToken()).getUserId()).isEqualTo(USER_ID); + + ReissueResponse second = authApplication.reissue(first.refreshToken()); + + assertThat(second.refreshToken()).isNotEqualTo(first.refreshToken()); + assertThat(refreshTokenStore).containsOnlyKeys(second.refreshToken()); + } +} diff --git a/src/test/java/org/websoso/WSSServer/auth/application/AuthApplicationReissueTest.java b/src/test/java/org/websoso/WSSServer/auth/application/AuthApplicationReissueTest.java new file mode 100644 index 000000000..52f192d3e --- /dev/null +++ b/src/test/java/org/websoso/WSSServer/auth/application/AuthApplicationReissueTest.java @@ -0,0 +1,163 @@ +package org.websoso.WSSServer.auth.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.websoso.WSSServer.exception.error.CustomAuthError.INVALID_TOKEN; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.websoso.WSSServer.auth.client.AppleClient; +import org.websoso.WSSServer.auth.client.AppleIdTokenVerifier; +import org.websoso.WSSServer.auth.client.AppleKeyGenerator; +import org.websoso.WSSServer.auth.client.KakaoService; +import org.websoso.WSSServer.auth.controller.dto.ReissueResponse; +import org.websoso.WSSServer.auth.domain.RefreshToken; +import org.websoso.WSSServer.auth.jwt.JWTUtil; +import org.websoso.WSSServer.auth.jwt.JwtKeyProvider; +import org.websoso.WSSServer.auth.jwt.JwtProvider; +import org.websoso.WSSServer.auth.jwt.TestTokenFactory; +import org.websoso.WSSServer.auth.service.AppleService; +import org.websoso.WSSServer.auth.service.TokenService; +import org.websoso.WSSServer.exception.exception.CustomAuthException; +import org.websoso.WSSServer.notification.repository.UserDeviceRepository; +import org.websoso.WSSServer.user.service.UserService; + +@ExtendWith(MockitoExtension.class) +class AuthApplicationReissueTest { + + private static final Long USER_ID = 42L; + + private final TestTokenFactory testTokenFactory = new TestTokenFactory(); + private final JwtKeyProvider jwtKeyProvider = new JwtKeyProvider(TestTokenFactory.TEST_SECRET); + private final JwtProvider jwtProvider = new JwtProvider(jwtKeyProvider, + TestTokenFactory.ACCESS_TOKEN_EXPIRATION, TestTokenFactory.REFRESH_TOKEN_EXPIRATION); + private final JWTUtil jwtUtil = new JWTUtil(jwtKeyProvider); + + @Mock + private TokenService tokenService; + + @Mock + private UserDeviceRepository userDeviceRepository; + + @Mock + private UserService userService; + + @Mock + private KakaoService kakaoService; + + @Mock + private AppleService appleService; + + @Mock + private AppleClient appleClient; + + @Mock + private AppleKeyGenerator appleKeyGenerator; + + @Mock + private AppleIdTokenVerifier appleIdTokenVerifier; + + private AuthApplication authApplication; + + @BeforeEach + void setUp() { + authApplication = new AuthApplication(tokenService, jwtProvider, jwtUtil, userDeviceRepository, + userService, kakaoService, appleService, appleClient, appleKeyGenerator, appleIdTokenVerifier); + } + + @DisplayName("유효한 리프레시 토큰이면 Access/Refresh Token을 모두 재발급하고 기존 토큰을 회전한다") + @Test + void reissue_validRefreshToken_rotatesAndReturnsNewTokens() { + String oldRefreshToken = testTokenFactory.createRefreshToken(USER_ID); + RefreshToken storedRefreshToken = new RefreshToken(oldRefreshToken, USER_ID); + given(tokenService.findRefreshTokenOrThrow(oldRefreshToken)).willReturn(storedRefreshToken); + + ReissueResponse response = authApplication.reissue(oldRefreshToken); + + assertThat(response.Authorization()).isNotBlank(); + assertThat(response.refreshToken()).isNotBlank(); + assertThat(jwtUtil.getUserIdFromJwt(response.refreshToken())).isEqualTo(USER_ID); + + ArgumentCaptor newRefreshTokenCaptor = ArgumentCaptor.forClass(String.class); + then(tokenService).should().rotateRefreshToken(eq(storedRefreshToken), newRefreshTokenCaptor.capture(), + eq(USER_ID)); + assertThat(newRefreshTokenCaptor.getValue()).isEqualTo(response.refreshToken()); + } + + @DisplayName("재발급된 리프레시 토큰은 요청에 사용한 기존 리프레시 토큰과 다른 값이다") + @Test + void reissue_returnsRefreshTokenDifferentFromOldOne() { + String oldRefreshToken = testTokenFactory.createRefreshToken(USER_ID); + given(tokenService.findRefreshTokenOrThrow(oldRefreshToken)) + .willReturn(new RefreshToken(oldRefreshToken, USER_ID)); + + ReissueResponse response = authApplication.reissue(oldRefreshToken); + + assertThat(response.refreshToken()).isNotEqualTo(oldRefreshToken); + } + + @DisplayName("만료된 리프레시 토큰이면 재발급을 거부한다") + @Test + void reissue_expiredRefreshToken_throwsInvalidToken() { + String expiredRefreshToken = testTokenFactory.createExpiredRefreshToken(USER_ID); + + assertThatThrownBy(() -> authApplication.reissue(expiredRefreshToken)) + .isInstanceOf(CustomAuthException.class) + .extracting(throwable -> ((CustomAuthException) throwable).getICustomError()) + .isEqualTo(INVALID_TOKEN); + + then(tokenService).shouldHaveNoInteractions(); + } + + @DisplayName("변조된(서명이 다른) 리프레시 토큰이면 재발급을 거부한다") + @Test + void reissue_tamperedRefreshToken_throwsInvalidToken() { + String tamperedRefreshToken = testTokenFactory.createRefreshTokenWithInvalidSignature(USER_ID); + + assertThatThrownBy(() -> authApplication.reissue(tamperedRefreshToken)) + .isInstanceOf(CustomAuthException.class) + .extracting(throwable -> ((CustomAuthException) throwable).getICustomError()) + .isEqualTo(INVALID_TOKEN); + + then(tokenService).shouldHaveNoInteractions(); + } + + @DisplayName("Access Token으로 재발급을 시도하면 잘못된 토큰 유형으로 거부한다") + @Test + void reissue_accessTokenGiven_throwsInvalidToken() { + String accessToken = testTokenFactory.createAccessToken(USER_ID); + + assertThatThrownBy(() -> authApplication.reissue(accessToken)) + .isInstanceOf(CustomAuthException.class) + .extracting(throwable -> ((CustomAuthException) throwable).getICustomError()) + .isEqualTo(INVALID_TOKEN); + + then(tokenService).shouldHaveNoInteractions(); + } + + @DisplayName("서명은 유효하지만 저장되지 않은 리프레시 토큰이면 재발급을 거부한다") + @Test + void reissue_unstoredRefreshToken_throwsInvalidToken() { + String refreshToken = testTokenFactory.createRefreshToken(USER_ID); + given(tokenService.findRefreshTokenOrThrow(refreshToken)) + .willThrow(new CustomAuthException(INVALID_TOKEN, "given token is invalid token for reissue")); + + assertThatThrownBy(() -> authApplication.reissue(refreshToken)) + .isInstanceOf(CustomAuthException.class) + .extracting(throwable -> ((CustomAuthException) throwable).getICustomError()) + .isEqualTo(INVALID_TOKEN); + + then(tokenService).should(never()) + .rotateRefreshToken(any(), any(), any()); + } +} diff --git a/src/test/java/org/websoso/WSSServer/auth/jwt/JWTUtilTest.java b/src/test/java/org/websoso/WSSServer/auth/jwt/JWTUtilTest.java index 41ac27ffb..a20dc32b9 100644 --- a/src/test/java/org/websoso/WSSServer/auth/jwt/JWTUtilTest.java +++ b/src/test/java/org/websoso/WSSServer/auth/jwt/JWTUtilTest.java @@ -45,10 +45,18 @@ void validateJWT_expiredRefreshToken_returnsExpiredRefresh() { assertThat(jwtUtil.validateJWT(token)).isEqualTo(JwtValidationType.EXPIRED_REFRESH); } - @DisplayName("다른 시크릿으로 서명된 토큰은 INVALID_SIGNATURE를 반환한다") + @DisplayName("다른 시크릿으로 서명된 Access Token은 INVALID_SIGNATURE를 반환한다") @Test - void validateJWT_wrongSignature_returnsInvalidSignature() { - String token = testTokenFactory.createTokenWithInvalidSignature(USER_ID); + void validateJWT_wrongSignatureAccessToken_returnsInvalidSignature() { + String token = testTokenFactory.createAccessTokenWithInvalidSignature(USER_ID); + + assertThat(jwtUtil.validateJWT(token)).isEqualTo(JwtValidationType.INVALID_SIGNATURE); + } + + @DisplayName("다른 시크릿으로 서명된 Refresh Token은 INVALID_SIGNATURE를 반환한다") + @Test + void validateJWT_wrongSignatureRefreshToken_returnsInvalidSignature() { + String token = testTokenFactory.createRefreshTokenWithInvalidSignature(USER_ID); assertThat(jwtUtil.validateJWT(token)).isEqualTo(JwtValidationType.INVALID_SIGNATURE); } diff --git a/src/test/java/org/websoso/WSSServer/auth/jwt/JwtProviderTest.java b/src/test/java/org/websoso/WSSServer/auth/jwt/JwtProviderTest.java index 4ba687c2b..b508178a5 100644 --- a/src/test/java/org/websoso/WSSServer/auth/jwt/JwtProviderTest.java +++ b/src/test/java/org/websoso/WSSServer/auth/jwt/JwtProviderTest.java @@ -5,6 +5,8 @@ import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; +import java.util.HashSet; +import java.util.Set; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -40,6 +42,42 @@ void generateRefreshToken_hasExpectedClaims() { assertThat(claims.get(JwtProvider.CLAIM_USER_ID).toString()).isEqualTo(USER_ID.toString()); } + @DisplayName("같은 사용자의 Refresh Token을 연속으로 발급해도 서로 다른 문자열이 나온다") + @Test + void generateRefreshToken_consecutiveCalls_produceDistinctTokens() { + CustomAuthenticationToken authentication = CustomAuthenticationToken.create(USER_ID); + + Set tokens = new HashSet<>(); + for (int i = 0; i < 100; i++) { + tokens.add(jwtProvider.generateRefreshToken(authentication)); + } + + assertThat(tokens).hasSize(100); + } + + @DisplayName("Refresh Token 발급 시 jti 클레임에 발급마다 다른 값이 담긴다") + @Test + void generateRefreshToken_hasUniqueJtiClaim() { + CustomAuthenticationToken authentication = CustomAuthenticationToken.create(USER_ID); + + Claims first = parseClaims(jwtProvider.generateRefreshToken(authentication)); + Claims second = parseClaims(jwtProvider.generateRefreshToken(authentication)); + + assertThat(first.getId()).isNotBlank(); + assertThat(second.getId()).isNotBlank(); + assertThat(second.getId()).isNotEqualTo(first.getId()); + } + + @DisplayName("Access Token 발급 시에는 jti 클레임을 추가하지 않는다") + @Test + void generateAccessToken_hasNoJtiClaim() { + CustomAuthenticationToken authentication = CustomAuthenticationToken.create(USER_ID); + + Claims claims = parseClaims(jwtProvider.generateAccessToken(authentication)); + + assertThat(claims.getId()).isNull(); + } + @DisplayName("만료 시간이 지난 토큰도 정상적으로 발급되고 클레임에 만료시간이 반영된다") @Test void generateJWT_negativeExpiration_producesAlreadyExpiredToken() { diff --git a/src/test/java/org/websoso/WSSServer/auth/jwt/TestTokenFactory.java b/src/test/java/org/websoso/WSSServer/auth/jwt/TestTokenFactory.java index 8dc38c0d2..3fc100a7a 100644 --- a/src/test/java/org/websoso/WSSServer/auth/jwt/TestTokenFactory.java +++ b/src/test/java/org/websoso/WSSServer/auth/jwt/TestTokenFactory.java @@ -3,6 +3,7 @@ public class TestTokenFactory { public static final String TEST_SECRET = "test-only-jwt-secret-never-used-in-production-0123456789"; + private static final String OTHER_SECRET = "a-completely-different-test-secret-not-matching-the-real-one-987654"; public static final long ACCESS_TOKEN_EXPIRATION = 3_600_000L; public static final long REFRESH_TOKEN_EXPIRATION = 1_209_600_000L; @@ -32,8 +33,11 @@ public String createExpiredRefreshToken(Long userId) { return jwtProvider.generateJWT(CustomAuthenticationToken.create(userId), -1_000L, TokenType.REFRESH); } - public String createTokenWithInvalidSignature(Long userId) { - return new TestTokenFactory("a-completely-different-test-secret-not-matching-the-real-one-987654") - .createAccessToken(userId); + public String createAccessTokenWithInvalidSignature(Long userId) { + return new TestTokenFactory(OTHER_SECRET).createAccessToken(userId); + } + + public String createRefreshTokenWithInvalidSignature(Long userId) { + return new TestTokenFactory(OTHER_SECRET).createRefreshToken(userId); } } diff --git a/src/test/java/org/websoso/WSSServer/auth/service/TokenServiceTest.java b/src/test/java/org/websoso/WSSServer/auth/service/TokenServiceTest.java new file mode 100644 index 000000000..af512c76f --- /dev/null +++ b/src/test/java/org/websoso/WSSServer/auth/service/TokenServiceTest.java @@ -0,0 +1,109 @@ +package org.websoso.WSSServer.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.websoso.WSSServer.exception.error.CustomAuthError.INVALID_TOKEN; + +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.websoso.WSSServer.auth.domain.RefreshToken; +import org.websoso.WSSServer.auth.repository.RefreshTokenRepository; +import org.websoso.WSSServer.exception.exception.CustomAuthException; +import org.websoso.WSSServer.user.domain.User; + +@ExtendWith(MockitoExtension.class) +class TokenServiceTest { + + private static final Long USER_ID = 1L; + private static final String REFRESH_TOKEN = "refresh-token-value"; + + @InjectMocks + private TokenService tokenService; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Mock + private User user; + + @DisplayName("리프레시 토큰을 저장한다") + @Test + void savesRefreshToken() { + given(user.getUserId()).willReturn(USER_ID); + ArgumentCaptor captor = ArgumentCaptor.forClass(RefreshToken.class); + + tokenService.saveRefreshToken(user, REFRESH_TOKEN); + + then(refreshTokenRepository).should().save(captor.capture()); + assertThat(captor.getValue().getRefreshToken()).isEqualTo(REFRESH_TOKEN); + assertThat(captor.getValue().getUserId()).isEqualTo(USER_ID); + } + + @DisplayName("저장된 리프레시 토큰을 조회한다") + @Test + void findsStoredRefreshToken() { + RefreshToken stored = new RefreshToken(REFRESH_TOKEN, USER_ID); + given(refreshTokenRepository.findByRefreshToken(REFRESH_TOKEN)).willReturn(Optional.of(stored)); + + RefreshToken result = tokenService.findRefreshTokenOrThrow(REFRESH_TOKEN); + + assertThat(result).isSameAs(stored); + } + + @DisplayName("저장되지 않은 리프레시 토큰을 조회하면 예외가 발생한다") + @Test + void rejectsUnknownRefreshToken() { + given(refreshTokenRepository.findByRefreshToken(REFRESH_TOKEN)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> tokenService.findRefreshTokenOrThrow(REFRESH_TOKEN)) + .isInstanceOf(CustomAuthException.class) + .extracting(throwable -> ((CustomAuthException) throwable).getICustomError()) + .isEqualTo(INVALID_TOKEN); + } + + @DisplayName("기존 리프레시 토큰을 삭제하고 새 리프레시 토큰을 저장한다") + @Test + void rotatesRefreshToken() { + RefreshToken oldToken = new RefreshToken(REFRESH_TOKEN, USER_ID); + String newTokenValue = "new-refresh-token-value"; + ArgumentCaptor captor = ArgumentCaptor.forClass(RefreshToken.class); + + tokenService.rotateRefreshToken(oldToken, newTokenValue, USER_ID); + + then(refreshTokenRepository).should().delete(oldToken); + then(refreshTokenRepository).should().save(captor.capture()); + assertThat(captor.getValue().getRefreshToken()).isEqualTo(newTokenValue); + assertThat(captor.getValue().getUserId()).isEqualTo(USER_ID); + } + + @DisplayName("저장된 리프레시 토큰을 삭제한다") + @Test + void deletesStoredRefreshToken() { + RefreshToken stored = new RefreshToken(REFRESH_TOKEN, USER_ID); + given(refreshTokenRepository.findByRefreshToken(REFRESH_TOKEN)).willReturn(Optional.of(stored)); + + tokenService.deleteRefreshToken(REFRESH_TOKEN); + + then(refreshTokenRepository).should().delete(stored); + } + + @DisplayName("저장되지 않은 리프레시 토큰을 삭제해도 예외 없이 무시한다") + @Test + void ignoresDeletingUnknownRefreshToken() { + given(refreshTokenRepository.findByRefreshToken(REFRESH_TOKEN)).willReturn(Optional.empty()); + + tokenService.deleteRefreshToken(REFRESH_TOKEN); + + then(refreshTokenRepository).should(never()).delete(ArgumentMatchers.any()); + } +}