From 34c4072c5bf18fa0a240dcb2ba420c04beb34162 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Fri, 16 Oct 2026 20:56:22 +0100 Subject: [PATCH] test(security): user & token services tests (#53) Reviewed-on: https://gitea.defaoite.dev/dylan/vod-system/pulls/53 --- .../vodsystem/security/TokenServiceTest.java | 628 ++++++++++++++++++ .../vodsystem/services/UserServiceTest.java | 295 ++++++++ 2 files changed, 923 insertions(+) create mode 100644 src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java create mode 100644 src/test/java/com/ddf/vodsystem/services/UserServiceTest.java diff --git a/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java new file mode 100644 index 0000000..68b62ee --- /dev/null +++ b/src/test/java/com/ddf/vodsystem/security/TokenServiceTest.java @@ -0,0 +1,628 @@ +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 familyCaptor; + @Captor ArgumentCaptor 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; + } +} diff --git a/src/test/java/com/ddf/vodsystem/services/UserServiceTest.java b/src/test/java/com/ddf/vodsystem/services/UserServiceTest.java new file mode 100644 index 0000000..e6249ce --- /dev/null +++ b/src/test/java/com/ddf/vodsystem/services/UserServiceTest.java @@ -0,0 +1,295 @@ +package com.ddf.vodsystem.services; + +import com.ddf.vodsystem.dto.GoogleUser; +import com.ddf.vodsystem.dto.TokenPackage; +import com.ddf.vodsystem.entities.User; +import com.ddf.vodsystem.exceptions.NotAuthenticated; +import com.ddf.vodsystem.repositories.UserRepository; +import com.ddf.vodsystem.security.GoogleVerifierService; +import com.ddf.vodsystem.security.JwtService; +import com.ddf.vodsystem.security.TokenService; +import org.junit.jupiter.api.AfterEach; +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 org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + @Mock private GoogleVerifierService googleVerifierService; + @Mock private JwtService jwtService; + @Mock private UserRepository userRepository; + @Mock private TokenService tokenService; + + @Captor ArgumentCaptor userCaptor; + + private static final String ID_TOKEN = "google-id-token"; + private static final String REFRESH_TOKEN = "deadbeefdeadbeefdeadbeefdeadbeef"; + private static final String NEW_REFRESH_TOKEN = "cafebabecafebabecafebabecafebabe"; + private static final String JWT = "header.payload.signature"; + private static final String GOOGLE_ID = "google-id"; + + UserService userService; + + User createUser(Long id, String username, String email) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setEmail(email); + return user; + } + + GoogleUser googleUser() { + return new GoogleUser( + GOOGLE_ID, + "user@example.com", + "My User", + "https://lol.com/1" + ); + } + + @BeforeEach + void setUp() { + userService = new UserService( + userRepository, + jwtService, + googleVerifierService, + tokenService + ); + } + + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Nested + @DisplayName("getUserById()") + class getUserById { + @Test + void existingId_returnsUser() { + User user = createUser(1L, "user", "user@example.com"); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + Optional result = userService.getUserById(1L); + + assertTrue(result.isPresent()); + assertSame(user, result.get()); + } + + @Test + void unknownId_returnsEmpty() { + when(userRepository.findById(1L)).thenReturn(Optional.empty()); + + assertTrue(userService.getUserById(1L).isEmpty()); + } + } + + @Nested + @DisplayName("getLoggedInUser()") + class getLoggedInUser { + @Test + void authenticatedUserPrincipal_returnsUser() { + User user = createUser(1L, "user", "user@example.com"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null, List.of()) + ); + + Optional result = userService.getLoggedInUser(); + + assertTrue(result.isPresent()); + assertSame(user, result.get()); + } + + @Test + void noAuthentication_returnsEmpty() { + assertTrue(userService.getLoggedInUser().isEmpty()); + } + + @Test + void unauthenticated_returnsEmpty() { + User user = createUser(1L, "user", "user@example.com"); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(user, null) + ); + + assertTrue(userService.getLoggedInUser().isEmpty()); + } + + @Test + void nonUserPrincipal_returnsEmpty() { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("anonymous", null, List.of()) + ); + + assertTrue(userService.getLoggedInUser().isEmpty()); + } + } + + @Nested + @DisplayName("login()") + class login { + @Test + void newUser_savesUserToRepo() { + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + userService.login(ID_TOKEN); + + verify(userRepository).save(userCaptor.capture()); + User saved = userCaptor.getValue(); + assertEquals(GOOGLE_ID, saved.getGoogleId()); + assertEquals("user@example.com", saved.getEmail()); + assertEquals("user@example.com", saved.getUsername()); + assertEquals("My User", saved.getName()); + assertEquals("https://lol.com/1", saved.getProfilePictureUrl()); + assertEquals(0, saved.getRole()); + assertNotNull(saved.getCreatedAt()); + } + + @Test + void newUser_generatesHexStreamKeyOf48Characters() { + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + userService.login(ID_TOKEN); + + verify(userRepository).save(userCaptor.capture()); + String streamKey = userCaptor.getValue().getStreamKey(); + assertEquals(48, streamKey.length()); + assertTrue(streamKey.matches("[0-9a-f]+"), () -> "not hex: " + streamKey); + } + + @Test + void existingUser_updatesUserInfo() { + User existingUser = createUser(1L, "old-username", "old@example.com"); + existingUser.setGoogleId(GOOGLE_ID); + existingUser.setName("Old Name"); + existingUser.setProfilePictureUrl("https://lol.com/old"); + existingUser.setStreamKey("123"); + + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.of(existingUser)); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + userService.login(ID_TOKEN); + + verify(userRepository).save(userCaptor.capture()); + User saved = userCaptor.getValue(); + assertSame(existingUser, saved); + assertEquals(1L, saved.getId()); + assertEquals(GOOGLE_ID, saved.getGoogleId()); + assertEquals("user@example.com", saved.getEmail()); + assertEquals("My User", saved.getName()); + assertEquals("https://lol.com/1", saved.getProfilePictureUrl()); + assertEquals("old-username", saved.getUsername()); + assertEquals("123", saved.getStreamKey()); + } + + @Test + void validToken_returnsRefreshTokenAndJwt() { + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(tokenService.createSession(any())).thenReturn(REFRESH_TOKEN); + when(jwtService.generateToken(any())).thenReturn(JWT); + + TokenPackage tokenPackage = userService.login(ID_TOKEN); + + assertEquals(REFRESH_TOKEN, tokenPackage.refreshToken()); + assertEquals(JWT, tokenPackage.accessToken()); + } + + @Test + void validToken_createsSessionForSavedUser() { + User savedUser = createUser(42L, "user@example.com", "user@example.com"); + + when(googleVerifierService.verify(ID_TOKEN)).thenReturn(googleUser()); + when(userRepository.findByGoogleId(GOOGLE_ID)).thenReturn(Optional.empty()); + when(userRepository.save(any())).thenReturn(savedUser); + + userService.login(ID_TOKEN); + + verify(tokenService).createSession(savedUser); + verify(jwtService).generateToken(42L); + } + + @Test + void invalidToken_throwsNotAuthenticated() { + when(googleVerifierService.verify(ID_TOKEN)) + .thenThrow(new NotAuthenticated("Invalid ID token")); + + assertThrows( + NotAuthenticated.class, + () -> userService.login(ID_TOKEN) + ); + + verifyNoInteractions(userRepository, tokenService, jwtService); + } + } + + @Nested + @DisplayName("refresh()") + class refresh { + @Test + void validToken_returnsRotatedTokenAndJwt() { + when(tokenService.rotate(REFRESH_TOKEN)) + .thenReturn(new TokenService.Rotation(1L, NEW_REFRESH_TOKEN)); + when(jwtService.generateToken(1L)).thenReturn(JWT); + + TokenPackage tokenPackage = userService.refresh(REFRESH_TOKEN); + + assertEquals(NEW_REFRESH_TOKEN, tokenPackage.refreshToken()); + assertEquals(JWT, tokenPackage.accessToken()); + } + + @Test + void invalidToken_throwsNotAuthenticated() { + when(tokenService.rotate(REFRESH_TOKEN)) + .thenThrow(new NotAuthenticated("Invalid refresh token")); + + assertThrows( + NotAuthenticated.class, + () -> userService.refresh(REFRESH_TOKEN) + ); + + verifyNoInteractions(jwtService); + } + } + + @Nested + @DisplayName("logout()") + class logout { + @Test + void revokesRefreshToken() { + userService.logout(REFRESH_TOKEN); + + verify(tokenService).revoke(REFRESH_TOKEN); + } + + @Test + void doesNotIssueTokens() { + userService.logout(REFRESH_TOKEN); + + verify(tokenService, never()).createSession(any()); + verify(tokenService, never()).rotate(any()); + verifyNoInteractions(jwtService); + } + } +}