feat(backend): add password reset via email
- Add PasswordResetToken entity, repository (Flyway V8 migration)
- PasswordResetService: token generation, validation, nightly cleanup
- AuthController: POST /api/auth/forgot-password and /api/auth/reset-password (both permitAll)
- AuthE2EController (@Profile("e2e")): GET /api/auth/reset-token-for-test for CI testing
- spring-boot-starter-mail dependency; JavaMailSender optional (@Autowired required=false)
- mail health indicator disabled; mail config via MAIL_HOST/PORT/USERNAME/PASSWORD env vars
- 5 unit tests written TDD-style (all pass)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package org.raddatz.familienarchiv.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
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.raddatz.familienarchiv.dto.ResetPasswordRequest;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.model.AppUser;
|
||||
import org.raddatz.familienarchiv.model.PasswordResetToken;
|
||||
import org.raddatz.familienarchiv.repository.AppUserRepository;
|
||||
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PasswordResetServiceTest {
|
||||
|
||||
@Mock AppUserRepository userRepository;
|
||||
@Mock PasswordResetTokenRepository tokenRepository;
|
||||
@Mock PasswordEncoder passwordEncoder;
|
||||
@Mock JavaMailSender mailSender;
|
||||
@InjectMocks PasswordResetService service;
|
||||
|
||||
private AppUser makeUser(String email) {
|
||||
return AppUser.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.username("testuser")
|
||||
.email(email)
|
||||
.password("hashed")
|
||||
.build();
|
||||
}
|
||||
|
||||
// ─── requestReset ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void requestReset_savesTokenForKnownEmail() {
|
||||
AppUser user = makeUser("user@example.com");
|
||||
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user));
|
||||
|
||||
service.requestReset("user@example.com", "http://localhost:3000");
|
||||
|
||||
verify(tokenRepository).save(argThat(t ->
|
||||
t.getUser().equals(user)
|
||||
&& t.getToken().length() == 64
|
||||
&& !t.isUsed()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestReset_doesNothingForUnknownEmail() {
|
||||
when(userRepository.findByEmail("ghost@example.com")).thenReturn(Optional.empty());
|
||||
|
||||
service.requestReset("ghost@example.com", "http://localhost:3000");
|
||||
|
||||
verify(tokenRepository, never()).save(any());
|
||||
}
|
||||
|
||||
// ─── resetPassword ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void resetPassword_updatesPasswordForValidToken() {
|
||||
AppUser user = makeUser("user@example.com");
|
||||
PasswordResetToken token = PasswordResetToken.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.token("validtoken123")
|
||||
.user(user)
|
||||
.expiresAt(LocalDateTime.now().plusHours(1))
|
||||
.used(false)
|
||||
.build();
|
||||
when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token));
|
||||
when(passwordEncoder.encode("newpass")).thenReturn("hashed-newpass");
|
||||
|
||||
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||
req.setToken("validtoken123");
|
||||
req.setNewPassword("newpass");
|
||||
service.resetPassword(req);
|
||||
|
||||
verify(passwordEncoder).encode("newpass");
|
||||
verify(userRepository).save(argThat(u -> u.getPassword().equals("hashed-newpass")));
|
||||
assertThat(token.isUsed()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetPassword_throwsForExpiredToken() {
|
||||
AppUser user = makeUser("user@example.com");
|
||||
PasswordResetToken token = PasswordResetToken.builder()
|
||||
.token("expiredtoken")
|
||||
.user(user)
|
||||
.expiresAt(LocalDateTime.now().minusMinutes(1))
|
||||
.used(false)
|
||||
.build();
|
||||
when(tokenRepository.findByToken("expiredtoken")).thenReturn(Optional.of(token));
|
||||
|
||||
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||
req.setToken("expiredtoken");
|
||||
req.setNewPassword("newpass");
|
||||
|
||||
assertThatThrownBy(() -> service.resetPassword(req))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resetPassword_throwsForUnknownToken() {
|
||||
when(tokenRepository.findByToken("nosuchtoken")).thenReturn(Optional.empty());
|
||||
|
||||
ResetPasswordRequest req = new ResetPasswordRequest();
|
||||
req.setToken("nosuchtoken");
|
||||
req.setNewPassword("newpass");
|
||||
|
||||
assertThatThrownBy(() -> service.resetPassword(req))
|
||||
.isInstanceOf(DomainException.class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user