diff --git a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java index 516f722..a1ab1d2 100644 --- a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java +++ b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java @@ -186,12 +186,9 @@ class TokenServiceTest { @Test void revokedFamily_throwsNotAuthenticated() { - TokenFamily tokenFamily = new TokenFamily(); - tokenFamily.setId(1L); + TokenFamily tokenFamily = activeFamily(); tokenFamily.setRevokedAt(Instant.now(clock)); - - RefreshToken refreshToken = new RefreshToken(); - refreshToken.setTokenFamily(tokenFamily); + RefreshToken refreshToken = tokenIn(tokenFamily); clock.advance(Duration.ofMinutes(1)); @@ -208,14 +205,8 @@ class TokenServiceTest { // Happy Path @Test void unusedToken_returnsUserIdAndRefreshToken() { - TokenFamily tokenFamily = new TokenFamily(); - tokenFamily.setId(1L); - tokenFamily.setUser(createUser(1L, "user", "user")); - tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10)); - - RefreshToken refreshToken = new RefreshToken(); - refreshToken.setTokenFamily(tokenFamily); - refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10)); + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); when(refreshTokenRepository.findByHash(any())) .thenReturn(Optional.of(refreshToken)); @@ -228,14 +219,8 @@ class TokenServiceTest { @Test void unsedToken_savesRefreshToken() { - TokenFamily tokenFamily = new TokenFamily(); - tokenFamily.setId(1L); - tokenFamily.setUser(createUser(1L, "user", "user")); - tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10)); - - RefreshToken refreshToken = new RefreshToken(); - refreshToken.setTokenFamily(tokenFamily); - refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10)); + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); when(refreshTokenRepository.findByHash(any())) .thenReturn(Optional.of(refreshToken)); @@ -257,14 +242,8 @@ class TokenServiceTest { @Test void ununsedToken_savesOldTokenWithUsedAt() { - TokenFamily tokenFamily = new TokenFamily(); - tokenFamily.setId(1L); - tokenFamily.setUser(createUser(1L, "user", "user")); - tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10)); - - RefreshToken refreshToken = new RefreshToken(); - refreshToken.setTokenFamily(tokenFamily); - refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10)); + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); when(refreshTokenRepository.findByHash(any())) .thenReturn(Optional.of(refreshToken)); @@ -283,14 +262,8 @@ class TokenServiceTest { @Test void unusedToken_doesNotRevokeFamily() { - TokenFamily tokenFamily = new TokenFamily(); - tokenFamily.setId(1L); - tokenFamily.setUser(createUser(1L, "user", "user")); - tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10)); - - RefreshToken refreshToken = new RefreshToken(); - refreshToken.setTokenFamily(tokenFamily); - refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10)); + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); when(refreshTokenRepository.findByHash(any())) .thenReturn(Optional.of(refreshToken)); @@ -307,15 +280,9 @@ class TokenServiceTest { @Test void unusedToken_newTokenIsDifferentFromOldToken() { - TokenFamily tokenFamily = new TokenFamily(); - tokenFamily.setId(1L); - tokenFamily.setUser(createUser(1L, "user", "user")); - tokenFamily.setExpiresAt(Instant.now(clock).plusSeconds(10)); - - RefreshToken refreshToken = new RefreshToken(); + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); refreshToken.setTokenHash(rawToken.getBytes()); - refreshToken.setTokenFamily(tokenFamily); - refreshToken.setExpiresAt(Instant.now(clock).plusSeconds(10)); when(refreshTokenRepository.findByHash(any())) .thenReturn(Optional.of(refreshToken)); @@ -329,15 +296,9 @@ class TokenServiceTest { void usedWithinGrace_returnsNewToken() { Instant usedAt = Instant.now(clock); - TokenFamily tokenFamily = new TokenFamily(); - tokenFamily.setId(1L); - tokenFamily.setUser(createUser(1L, "user", "user")); - tokenFamily.setExpiresAt(usedAt.plusSeconds(10)); - - RefreshToken refreshToken = new RefreshToken(); + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); refreshToken.setTokenHash(rawToken.getBytes()); - refreshToken.setTokenFamily(tokenFamily); - refreshToken.setExpiresAt(usedAt.plusSeconds(10)); refreshToken.setUsedAt(usedAt); when(refreshTokenRepository.findByHash(any())) @@ -362,5 +323,215 @@ class TokenServiceTest { assertSame(tokenFamily, newToken.getTokenFamily()); assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash()); } + + @Test + void usedAtExactGraceBoundary_returnsNewToken() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(authProperties.expiration().gracePeriod()); + + TokenService.Rotation rotation = tokenService.rotate(rawToken); + + assertEquals(1L, rotation.userId()); + assertNull(tokenFamily.getRevokedAt()); + } + + // Reuse after grace period + @Test + void usedAfterGrace_throwsNotAuthenticated() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + } + + @Test + void usedAfterGrace_revokesFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + verify(tokenFamilyRepository).save(familyCaptor.capture()); + assertSame(tokenFamily, familyCaptor.getValue()); + assertEquals(clock.instant(), tokenFamily.getRevokedAt()); + } + + @Test + void usedAfterGrace_doesNotIssueNewToken() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + verify(refreshTokenRepository, never()).save(any()); + } + + @Test + void usedAfterGrace_subsequentRotateRejectedWithoutResaving() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + verify(tokenFamilyRepository, times(1)).save(any()); + } + + // Expiry + @Test + void expiredToken_throwsNotAuthenticated() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setExpiresAt(clock.instant().minusSeconds(1)); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + } + + @Test + void expiredFamily_throwsNotAuthenticated() { + TokenFamily tokenFamily = activeFamily(); + tokenFamily.setExpiresAt(clock.instant().minusSeconds(1)); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + } + + @Test + void expiredToken_doesNotSaveOrRevoke() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(11)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + verify(refreshTokenRepository, never()).save(any()); + verify(tokenFamilyRepository, never()).save(any()); + assertNull(refreshToken.getUsedAt()); + assertNull(tokenFamily.getRevokedAt()); + } + + @Test + void tokenAtExactExpiry_returnsNewToken() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(10)); + + TokenService.Rotation rotation = tokenService.rotate(rawToken); + + assertEquals(1L, rotation.userId()); + } + + // Ordering + @Test + void revokedFamilyAndUsedAfterGrace_doesNotRevokeAgain() { + TokenFamily tokenFamily = activeFamily(); + Instant revokedAt = clock.instant(); + tokenFamily.setRevokedAt(revokedAt); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(6)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + assertEquals(revokedAt, tokenFamily.getRevokedAt()); + verify(tokenFamilyRepository, never()).save(any()); + } + + @Test + void revokedAndExpiredFamily_throwsNotAuthenticated() { + TokenFamily tokenFamily = activeFamily(); + tokenFamily.setRevokedAt(clock.instant()); + RefreshToken refreshToken = tokenIn(tokenFamily); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(11)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + } + + @Test + void expiredAndUsedAfterGrace_revokesFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(11)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + assertEquals(clock.instant(), tokenFamily.getRevokedAt()); + verify(tokenFamilyRepository).save(tokenFamily); + } + + @Test + void expiredWithinGrace_doesNotRevokeFamily() { + TokenFamily tokenFamily = activeFamily(); + RefreshToken refreshToken = tokenIn(tokenFamily); + refreshToken.setExpiresAt(clock.instant().plusSeconds(2)); + refreshToken.setUsedAt(clock.instant()); + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken)); + + clock.advance(Duration.ofSeconds(3)); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + assertNull(tokenFamily.getRevokedAt()); + verify(tokenFamilyRepository, never()).save(any()); + verify(refreshTokenRepository, never()).save(any()); + } + + @Test + void unknownToken_doesNotTouchRepositoriesFurther() { + when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty()); + + assertThrows(NotAuthenticated.class, () -> tokenService.rotate(rawToken)); + + verify(refreshTokenRepository, never()).save(any()); + verifyNoInteractions(tokenFamilyRepository); + } + + private TokenFamily activeFamily() { + TokenFamily tokenFamily = new TokenFamily(); + tokenFamily.setId(1L); + tokenFamily.setUser(createUser(1L, "user", "user")); + tokenFamily.setExpiresAt(clock.instant().plusSeconds(10)); + return tokenFamily; + } + + private RefreshToken tokenIn(TokenFamily tokenFamily) { + RefreshToken refreshToken = new RefreshToken(); + refreshToken.setTokenFamily(tokenFamily); + refreshToken.setExpiresAt(clock.instant().plusSeconds(10)); + return refreshToken; + } } } \ No newline at end of file