Files
vod-system/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java
Dylan De Faoite cf951afbae
All checks were successful
CI / Maven unit tests (push) Successful in 18s
CI / Build and push images (push) Has been skipped
CI / Deploy to server (push) Has been skipped
fix revokation bugs and add unit tests
2026-10-15 20:55:40 +01:00

629 lines
23 KiB
Java

package com.ddf.vodsystem.security;
import com.ddf.vodsystem.dto.properties.AuthProperties;
import com.ddf.vodsystem.entities.RefreshToken;
import com.ddf.vodsystem.entities.TokenFamily;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.RefreshTokenRepository;
import com.ddf.vodsystem.repositories.TokenFamilyRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.security.MessageDigest;
import java.time.*;
import java.util.HexFormat;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
class MutableClock extends Clock {
private Instant now;
MutableClock(Instant start) {
this.now = start;
}
void advance(Duration d) {
now = now.plus(d);
}
@Override public Instant instant() { return now; }
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
@Override public Clock withZone(ZoneId zone) { return this; }
}
@ExtendWith(MockitoExtension.class)
class TokenServiceTest {
@Mock private TokenFamilyRepository tokenFamilyRepository;
@Mock private RefreshTokenRepository refreshTokenRepository;
@Captor ArgumentCaptor<TokenFamily> familyCaptor;
@Captor ArgumentCaptor<RefreshToken> tokenCaptor;
private static final HexFormat HEX = HexFormat.of();
private static final String rawToken = "deadbeefdeadbeefdeadbeefdeadbeef";
AuthProperties authProperties;
TokenService tokenService;
MutableClock clock;
User createUser(Long id, String username, String email) {
User user = new User();
user.setId(id);
user.setUsername(username);
user.setEmail(email);
return user;
}
@BeforeEach
void setUp() {
authProperties = new AuthProperties(
"a-secret",
new AuthProperties.Expiration(
Duration.ofSeconds(10),
Duration.ofSeconds(10),
Duration.ofSeconds(10),
Duration.ofSeconds(5)
)
);
clock = new MutableClock(Instant.parse("2026-01-01T00:00:00Z"));
tokenService = new TokenService(
tokenFamilyRepository,
refreshTokenRepository,
authProperties,
clock
);
}
@Nested
@DisplayName("createSession()")
class createSession {
@Test
void createSession_createsTokenFamily() {
User user = createUser(1L, "user", "user@gmail.com");
tokenService.createSession(user);
verify(tokenFamilyRepository).save(familyCaptor.capture());
verify(refreshTokenRepository).save(tokenCaptor.capture());
TokenFamily tokenFamily = familyCaptor.getValue();
RefreshToken refreshToken = tokenCaptor.getValue();
assertSame(tokenFamily.getUser(), user);
assertSame(refreshToken.getTokenFamily(), tokenFamily);
assertEquals(
tokenFamily.getExpiresAt(),
clock.instant().plusMillis(authProperties.expiration().tokenFamily().toMillis())
);
}
@Test
void createSession_savesRefreshTokenCreatedAtAndExpires() {
User user = createUser(1L, "user", "user");
tokenService.createSession(user);
verify(refreshTokenRepository).save(tokenCaptor.capture());
RefreshToken refreshToken = tokenCaptor.getValue();
assertEquals(refreshToken.getCreatedAt(), clock.instant());
assertEquals(
refreshToken.getExpiresAt(),
clock.instant().plusMillis(authProperties.expiration().refreshToken().toMillis())
);
}
@Test
void createSession_returnsHexTokenOf64Characters() {
User user = createUser(1L, "user", "user");
String token = tokenService.createSession(user);
assertEquals(64, token.length());
assertTrue(token.matches("[0-9a-fA-F]+"), () -> "not hex: " + token);
}
@Test
void createSession_doesNotStoreRawToken() {
User user = createUser(1L, "user", "user");
String token = tokenService.createSession(user);
verify(refreshTokenRepository).save(tokenCaptor.capture());
RefreshToken refreshToken = tokenCaptor.getValue();
assertNotEquals(refreshToken.getTokenHash(), HEX.parseHex(token));
}
}
// Rotate
@Nested
@DisplayName("rotate()")
class rotate {
// Input val
@Test
void nullToken_throwsNotAuthenticated() {
assertThrows(
NotAuthenticated.class,
() -> tokenService.rotate(null)
);
}
@Test
void blankToken_throwsNotAuthenticated() {
assertThrows(
NotAuthenticated.class,
() -> tokenService.rotate("")
);
}
// Invalid cases
@Test
void unknownToken_throwsNotAuthenticated() {
when(refreshTokenRepository.findByHash(any())).thenReturn(
Optional.empty()
);
assertThrows(
NotAuthenticated.class,
() -> tokenService.rotate(rawToken)
);
}
@Test
void revokedFamily_throwsNotAuthenticated() {
TokenFamily tokenFamily = activeFamily();
tokenFamily.setRevokedAt(Instant.now(clock));
RefreshToken refreshToken = tokenIn(tokenFamily);
clock.advance(Duration.ofMinutes(1));
when(refreshTokenRepository.findByHash(any())).thenReturn(
Optional.of(refreshToken)
);
assertThrows(
NotAuthenticated.class,
() -> tokenService.rotate(rawToken)
);
}
// Happy Path
@Test
void unusedToken_returnsUserIdAndRefreshToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
TokenService.Rotation rotation = tokenService.rotate(rawToken);
assertEquals(1L, rotation.userId());
assertNotEquals(rawToken, rotation.newRefreshToken());
}
@Test
void unsedToken_savesRefreshToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
tokenService.rotate(rawToken);
verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture());
RefreshToken newToken = tokenCaptor.getAllValues().stream()
.filter(t -> t.getUsedAt() == null)
.reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); })
.orElseThrow(() -> new AssertionError("no unused token was saved"));
assertNotEquals(refreshToken.getTokenHash(), newToken.getTokenHash());
assertEquals(refreshToken.getTokenFamily().getUser(), newToken.getTokenFamily().getUser()); // same owner
assertEquals(refreshToken.getTokenFamily(), newToken.getTokenFamily()); // same rotation family, if you model one
assertNull(newToken.getUsedAt());
}
@Test
void ununsedToken_savesOldTokenWithUsedAt() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
tokenService.rotate(rawToken);
verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture());
RefreshToken savedToken = tokenCaptor.getAllValues().stream()
.filter(t -> t.getUsedAt() != null)
.reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); })
.orElseThrow(() -> new AssertionError("used_at field not set in any token"));
assertEquals(savedToken.getUsedAt(), clock.instant());
}
@Test
void unusedToken_doesNotRevokeFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
tokenService.rotate(rawToken);
verify(refreshTokenRepository, times(2)).save(tokenCaptor.capture());
RefreshToken newToken = tokenCaptor.getAllValues().stream()
.filter(t -> t.getUsedAt() == null)
.reduce((a, b) -> { throw new AssertionError("expected exactly one unused token saved"); })
.orElseThrow(() -> new AssertionError("no unused token was saved"));
assertNull(newToken.getTokenFamily().getRevokedAt());
}
@Test
void unusedToken_newTokenIsDifferentFromOldToken() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setTokenHash(rawToken.getBytes());
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
TokenService.Rotation rotation = tokenService.rotate(rawToken);
assertNotEquals(rawToken, rotation.newRefreshToken());
}
// Grace period checks
@Test
void usedWithinGrace_returnsNewToken() {
Instant usedAt = Instant.now(clock);
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setTokenHash(rawToken.getBytes());
refreshToken.setUsedAt(usedAt);
when(refreshTokenRepository.findByHash(any()))
.thenReturn(Optional.of(refreshToken));
// within grace: 3s since usedAt, grace is 5s, and nothing has expired yet
clock.advance(Duration.ofSeconds(3));
TokenService.Rotation rotation = tokenService.rotate(rawToken);
assertEquals(1L, rotation.userId());
assertNotEquals(rawToken, rotation.newRefreshToken());
assertNull(tokenFamily.getRevokedAt());
verify(tokenFamilyRepository, never()).save(any());
// grace is not extended by a replay, and only the new token is written
assertEquals(usedAt, refreshToken.getUsedAt());
verify(refreshTokenRepository, times(1)).save(tokenCaptor.capture());
RefreshToken newToken = tokenCaptor.getValue();
assertNull(newToken.getUsedAt());
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);
}
}
@Nested
@DisplayName("revoke()")
class revoke {
@Test
void knownToken_revokesTokenAndFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
tokenService.revoke(rawToken);
assertEquals(clock.instant(), tokenFamily.getRevokedAt());
assertEquals(clock.instant(), refreshToken.getRevokedAt());
}
@Test
void knownToken_savesTokenAndFamily() {
TokenFamily tokenFamily = activeFamily();
RefreshToken refreshToken = tokenIn(tokenFamily);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
tokenService.revoke(rawToken);
verify(tokenFamilyRepository).save(tokenFamily);
verify(refreshTokenRepository).save(refreshToken);
}
@Test
void knownToken_looksUpBySingleSha256Hash() throws Exception {
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty());
tokenService.revoke(rawToken);
byte[] expectedHash = MessageDigest.getInstance("SHA-256").digest(HEX.parseHex(rawToken));
verify(refreshTokenRepository).findByHash(expectedHash);
}
@Test
void alreadyRevoked_keepsOriginalTimestampsAndDoesNotSave() {
Instant revokedAt = clock.instant();
TokenFamily tokenFamily = activeFamily();
tokenFamily.setRevokedAt(revokedAt);
RefreshToken refreshToken = tokenIn(tokenFamily);
refreshToken.setRevokedAt(revokedAt);
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.of(refreshToken));
clock.advance(Duration.ofMinutes(1));
tokenService.revoke(rawToken);
assertEquals(revokedAt, tokenFamily.getRevokedAt());
assertEquals(revokedAt, refreshToken.getRevokedAt());
verify(tokenFamilyRepository, never()).save(any());
verify(refreshTokenRepository, never()).save(any());
}
@Test
void unknownToken_doesNothing() {
when(refreshTokenRepository.findByHash(any())).thenReturn(Optional.empty());
tokenService.revoke(rawToken);
verify(refreshTokenRepository, never()).save(any());
verifyNoInteractions(tokenFamilyRepository);
}
@Test
void nullToken_doesNothing() {
tokenService.revoke(null);
verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository);
}
@Test
void blankToken_doesNothing() {
tokenService.revoke("");
verifyNoInteractions(refreshTokenRepository, tokenFamilyRepository);
}
@Test
void malformedToken_throwsNotAuthenticated() {
assertThrows(
NotAuthenticated.class,
() -> tokenService.revoke("not-hex")
);
verifyNoInteractions(refreshTokenRepository, 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;
}
}