Replace the header "Byt lösenord" link with an Inställningar menu for changing email or password. Email changes are two-step: request with password, confirmation link to the new address, then password again on confirm so a wrong inbox cannot take over the account. - Backend: EmailChangeService, V10 email_change_tokens, confirm API - Frontend: ChangeEmailPage, ConfirmEmailChangePage, header dropdown - E2E: account-settings round-trips, Mailpit verification, wrong-password guard - Flyway: V9 restore for dev DBs, CI migration checks, V10 for email tokens Co-authored-by: Cursor <cursoragent@cursor.com>
237 lines
8.7 KiB
Java
237 lines
8.7 KiB
Java
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.exception.InvalidCredentialsException;
|
|
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());
|
|
assertEquals("user", result.getRole());
|
|
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<User> 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<User> 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");
|
|
}
|
|
|
|
@Test
|
|
void shouldReturnUserWhenCredentialsAreValid() {
|
|
User user = new User();
|
|
user.setEmail("user@example.com");
|
|
user.setPasswordHash("hashed");
|
|
|
|
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
|
when(passwordEncoder.matches("password123", "hashed")).thenReturn(true);
|
|
|
|
User result = userService.authenticate("user@example.com", "password123");
|
|
|
|
assertNotNull(result);
|
|
assertEquals("user@example.com", result.getEmail());
|
|
}
|
|
|
|
@Test
|
|
void shouldThrowWhenEmailNotFoundOnAuthenticate() {
|
|
when(userRepository.findByEmail("unknown@example.com")).thenReturn(Optional.empty());
|
|
|
|
assertThrows(InvalidCredentialsException.class, () ->
|
|
userService.authenticate("unknown@example.com", "password123"));
|
|
}
|
|
|
|
@Test
|
|
void shouldThrowWhenPasswordDoesNotMatch() {
|
|
User user = new User();
|
|
user.setEmail("user@example.com");
|
|
user.setPasswordHash("hashed");
|
|
|
|
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
|
when(passwordEncoder.matches("wrongpassword", "hashed")).thenReturn(false);
|
|
|
|
assertThrows(InvalidCredentialsException.class, () ->
|
|
userService.authenticate("user@example.com", "wrongpassword"));
|
|
}
|
|
|
|
@Test
|
|
void shouldNormalizeEmailBeforeAuthenticating() {
|
|
User user = new User();
|
|
user.setEmail("user@example.com");
|
|
user.setPasswordHash("hashed");
|
|
|
|
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
|
when(passwordEncoder.matches("password123", "hashed")).thenReturn(true);
|
|
|
|
userService.authenticate(" User@Example.COM ", "password123");
|
|
|
|
verify(userRepository).findByEmail("user@example.com");
|
|
}
|
|
|
|
@Test
|
|
void shouldChangePasswordWhenCurrentPasswordMatches() {
|
|
User user = new User();
|
|
user.setEmail("admin@bilhej.se");
|
|
user.setPasswordHash("old-hash");
|
|
|
|
when(userRepository.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
|
|
when(passwordEncoder.matches("test1234", "old-hash")).thenReturn(true);
|
|
when(passwordEncoder.encode("newpassword123")).thenReturn("new-hash");
|
|
when(userRepository.save(user)).thenReturn(user);
|
|
|
|
userService.changePassword("admin@bilhej.se", "test1234", "newpassword123");
|
|
|
|
assertEquals("new-hash", user.getPasswordHash());
|
|
verify(userRepository).save(user);
|
|
}
|
|
|
|
@Test
|
|
void shouldRejectChangePasswordWhenCurrentPasswordWrong() {
|
|
User user = new User();
|
|
user.setEmail("admin@bilhej.se");
|
|
user.setPasswordHash("old-hash");
|
|
|
|
when(userRepository.findByEmail("admin@bilhej.se")).thenReturn(Optional.of(user));
|
|
when(passwordEncoder.matches("wrong", "old-hash")).thenReturn(false);
|
|
|
|
assertThrows(
|
|
InvalidCredentialsException.class,
|
|
() -> userService.changePassword("admin@bilhej.se", "wrong", "newpassword123"));
|
|
|
|
verify(userRepository, never()).save(any(User.class));
|
|
}
|
|
|
|
@Test
|
|
void shouldApplyEmailChangeWhenNewEmailAvailable() {
|
|
User user = new User();
|
|
user.setEmail("old@example.com");
|
|
user.setPasswordHash("hash");
|
|
user.setRole("user");
|
|
|
|
when(userRepository.existsByEmail("new@example.com")).thenReturn(false);
|
|
when(userRepository.save(user)).thenReturn(user);
|
|
|
|
User result = userService.applyEmailChange(user, "new@example.com");
|
|
|
|
assertEquals("new@example.com", result.getEmail());
|
|
verify(userRepository).save(user);
|
|
}
|
|
|
|
@Test
|
|
void shouldRejectApplyEmailChangeWhenNewEmailTaken() {
|
|
User user = new User();
|
|
user.setEmail("old@example.com");
|
|
user.setPasswordHash("hash");
|
|
|
|
when(userRepository.existsByEmail("taken@example.com")).thenReturn(true);
|
|
|
|
assertThrows(
|
|
EmailAlreadyExistsException.class,
|
|
() -> userService.applyEmailChange(user, "taken@example.com"));
|
|
|
|
verify(userRepository, never()).save(any(User.class));
|
|
}
|
|
}
|