From ce95a451ce8f8e062f675ce9e7e31f551c1dd4bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Fri, 1 May 2026 17:38:17 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20JWT=20authentication=20?= =?UTF-8?q?=E2=80=94=20service,=20filter,=20SecurityFilterChain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../se/bilhalsning/config/SecurityConfig.java | 31 ++++ .../security/JwtAuthenticationFilter.java | 50 +++++++ .../se/bilhalsning/security/JwtService.java | 57 ++++++++ .../security/UserDetailsServiceImpl.java | 25 ++++ .../se/bilhalsning/FlywayMigrationTest.java | 81 ++++++++++ .../config/SecurityConfigTest.java | 44 ++++++ .../repository/UserRepositoryTest.java | 59 ++++++++ .../security/JwtAuthenticationFilterTest.java | 138 ++++++++++++++++++ .../bilhalsning/security/JwtServiceTest.java | 74 ++++++++++ .../security/UserDetailsServiceImplTest.java | 61 ++++++++ .../bilhalsning/service/UserServiceTest.java | 121 +++++++++++++++ 11 files changed, 741 insertions(+) create mode 100644 backend/src/main/java/se/bilhalsning/security/JwtAuthenticationFilter.java create mode 100644 backend/src/main/java/se/bilhalsning/security/JwtService.java create mode 100644 backend/src/main/java/se/bilhalsning/security/UserDetailsServiceImpl.java create mode 100644 backend/src/test/java/se/bilhalsning/FlywayMigrationTest.java create mode 100644 backend/src/test/java/se/bilhalsning/config/SecurityConfigTest.java create mode 100644 backend/src/test/java/se/bilhalsning/repository/UserRepositoryTest.java create mode 100644 backend/src/test/java/se/bilhalsning/security/JwtAuthenticationFilterTest.java create mode 100644 backend/src/test/java/se/bilhalsning/security/JwtServiceTest.java create mode 100644 backend/src/test/java/se/bilhalsning/security/UserDetailsServiceImplTest.java create mode 100644 backend/src/test/java/se/bilhalsning/service/UserServiceTest.java diff --git a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java index eedaf1b..a32a295 100644 --- a/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java +++ b/backend/src/main/java/se/bilhalsning/config/SecurityConfig.java @@ -1,15 +1,46 @@ package se.bilhalsning.config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import se.bilhalsning.security.JwtAuthenticationFilter; +import se.bilhalsning.security.JwtService; @Configuration +@EnableWebSecurity public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public JwtService jwtService(@Value("${app.jwt.secret}") String secret) { + return new JwtService(secret); + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/register", "/api/auth/login").permitAll() + .requestMatchers("/api/webhooks/**").permitAll() + .requestMatchers("/api/vehicles/**").permitAll() + .requestMatchers("/api/templates").permitAll() + .anyRequest().authenticated()) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } } diff --git a/backend/src/main/java/se/bilhalsning/security/JwtAuthenticationFilter.java b/backend/src/main/java/se/bilhalsning/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..09e8b4a --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/security/JwtAuthenticationFilter.java @@ -0,0 +1,50 @@ +package se.bilhalsning.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final UserDetailsServiceImpl userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String authHeader = request.getHeader("Authorization"); + + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = authHeader.substring(7); + String username = jwtService.extractUsername(token); + + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + var userDetails = userDetailsService.loadUserByUsername(username); + + if (jwtService.isTokenValid(token)) { + var authToken = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authToken); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/se/bilhalsning/security/JwtService.java b/backend/src/main/java/se/bilhalsning/security/JwtService.java new file mode 100644 index 0000000..c9ee269 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/security/JwtService.java @@ -0,0 +1,57 @@ +package se.bilhalsning.security; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +public class JwtService { + + private static final long DEFAULT_EXPIRATION_MS = 86_400_000; + + private final SecretKey secretKey; + private final long expirationMs; + + public JwtService(String secret) { + this(secret, DEFAULT_EXPIRATION_MS); + } + + JwtService(String secret, long expirationMs) { + this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + this.expirationMs = expirationMs; + } + + public String generateToken(String email) { + return Jwts.builder() + .subject(email) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expirationMs)) + .signWith(secretKey) + .compact(); + } + + public String extractUsername(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } + + public boolean isTokenValid(String token) { + try { + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + return true; + } catch (ExpiredJwtException e) { + return false; + } catch (Exception e) { + return false; + } + } +} diff --git a/backend/src/main/java/se/bilhalsning/security/UserDetailsServiceImpl.java b/backend/src/main/java/se/bilhalsning/security/UserDetailsServiceImpl.java new file mode 100644 index 0000000..9a10306 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/security/UserDetailsServiceImpl.java @@ -0,0 +1,25 @@ +package se.bilhalsning.security; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import se.bilhalsning.repository.UserRepository; + +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + String normalizedEmail = email.toLowerCase().trim(); + return userRepository.findByEmail(normalizedEmail) + .map(user -> new User(user.getEmail(), user.getPasswordHash(), List.of())) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + normalizedEmail)); + } +} diff --git a/backend/src/test/java/se/bilhalsning/FlywayMigrationTest.java b/backend/src/test/java/se/bilhalsning/FlywayMigrationTest.java new file mode 100644 index 0000000..a1b9cfa --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/FlywayMigrationTest.java @@ -0,0 +1,81 @@ +package se.bilhalsning; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.transaction.annotation.Transactional; +import se.bilhalsning.entity.Subscription; +import se.bilhalsning.entity.User; +import se.bilhalsning.repository.UserRepository; + +@SpringBootTest +@Transactional +class FlywayMigrationTest { + + @Autowired + private UserRepository userRepository; + + @Test + void shouldPersistAndRetrieveUser() { + User user = new User(); + user.setEmail("test@example.com"); + user.setPasswordHash("hash"); + user.setSubscription(Subscription.BASIC); + userRepository.saveAndFlush(user); + + User found = userRepository.findById(user.getId()).orElseThrow(); + + assertEquals(user.getId(), found.getId()); + assertEquals("test@example.com", found.getEmail()); + assertEquals("hash", found.getPasswordHash()); + assertEquals(Subscription.BASIC, found.getSubscription()); + assertNotNull(found.getCreatedAt()); + assertNotNull(found.getUpdatedAt()); + } + + @Test + void shouldEnforceUniqueEmail() { + User first = new User(); + first.setEmail("unique@example.com"); + first.setPasswordHash("hash"); + userRepository.saveAndFlush(first); + + User duplicate = new User(); + duplicate.setEmail("unique@example.com"); + duplicate.setPasswordHash("hash"); + + assertThrows(DataIntegrityViolationException.class, () -> { + userRepository.saveAndFlush(duplicate); + }); + } + + @Test + void shouldPersistAllSubscriptionValues() { + for (Subscription sub : Subscription.values()) { + User user = new User(); + user.setEmail(sub.getValue() + "@example.com"); + user.setPasswordHash("hash"); + user.setSubscription(sub); + userRepository.saveAndFlush(user); + + User found = userRepository.findByEmail(sub.getValue() + "@example.com").orElseThrow(); + assertEquals(sub, found.getSubscription()); + } + } + + @Test + void shouldDefaultSubscriptionToNone() { + User user = new User(); + user.setEmail("default@example.com"); + user.setPasswordHash("hash"); + userRepository.saveAndFlush(user); + + User found = userRepository.findByEmail("default@example.com").orElseThrow(); + assertEquals(Subscription.NONE, found.getSubscription()); + } +} diff --git a/backend/src/test/java/se/bilhalsning/config/SecurityConfigTest.java b/backend/src/test/java/se/bilhalsning/config/SecurityConfigTest.java new file mode 100644 index 0000000..e516f3e --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/config/SecurityConfigTest.java @@ -0,0 +1,44 @@ +package se.bilhalsning.config; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import se.bilhalsning.security.JwtService; + +@SpringBootTest +class SecurityConfigTest { + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private SecurityFilterChain securityFilterChain; + + @Autowired + private JwtService jwtService; + + @Test + void shouldProvideBcryptPasswordEncoder() { + assertNotNull(passwordEncoder); + assertInstanceOf(BCryptPasswordEncoder.class, passwordEncoder); + } + + @Test + void shouldProvideSecurityFilterChain() { + assertNotNull(securityFilterChain); + assertDoesNotThrow(() -> securityFilterChain.getFilters()); + assertNotNull(securityFilterChain.getFilters()); + } + + @Test + void shouldExposeJwtServiceAsBean() { + assertNotNull(jwtService); + } +} diff --git a/backend/src/test/java/se/bilhalsning/repository/UserRepositoryTest.java b/backend/src/test/java/se/bilhalsning/repository/UserRepositoryTest.java new file mode 100644 index 0000000..38afe51 --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/repository/UserRepositoryTest.java @@ -0,0 +1,59 @@ +package se.bilhalsning.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; +import se.bilhalsning.entity.User; + +@SpringBootTest +@Transactional +class UserRepositoryTest { + + @Autowired + private UserRepository userRepository; + + @Test + void shouldFindByEmailWhenExists() { + User user = new User(); + user.setEmail("find@example.com"); + user.setPasswordHash("hash"); + userRepository.saveAndFlush(user); + + Optional result = userRepository.findByEmail("find@example.com"); + + assertTrue(result.isPresent()); + assertEquals("find@example.com", result.get().getEmail()); + } + + @Test + void shouldReturnEmptyWhenEmailNotFound() { + Optional result = userRepository.findByEmail("no@example.com"); + + assertFalse(result.isPresent()); + } + + @Test + void shouldReturnTrueWhenEmailExists() { + User user = new User(); + user.setEmail("exists@example.com"); + user.setPasswordHash("hash"); + userRepository.saveAndFlush(user); + + boolean result = userRepository.existsByEmail("exists@example.com"); + + assertTrue(result); + } + + @Test + void shouldReturnFalseWhenEmailDoesNotExist() { + boolean result = userRepository.existsByEmail("nope@example.com"); + + assertFalse(result); + } +} diff --git a/backend/src/test/java/se/bilhalsning/security/JwtAuthenticationFilterTest.java b/backend/src/test/java/se/bilhalsning/security/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..297baad --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/security/JwtAuthenticationFilterTest.java @@ -0,0 +1,138 @@ +package se.bilhalsning.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import java.io.IOException; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; + +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationFilterTest { + + private static final String VALID_TOKEN = "valid.jwt.token"; + private static final String EMAIL = "test@example.com"; + + @Mock + private JwtService jwtService; + + @Mock + private UserDetailsServiceImpl userDetailsService; + + @Mock + private FilterChain filterChain; + + @InjectMocks + private JwtAuthenticationFilter filter; + + @BeforeEach + void setUp() { + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldSetAuthenticationWhenValidToken() throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + VALID_TOKEN); + MockHttpServletResponse response = new MockHttpServletResponse(); + + UserDetails userDetails = new User(EMAIL, "password", List.of()); + when(jwtService.extractUsername(VALID_TOKEN)).thenReturn(EMAIL); + when(jwtService.isTokenValid(VALID_TOKEN)).thenReturn(true); + when(userDetailsService.loadUserByUsername(EMAIL)).thenReturn(userDetails); + + filter.doFilterInternal(request, response, filterChain); + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + assertNotNull(auth); + assertEquals(EMAIL, auth.getName()); + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldNotSetAuthenticationWhenNoAuthHeader() throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest(); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldNotSetAuthenticationWhenHeaderLacksBearerPrefix() throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Basic dXNlcjpwYXNz"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + filter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(jwtService, never()).extractUsername(anyString()); + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldNotSetAuthenticationWhenTokenInvalid() throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer invalid-token"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + when(jwtService.extractUsername("invalid-token")).thenReturn(EMAIL); + when(jwtService.isTokenValid("invalid-token")).thenReturn(false); + + filter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldContinueChainWhenAuthAlreadyPresent() throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer " + VALID_TOKEN); + MockHttpServletResponse response = new MockHttpServletResponse(); + + Authentication existingAuth = SecurityContextHolder.getContext().getAuthentication(); + if (existingAuth == null) { + // Pre-populate auth context to simulate existing auth + UserDetails userDetails = new User("existing@example.com", "password", List.of()); + SecurityContextHolder.getContext().setAuthentication( + new org.springframework.security.authentication.UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities())); + } + + when(jwtService.extractUsername(VALID_TOKEN)).thenReturn(EMAIL); + + filter.doFilterInternal(request, response, filterChain); + + // Should have skipped JWT processing, existing auth should remain + verify(jwtService, never()).isTokenValid(anyString()); + verify(filterChain).doFilter(request, response); + } +} diff --git a/backend/src/test/java/se/bilhalsning/security/JwtServiceTest.java b/backend/src/test/java/se/bilhalsning/security/JwtServiceTest.java new file mode 100644 index 0000000..924edc3 --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/security/JwtServiceTest.java @@ -0,0 +1,74 @@ +package se.bilhalsning.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class JwtServiceTest { + + private static final String SECRET = "this-is-a-test-secret-that-is-at-least-32-bytes-long!!"; + private static final String EMAIL = "test@example.com"; + + @Test + void shouldGenerateValidTokenThatCanBeParsed() { + JwtService jwtService = new JwtService(SECRET); + + String token = jwtService.generateToken(EMAIL); + + assertNotNull(token); + String extracted = jwtService.extractUsername(token); + assertEquals(EMAIL, extracted); + assertTrue(jwtService.isTokenValid(token)); + } + + @Test + void shouldExtractUsername() { + JwtService jwtService = new JwtService(SECRET); + + String token = jwtService.generateToken("another@example.com"); + String username = jwtService.extractUsername(token); + + assertEquals("another@example.com", username); + } + + @Test + void shouldReturnFalseForExpiredToken() { + JwtService jwtService = new JwtService(SECRET, 0); + + String token = jwtService.generateToken(EMAIL); + + assertFalse(jwtService.isTokenValid(token)); + } + + @Test + void shouldReturnFalseForMalformedToken() { + JwtService jwtService = new JwtService(SECRET); + + assertFalse(jwtService.isTokenValid("not-a-valid-token")); + assertFalse(jwtService.isTokenValid("")); + assertFalse(jwtService.isTokenValid("header.payload.signature")); + } + + @Test + void shouldReturnFalseForTokenSignedWithDifferentKey() { + JwtService first = new JwtService(SECRET); + JwtService second = new JwtService("another-secret-that-is-at-least-32-bytes-long"); + + String token = first.generateToken(EMAIL); + + assertFalse(second.isTokenValid(token)); + } + + @Test + void shouldReturnFalseForTamperedToken() { + JwtService jwtService = new JwtService(SECRET); + + String token = jwtService.generateToken(EMAIL); + String tampered = token.substring(0, token.length() - 3) + "xyz"; + + assertFalse(jwtService.isTokenValid(tampered)); + } +} diff --git a/backend/src/test/java/se/bilhalsning/security/UserDetailsServiceImplTest.java b/backend/src/test/java/se/bilhalsning/security/UserDetailsServiceImplTest.java new file mode 100644 index 0000000..8b69d3a --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/security/UserDetailsServiceImplTest.java @@ -0,0 +1,61 @@ +package se.bilhalsning.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import se.bilhalsning.entity.User; +import se.bilhalsning.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class UserDetailsServiceImplTest { + + @Mock + private UserRepository userRepository; + + @InjectMocks + private UserDetailsServiceImpl userDetailsService; + + @Test + void shouldReturnUserDetailsWhenUserExists() { + User user = new User(); + user.setEmail("test@example.com"); + user.setPasswordHash("hashed-password"); + when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.of(user)); + + UserDetails result = userDetailsService.loadUserByUsername("test@example.com"); + + assertEquals("test@example.com", result.getUsername()); + assertEquals("hashed-password", result.getPassword()); + } + + @Test + void shouldThrowUsernameNotFoundExceptionWhenUserMissing() { + when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + + assertThrows(UsernameNotFoundException.class, () -> + userDetailsService.loadUserByUsername("missing@example.com")); + } + + @Test + void shouldNormalizeEmailToLowercase() { + User user = new User(); + user.setEmail("uppercase@example.com"); + user.setPasswordHash("hashed-password"); + when(userRepository.findByEmail("uppercase@example.com")).thenReturn(Optional.of(user)); + + userDetailsService.loadUserByUsername("UPPERCASE@EXAMPLE.COM"); + + verify(userRepository).findByEmail("uppercase@example.com"); + } +} diff --git a/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java b/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java new file mode 100644 index 0000000..77f72b5 --- /dev/null +++ b/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java @@ -0,0 +1,121 @@ +package se.bilhalsning.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +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.crypto.password.PasswordEncoder; +import se.bilhalsning.entity.Subscription; +import se.bilhalsning.entity.User; +import se.bilhalsning.exception.EmailAlreadyExistsException; +import se.bilhalsning.repository.UserRepository; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @InjectMocks + private UserService userService; + + @Test + void shouldCreateUserWhenEmailIsNew() { + when(userRepository.existsByEmail("new@example.com")).thenReturn(false); + when(passwordEncoder.encode("password123")).thenReturn("hashed"); + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + User result = userService.createUser("new@example.com", "password123"); + + assertNotNull(result); + assertEquals("new@example.com", result.getEmail()); + assertEquals("hashed", result.getPasswordHash()); + assertEquals(Subscription.NONE, result.getSubscription()); + verify(userRepository).save(any(User.class)); + } + + @Test + void shouldThrowWhenEmailAlreadyExists() { + when(userRepository.existsByEmail("taken@example.com")).thenReturn(true); + + assertThrows(EmailAlreadyExistsException.class, () -> + userService.createUser("taken@example.com", "password123")); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void shouldNormalizeEmailToLowercase() { + when(userRepository.existsByEmail("user@example.com")).thenReturn(false); + when(passwordEncoder.encode(any())).thenReturn("hashed"); + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + User result = userService.createUser("User@Example.COM", "password123"); + + assertEquals("user@example.com", result.getEmail()); + verify(userRepository).existsByEmail("user@example.com"); + } + + @Test + void shouldTrimWhitespaceFromEmail() { + when(userRepository.existsByEmail("user@example.com")).thenReturn(false); + when(passwordEncoder.encode(any())).thenReturn("hashed"); + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + User result = userService.createUser(" user@example.com ", "password123"); + + assertEquals("user@example.com", result.getEmail()); + verify(userRepository).existsByEmail("user@example.com"); + } + + @Test + void shouldHashPasswordBeforeSave() { + when(userRepository.existsByEmail("test@example.com")).thenReturn(false); + when(passwordEncoder.encode("myPassword")).thenReturn("bcryptHash"); + when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0)); + + userService.createUser("test@example.com", "myPassword"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + assertEquals("bcryptHash", captor.getValue().getPasswordHash()); + } + + @Test + void shouldFindByEmailWhenExists() { + User user = new User(); + user.setEmail("found@example.com"); + when(userRepository.findByEmail("found@example.com")).thenReturn(Optional.of(user)); + + Optional result = userService.findByEmail("found@example.com"); + + assertTrue(result.isPresent()); + assertEquals("found@example.com", result.get().getEmail()); + } + + @Test + void shouldFindByEmailNormalizesInput() { + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.empty()); + + userService.findByEmail(" User@Example.COM "); + + verify(userRepository).findByEmail("user@example.com"); + } +}