diff --git a/pom.xml b/pom.xml index 526401d..b851b5d 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 4.0.6 + 4.0.2 com.amigoscode @@ -87,6 +87,19 @@ org.springframework.boot spring-boot-maven-plugin + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + -javaagent:${settings.localRepository}/net/bytebuddy/byte-buddy-agent/1.17.8/byte-buddy-agent-1.17.8.jar + -Dnet.bytebuddy.experimental=true + --add-opens java.base/java.lang=ALL-UNNAMED + --add-opens java.base/java.lang.reflect=ALL-UNNAMED + + + diff --git a/src/test/java/com/amigoscode/RefreshTokenServiceTest.java b/src/test/java/com/amigoscode/RefreshTokenServiceTest.java new file mode 100644 index 0000000..225303c --- /dev/null +++ b/src/test/java/com/amigoscode/RefreshTokenServiceTest.java @@ -0,0 +1,268 @@ +package com.amigoscode; + +import com.amigoscode.auth.refresh.RefreshToken; +import com.amigoscode.auth.refresh.RefreshTokenRepository; +import com.amigoscode.auth.refresh.RefreshTokenResponse; +import com.amigoscode.auth.refresh.RefreshTokenService; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +class RefreshTokenServiceTest { + + @Mock + private RefreshTokenRepository refreshTokenRepository; + @Mock + private ApplicationUserRepository applicationUserRepository; + @InjectMocks + private RefreshTokenService underTest; + + private ApplicationUser applicationUser; + private RefreshToken currentToken; + + @BeforeEach + void setUp() { + applicationUser = new ApplicationUser("iakovos", "encryptedPassword", Set.of()); + currentToken = new RefreshToken( + "current-token", + applicationUser, + LocalDateTime.now().plusDays(3), + LocalDateTime.now().minusDays(4) + ); + } + + @Test + @DisplayName("Generate token User not found - throws") + void generateRefreshToken_shouldThrowWhenUserNotFound(){ + //user not found = + //1.applicationUserRepository returns Optional.empty() + //2.method throws new UsernameNotFoundException("User not found") + //3.refreshTokenRepository.save will NOT execute. + given(applicationUserRepository.findApplicationUserByUsername(anyString())) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> underTest.generateRefreshToken("iakovos")) + .isInstanceOf(UsernameNotFoundException.class) + .hasMessageContaining("User not found"); + + then(applicationUserRepository).should(times(1)).findApplicationUserByUsername(anyString()); + then(refreshTokenRepository).should(never()).save(any()); + then(applicationUserRepository).shouldHaveNoMoreInteractions(); + then(refreshTokenRepository).shouldHaveNoInteractions(); + + } + + @Test + @DisplayName("Generate token - success") + void generateRefreshToken_shouldGenerateRefreshToken(){ + //generate token successfully = + //1.applicationUserRepository.findApplicationUserByUsername returns an existing ApplicationUser + //2.refreshTokenRepository.save() saves successfully a new RefreshToken + //3.returns a RefreshTokenResponse + //4.applicationUserRepository.findApplicationUserByUsername was called only once + //5.refreshTokenRepository.save() was called only once + + + given(applicationUserRepository.findApplicationUserByUsername(anyString())) + .willReturn(Optional.of(applicationUser)); + + /* + * I used willAnswer() here so save() returns the exact RefreshToken created and passed, without creating a separate instance + * willAnswer() dynamically determines what to return based on the invocation. You want to return the same object that was passed to save() + * willReturn() always returns a predefined object. You have a known, predefined currentToken + * + * If I used given(refreshTokenRepository.save(currentToken)).willReturn(currentToken); + * I get "org.mockito.exceptions.misusing.PotentialStubbingProblem: Strict stubbing argument mismatch." + * */ + given(refreshTokenRepository.save(any(RefreshToken.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + + RefreshTokenResponse response = underTest.generateRefreshToken("iakovos"); + + assertThat(response.applicationUser()).isEqualTo(applicationUser); + then(applicationUserRepository).should(times(1)).findApplicationUserByUsername(anyString()); + then(refreshTokenRepository).should(times(1)).save(any(RefreshToken.class)); + then(applicationUserRepository).shouldHaveNoMoreInteractions(); + then(refreshTokenRepository).shouldHaveNoMoreInteractions(); + + } + + @Test + @DisplayName("Rotate Invalid token - throws") + void rotateAndGetNewToken_shouldThrowWhenTokenIsInvalid(){ + //token is invalid = + //1.refreshTokenRepository.findByToken(token) returns Optional.empty() + //2.method throws new BadCredentialsException("Invalid refresh token") + //3.refreshTokenRepository.save() will NOT execute. + + given(refreshTokenRepository.findByToken(anyString())).willReturn(Optional.empty()); + + assertThatThrownBy(() -> underTest.rotateAndGetNewToken("invalid-token")) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("Invalid refresh token"); + + then(refreshTokenRepository).should(times(1)).findByToken(anyString()); + then(refreshTokenRepository).should(never()).save(any(RefreshToken.class)); + + } + + @Test + @DisplayName("Rotate Token already Revoked - throws") + void rotateAndGetNewToken_shouldThrowWhenTokenIsAlreadyRevoked(){ + //token already revoked = + //1.refreshTokenRepository.findByToken returns a RefreshToken -> use the currentToken + //2.currentToken.isRevoked() is true + //3.method throws BadCredentialsException("Refresh token is revoked") + //4.refreshTokenRepository.save will NOT execute + + currentToken.setRevoked(true); + given(refreshTokenRepository.findByToken("current-token")) + .willReturn(Optional.of(currentToken)); + + + assertThatThrownBy(() -> underTest.rotateAndGetNewToken("current-token")) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("Refresh token is revoked"); + + then(refreshTokenRepository).should(times(1)).findByToken(anyString()); + then(refreshTokenRepository).should(never()).save(any(RefreshToken.class)); + then(refreshTokenRepository).shouldHaveNoMoreInteractions(); + + } + + @Test + @DisplayName("Rotate Token is expired - throws") + void rotateAndGetNewToken_shouldThrowWhenTokenIsExpired(){ + //token is expired = + //1.refreshTokenRepository.findByToken() returns a RefreshToken -> use the currentToken and change the expireDate + //2.currentToken getExpiresAt().isBefore(LocalDateTime.now()) + //3.refreshTokenRepository.save will NOT execute + + currentToken.setExpiresAt(LocalDateTime.now().minusDays(1)); + given(refreshTokenRepository.findByToken("current-token")).willReturn(Optional.of(currentToken)); + + assertThatThrownBy(() -> underTest.rotateAndGetNewToken("current-token")) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("Refresh token is expired"); + + then(refreshTokenRepository).should(times(1)).findByToken(anyString()); + then(refreshTokenRepository).should(never()).save(any(RefreshToken.class)); + then(refreshTokenRepository).shouldHaveNoMoreInteractions(); + + } + + @Test + @DisplayName("Rotate and Generate Refresh Token - success") + void rotateAndGetNewToken_shouldRotateAndGetNewToken(){ + // Revoke and Generate Refresh Token = + //1.refreshTokenRepository.findByToken() returns a RefreshToken - use currentToken + //2.ensure currentToken = true + //3.refreshTokenRepository.save(currentToken) will save the currentToken which revoked + //4.refreshTokenRepository.save() will be called for new RefreshToken + //5.method will return new RefreshTokenResponse with the new token. + + /* + * I used willAnswer() here so save() returns the exact RefreshToken created and passed, without creating a separate instance + * willAnswer() dynamically determines what to return based on the invocation. You want to return the same object that was passed to save() + * willReturn() always returns a predefined object. You have a known, predefined currentToken + * */ + given(refreshTokenRepository.findByToken("current-token")).willReturn(Optional.of(currentToken)); + given(refreshTokenRepository.save(any(RefreshToken.class))).willAnswer(invocation -> invocation.getArgument(0)); + + RefreshTokenResponse response = underTest.rotateAndGetNewToken("current-token"); + + + ArgumentCaptor refreshTokenCaptor = ArgumentCaptor.forClass(RefreshToken.class); + assertThat(response.refreshToken()).isNotEqualTo(currentToken.getToken()); + assertThat(response.applicationUser()).isEqualTo(applicationUser); + + then(refreshTokenRepository).should(times(1)).findByToken(anyString()); + then(refreshTokenRepository).should(times(2)).save(refreshTokenCaptor.capture()); + then(refreshTokenRepository).shouldHaveNoMoreInteractions(); + RefreshToken revokedToken = refreshTokenCaptor.getAllValues().getFirst(); + assertThat(revokedToken.getToken()).isEqualTo("current-token"); + assertThat(revokedToken.isRevoked()).isTrue(); + + } + + @Test + @DisplayName("Revoke Invalid token - throws") + void revokeRefreshToken_shouldThrowWhenTokenIsInvalid(){ + given(refreshTokenRepository.findByToken("invalid-token")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> underTest.revokeRefreshToken("invalid-token")) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("Invalid refresh token"); + + then(refreshTokenRepository).should().findByToken("invalid-token"); + then(refreshTokenRepository).should(never()).save(any(RefreshToken.class)); + + } + + + @Test + @DisplayName("Revoke Token already Revoked - throws") + void revokeRefreshToken_shouldThrowWhenTokenIsAlreadyRevoked(){ + currentToken.setRevoked(true); + given(refreshTokenRepository.findByToken(anyString())).willReturn(Optional.of(currentToken)); + + assertThatThrownBy(() -> underTest.revokeRefreshToken("already-revoked-token")) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("Refresh token is revoked"); + + + then(refreshTokenRepository).should().findByToken(anyString()); + then(refreshTokenRepository).should(never()).save(any(RefreshToken.class)); + } + + @Test + @DisplayName("Revoke Token is expired - throws") + void revokeRefreshToken_shouldThrowWhenTokenIsExpired(){ + currentToken.setExpiresAt(LocalDateTime.now().minusDays(1)); + given(refreshTokenRepository.findByToken("expired-token")).willReturn(Optional.of(currentToken)); + + assertThatThrownBy(() -> underTest.revokeRefreshToken("expired-token")) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("Refresh token is expired"); + + then(refreshTokenRepository).should(times(1)).findByToken(anyString()); + then(refreshTokenRepository).should(never()).save(any(RefreshToken.class)); + } + + + @Test + @DisplayName("Revoke Refresh Token - success") + void revokeRefreshToken_shouldRevokeRefreshToken(){ + given(refreshTokenRepository.findByToken("current-token")).willReturn(Optional.of(currentToken)); + + underTest.revokeRefreshToken("current-token"); + + ArgumentCaptor refreshTokenCaptor = ArgumentCaptor.forClass(RefreshToken.class); + + then(refreshTokenRepository).should().findByToken("current-token"); + then(refreshTokenRepository).should().save(refreshTokenCaptor.capture()); + + RefreshToken revokedToken = refreshTokenCaptor.getValue(); + assertThat(revokedToken.isRevoked()).isTrue(); + assertThat(revokedToken.getToken()).isEqualTo("current-token"); + + } +}