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:
@@ -119,6 +119,10 @@
|
|||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.flywaydb</groupId>
|
<groupId>org.flywaydb</groupId>
|
||||||
<artifactId>flyway-core</artifactId>
|
<artifactId>flyway-core</artifactId>
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ import java.util.concurrent.ThreadPoolExecutor;
|
|||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.scheduling.annotation.EnableAsync;
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
|
@EnableScheduling
|
||||||
public class AsyncConfig {
|
public class AsyncConfig {
|
||||||
@Bean
|
@Bean
|
||||||
public Executor taskExecutor() {
|
public Executor taskExecutor() {
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(auth -> {
|
.authorizeHttpRequests(auth -> {
|
||||||
// Health endpoint must be open so CI/Docker health checks work without credentials
|
// Health endpoint must be open so CI/Docker health checks work without credentials
|
||||||
auth.requestMatchers("/actuator/health").permitAll();
|
auth.requestMatchers("/actuator/health").permitAll();
|
||||||
|
// Password reset endpoints are unauthenticated by nature
|
||||||
|
auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll();
|
||||||
|
// E2E test helper (only active under "e2e" profile)
|
||||||
|
auth.requestMatchers("/api/auth/reset-token-for-test").permitAll();
|
||||||
// In dev, allow unauthenticated access to the OpenAPI spec and Swagger UI
|
// In dev, allow unauthenticated access to the OpenAPI spec and Swagger UI
|
||||||
if (environment.matchesProfiles("dev")) {
|
if (environment.matchesProfiles("dev")) {
|
||||||
auth.requestMatchers(
|
auth.requestMatchers(
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.ForgotPasswordRequest;
|
||||||
|
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
|
||||||
|
import org.raddatz.familienarchiv.service.PasswordResetService;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final PasswordResetService passwordResetService;
|
||||||
|
|
||||||
|
@Value("${app.base-url:http://localhost:3000}")
|
||||||
|
private String appBaseUrl;
|
||||||
|
|
||||||
|
@PostMapping("/forgot-password")
|
||||||
|
public ResponseEntity<Void> forgotPassword(@RequestBody ForgotPasswordRequest request) {
|
||||||
|
passwordResetService.requestReset(request.getEmail(), appBaseUrl);
|
||||||
|
// Always return 204 — never disclose whether the email exists
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/reset-password")
|
||||||
|
public ResponseEntity<Void> resetPassword(@RequestBody ResetPasswordRequest request) {
|
||||||
|
passwordResetService.resetPassword(request);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package org.raddatz.familienarchiv.controller;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only endpoint to retrieve a password reset token by email.
|
||||||
|
* Only active under the "e2e" Spring profile.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
@Profile("e2e")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthE2EController {
|
||||||
|
|
||||||
|
private final PasswordResetTokenRepository tokenRepository;
|
||||||
|
|
||||||
|
@GetMapping("/reset-token-for-test")
|
||||||
|
public ResponseEntity<String> getResetTokenForTest(@RequestParam String email) {
|
||||||
|
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now())
|
||||||
|
.map(ResponseEntity::ok)
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ForgotPasswordRequest {
|
||||||
|
private String email;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package org.raddatz.familienarchiv.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ResetPasswordRequest {
|
||||||
|
private String token;
|
||||||
|
private String newPassword;
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ public enum ErrorCode {
|
|||||||
UNAUTHORIZED,
|
UNAUTHORIZED,
|
||||||
/** The authenticated user lacks the required permission. 403 */
|
/** The authenticated user lacks the required permission. 403 */
|
||||||
FORBIDDEN,
|
FORBIDDEN,
|
||||||
|
/** The password-reset token is missing, expired, or already used. 400 */
|
||||||
|
INVALID_RESET_TOKEN,
|
||||||
|
|
||||||
// --- Generic ---
|
// --- Generic ---
|
||||||
/** Request validation failed (missing or malformed fields). 400 */
|
/** Request validation failed (missing or malformed fields). 400 */
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package org.raddatz.familienarchiv.model;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "password_reset_tokens")
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Builder
|
||||||
|
public class PasswordResetToken {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
private AppUser user;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true, length = 64)
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
@Column(name = "expires_at", nullable = false)
|
||||||
|
private LocalDateTime expiresAt;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private boolean used = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package org.raddatz.familienarchiv.repository;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.model.PasswordResetToken;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, UUID> {
|
||||||
|
|
||||||
|
Optional<PasswordResetToken> findByToken(String token);
|
||||||
|
|
||||||
|
@Query("SELECT t.token FROM PasswordResetToken t WHERE t.user.email = :email AND t.used = false AND t.expiresAt > :now ORDER BY t.expiresAt DESC LIMIT 1")
|
||||||
|
Optional<String> findLatestActiveTokenByEmail(String email, LocalDateTime now);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("DELETE FROM PasswordResetToken t WHERE t.expiresAt < :now OR t.used = true")
|
||||||
|
void deleteExpiredAndUsed(LocalDateTime now);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.raddatz.familienarchiv.dto.ResetPasswordRequest;
|
||||||
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
|
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.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.mail.MailException;
|
||||||
|
import org.springframework.mail.SimpleMailMessage;
|
||||||
|
import org.springframework.mail.javamail.JavaMailSender;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class PasswordResetService {
|
||||||
|
|
||||||
|
private final AppUserRepository userRepository;
|
||||||
|
private final PasswordResetTokenRepository tokenRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private JavaMailSender mailSender;
|
||||||
|
|
||||||
|
@Value("${app.mail.from:noreply@familienarchiv.local}")
|
||||||
|
private String mailFrom;
|
||||||
|
|
||||||
|
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||||
|
private static final int TOKEN_EXPIRY_HOURS = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a reset token for the given email address and sends it via email.
|
||||||
|
* If the email is not found, silently does nothing (prevents user enumeration).
|
||||||
|
* If no mail sender is configured, logs a warning.
|
||||||
|
*/
|
||||||
|
public void requestReset(String email, String appBaseUrl) {
|
||||||
|
Optional<AppUser> userOpt = userRepository.findByEmail(email);
|
||||||
|
if (userOpt.isEmpty()) {
|
||||||
|
log.debug("Password reset requested for unknown email: {}", email);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AppUser user = userOpt.get();
|
||||||
|
String token = generateToken();
|
||||||
|
|
||||||
|
tokenRepository.save(PasswordResetToken.builder()
|
||||||
|
.user(user)
|
||||||
|
.token(token)
|
||||||
|
.expiresAt(LocalDateTime.now().plusHours(TOKEN_EXPIRY_HOURS))
|
||||||
|
.build());
|
||||||
|
|
||||||
|
sendResetEmail(user.getEmail(), token, appBaseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates the token and updates the user's password.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void resetPassword(ResetPasswordRequest request) {
|
||||||
|
PasswordResetToken resetToken = tokenRepository.findByToken(request.getToken())
|
||||||
|
.orElseThrow(() -> DomainException.badRequest(
|
||||||
|
ErrorCode.INVALID_RESET_TOKEN, "Invalid or unknown reset token"));
|
||||||
|
|
||||||
|
if (resetToken.isUsed() || resetToken.getExpiresAt().isBefore(LocalDateTime.now())) {
|
||||||
|
throw DomainException.badRequest(ErrorCode.INVALID_RESET_TOKEN, "Token expired or already used");
|
||||||
|
}
|
||||||
|
|
||||||
|
AppUser user = resetToken.getUser();
|
||||||
|
user.setPassword(passwordEncoder.encode(request.getNewPassword()));
|
||||||
|
userRepository.save(user);
|
||||||
|
|
||||||
|
resetToken.setUsed(true);
|
||||||
|
tokenRepository.save(resetToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nightly cleanup of expired and used tokens. */
|
||||||
|
@Scheduled(cron = "0 0 3 * * *")
|
||||||
|
@Transactional
|
||||||
|
public void cleanupExpiredTokens() {
|
||||||
|
tokenRepository.deleteExpiredAndUsed(LocalDateTime.now());
|
||||||
|
log.info("Cleaned up expired password reset tokens");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String generateToken() {
|
||||||
|
byte[] bytes = new byte[32];
|
||||||
|
SECURE_RANDOM.nextBytes(bytes);
|
||||||
|
return HexFormat.of().formatHex(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendResetEmail(String to, String token, String appBaseUrl) {
|
||||||
|
if (mailSender == null) {
|
||||||
|
log.warn("Mail sender not configured — skipping password reset email to {}", to);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String resetUrl = appBaseUrl + "/reset-password?token=" + token;
|
||||||
|
SimpleMailMessage message = new SimpleMailMessage();
|
||||||
|
message.setFrom(mailFrom);
|
||||||
|
message.setTo(to);
|
||||||
|
message.setSubject("Passwort zurücksetzen — Familienarchiv");
|
||||||
|
message.setText(
|
||||||
|
"Hallo,\n\n"
|
||||||
|
+ "Sie haben eine Passwort-Zurücksetzung beantragt.\n\n"
|
||||||
|
+ "Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:\n"
|
||||||
|
+ resetUrl + "\n\n"
|
||||||
|
+ "Der Link ist " + TOKEN_EXPIRY_HOURS + " Stunde(n) gültig.\n\n"
|
||||||
|
+ "Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.\n\n"
|
||||||
|
+ "Ihr Familienarchiv-Team");
|
||||||
|
|
||||||
|
try {
|
||||||
|
mailSender.send(message);
|
||||||
|
log.info("Password reset email sent to {}", to);
|
||||||
|
} catch (MailException e) {
|
||||||
|
log.error("Failed to send password reset email to {}: {}", to, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,23 @@ spring:
|
|||||||
max-file-size: 50MB
|
max-file-size: 50MB
|
||||||
max-request-size: 50MB
|
max-request-size: 50MB
|
||||||
|
|
||||||
|
mail:
|
||||||
|
host: ${MAIL_HOST:}
|
||||||
|
port: ${MAIL_PORT:587}
|
||||||
|
username: ${MAIL_USERNAME:}
|
||||||
|
password: ${MAIL_PASSWORD:}
|
||||||
|
properties:
|
||||||
|
mail:
|
||||||
|
smtp:
|
||||||
|
auth: true
|
||||||
|
starttls:
|
||||||
|
enable: true
|
||||||
|
|
||||||
|
management:
|
||||||
|
health:
|
||||||
|
mail:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
enabled: false
|
enabled: false
|
||||||
@@ -38,6 +55,11 @@ app:
|
|||||||
bucket: ${S3_BUCKET_NAME}
|
bucket: ${S3_BUCKET_NAME}
|
||||||
region: ${S3_REGION}
|
region: ${S3_REGION}
|
||||||
|
|
||||||
|
base-url: ${APP_BASE_URL:http://localhost:3000}
|
||||||
|
|
||||||
|
mail:
|
||||||
|
from: ${APP_MAIL_FROM:noreply@familienarchiv.local}
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
username: ${APP_ADMIN_USERNAME:admin}
|
username: ${APP_ADMIN_USERNAME:admin}
|
||||||
password: ${APP_ADMIN_PASSWORD:admin123}
|
password: ${APP_ADMIN_PASSWORD:admin123}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
CREATE TABLE password_reset_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
used BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_prt_token ON password_reset_tokens(token);
|
||||||
@@ -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