diff --git a/backend/pom.xml b/backend/pom.xml index cc086d69..45cb4be3 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -146,6 +146,12 @@ flyway-database-postgresql + + + com.github.ben-manes.caffeine + caffeine + + org.springdoc diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.java b/backend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.java new file mode 100644 index 00000000..cbcb3b1a --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/RateLimitInterceptor.java @@ -0,0 +1,44 @@ +package org.raddatz.familienarchiv.config; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +public class RateLimitInterceptor implements HandlerInterceptor { + + private static final int MAX_REQUESTS_PER_MINUTE = 10; + + // Caffeine cache: per-IP counter that auto-expires after 1 minute of inactivity. + // Bounded to 10_000 entries to prevent OOM from IP exhaustion. + private final Cache requestCounts = Caffeine.newBuilder() + .expireAfterWrite(1, TimeUnit.MINUTES) + .maximumSize(10_000) + .build(); + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) + throws Exception { + String ip = resolveClientIp(request); + AtomicInteger count = requestCounts.get(ip, k -> new AtomicInteger(0)); + if (count.incrementAndGet() > MAX_REQUESTS_PER_MINUTE) { + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.getWriter().write("{\"code\":\"RATE_LIMIT_EXCEEDED\",\"message\":\"Too many requests\"}"); + return false; + } + return true; + } + + private String resolveClientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java index 41cfb908..60745045 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java @@ -50,6 +50,8 @@ public class SecurityConfig { auth.requestMatchers("/actuator/health").permitAll(); // Password reset endpoints are unauthenticated by nature auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll(); + // Invite-based registration endpoints are public + auth.requestMatchers("/api/auth/invite/**", "/api/auth/register").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 diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/WebConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/WebConfig.java new file mode 100644 index 00000000..62b92ffe --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/WebConfig.java @@ -0,0 +1,15 @@ +package org.raddatz.familienarchiv.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new RateLimitInterceptor()) + .addPathPatterns("/api/auth/invite/**", "/api/auth/register"); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java index 704ad8d7..146300db 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java @@ -1,14 +1,17 @@ package org.raddatz.familienarchiv.controller; import org.raddatz.familienarchiv.dto.ForgotPasswordRequest; +import org.raddatz.familienarchiv.dto.InvitePrefillDTO; +import org.raddatz.familienarchiv.dto.RegisterRequest; import org.raddatz.familienarchiv.dto.ResetPasswordRequest; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.InviteToken; +import org.raddatz.familienarchiv.service.InviteService; import org.raddatz.familienarchiv.service.PasswordResetService; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; 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 org.springframework.web.bind.annotation.*; import lombok.RequiredArgsConstructor; @@ -18,6 +21,7 @@ import lombok.RequiredArgsConstructor; public class AuthController { private final PasswordResetService passwordResetService; + private final InviteService inviteService; @Value("${app.base-url:http://localhost:3000}") private String appBaseUrl; @@ -34,4 +38,20 @@ public class AuthController { passwordResetService.resetPassword(request); return ResponseEntity.noContent().build(); } + + @GetMapping("/invite/{code}") + public InvitePrefillDTO getInvitePrefill(@PathVariable String code) { + InviteToken token = inviteService.validateCode(code); + return new InvitePrefillDTO( + token.getPrefillFirstName(), + token.getPrefillLastName(), + token.getPrefillEmail() + ); + } + + @PostMapping("/register") + public ResponseEntity register(@RequestBody RegisterRequest request) { + AppUser user = inviteService.redeemInvite(request); + return ResponseEntity.status(HttpStatus.CREATED).body(user); + } } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/InviteController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/InviteController.java new file mode 100644 index 00000000..e6dcbb6c --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/InviteController.java @@ -0,0 +1,57 @@ +package org.raddatz.familienarchiv.controller; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.dto.CreateInviteRequest; +import org.raddatz.familienarchiv.dto.InviteListItemDTO; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.security.Permission; +import org.raddatz.familienarchiv.security.RequirePermission; +import org.raddatz.familienarchiv.service.InviteService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/invites") +@RequiredArgsConstructor +public class InviteController { + + private final InviteService inviteService; + private final UserService userService; + + @Value("${app.base-url:http://localhost:3000}") + private String appBaseUrl; + + @GetMapping + @RequirePermission(Permission.ADMIN_USER) + public List listInvites( + @RequestParam(value = "status", defaultValue = "active") String status) { + boolean activeOnly = !"all".equalsIgnoreCase(status); + return inviteService.listInvites(activeOnly, appBaseUrl); + } + + @PostMapping + @RequirePermission(Permission.ADMIN_USER) + public ResponseEntity createInvite( + @RequestBody CreateInviteRequest request, + @AuthenticationPrincipal UserDetails principal) { + AppUser creator = userService.findByEmail(principal.getUsername()); + InviteListItemDTO created = inviteService.toListItemDTO( + inviteService.createInvite(request, creator), appBaseUrl); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } + + @DeleteMapping("/{id}") + @RequirePermission(Permission.ADMIN_USER) + public ResponseEntity revokeInvite(@PathVariable UUID id) { + inviteService.revokeInvite(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateInviteRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateInviteRequest.java new file mode 100644 index 00000000..0a729db9 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/CreateInviteRequest.java @@ -0,0 +1,18 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Data +public class CreateInviteRequest { + private String label; + private Integer maxUses; + private String prefillFirstName; + private String prefillLastName; + private String prefillEmail; + private List groupIds; + private LocalDateTime expiresAt; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/InviteListItemDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/InviteListItemDTO.java new file mode 100644 index 00000000..8983f698 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/InviteListItemDTO.java @@ -0,0 +1,35 @@ +package org.raddatz.familienarchiv.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InviteListItemDTO { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String code; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String displayCode; + private String label; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int useCount; + private Integer maxUses; + private LocalDateTime expiresAt; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private boolean revoked; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String status; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createdAt; + private String shareableUrl; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/InvitePrefillDTO.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/InvitePrefillDTO.java new file mode 100644 index 00000000..1c76b011 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/InvitePrefillDTO.java @@ -0,0 +1,18 @@ +package org.raddatz.familienarchiv.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class InvitePrefillDTO { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String firstName; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String lastName; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String email; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/RegisterRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/RegisterRequest.java new file mode 100644 index 00000000..3cb23b71 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/RegisterRequest.java @@ -0,0 +1,12 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.Data; + +@Data +public class RegisterRequest { + private String code; + private String email; + private String password; + private String firstName; + private String lastName; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 6027942a..0fd93860 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -38,6 +38,16 @@ public enum ErrorCode { /** A mass import is already in progress; only one can run at a time. 409 */ IMPORT_ALREADY_RUNNING, + // --- Invites --- + /** The invite code does not exist. 404 */ + INVITE_NOT_FOUND, + /** The invite has already reached its use limit. 409 */ + INVITE_EXHAUSTED, + /** The invite has been revoked by an admin. 409 */ + INVITE_REVOKED, + /** The invite has passed its expiry date. 410 */ + INVITE_EXPIRED, + // --- Auth --- /** The request is not authenticated. 401 */ UNAUTHORIZED, diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/InviteToken.java b/backend/src/main/java/org/raddatz/familienarchiv/model/InviteToken.java new file mode 100644 index 00000000..f1b79b77 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/InviteToken.java @@ -0,0 +1,76 @@ +package org.raddatz.familienarchiv.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +@Entity +@Table(name = "invite_tokens") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class InviteToken { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private UUID id; + + @Column(nullable = false, unique = true, length = 10) + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private String code; + + private String label; + + private Integer maxUses; + + @Column(nullable = false) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private int useCount = 0; + + private String prefillFirstName; + private String prefillLastName; + private String prefillEmail; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "invite_token_group_ids", joinColumns = @JoinColumn(name = "invite_token_id")) + @Column(name = "group_id") + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private Set groupIds = new HashSet<>(); + + private LocalDateTime expiresAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "created_by", nullable = false) + private AppUser createdBy; + + @CreationTimestamp + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createdAt; + + @Column(nullable = false) + @Builder.Default + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + private boolean revoked = false; + + public boolean isExhausted() { + return maxUses != null && useCount >= maxUses; + } + + public boolean isExpired() { + return expiresAt != null && expiresAt.isBefore(LocalDateTime.now()); + } + + public boolean isActive() { + return !revoked && !isExhausted() && !isExpired(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/InviteTokenRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/InviteTokenRepository.java new file mode 100644 index 00000000..0104c9f0 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/InviteTokenRepository.java @@ -0,0 +1,27 @@ +package org.raddatz.familienarchiv.repository; + +import jakarta.persistence.LockModeType; +import org.raddatz.familienarchiv.model.InviteToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface InviteTokenRepository extends JpaRepository { + + Optional findByCode(String code); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT t FROM InviteToken t WHERE t.code = :code") + Optional findByCodeForUpdate(@Param("code") String code); + + @Query("SELECT t FROM InviteToken t WHERE t.revoked = false AND (t.expiresAt IS NULL OR t.expiresAt > CURRENT_TIMESTAMP) AND (t.maxUses IS NULL OR t.useCount < t.maxUses) ORDER BY t.createdAt DESC") + List findActive(); + + @Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC") + List findAllOrderedByCreatedAt(); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/InviteService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/InviteService.java new file mode 100644 index 00000000..375b4222 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/InviteService.java @@ -0,0 +1,180 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.raddatz.familienarchiv.dto.CreateInviteRequest; +import org.raddatz.familienarchiv.dto.InviteListItemDTO; +import org.raddatz.familienarchiv.dto.RegisterRequest; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.InviteToken; +import org.raddatz.familienarchiv.model.UserGroup; +import org.raddatz.familienarchiv.repository.AppUserRepository; +import org.raddatz.familienarchiv.repository.InviteTokenRepository; +import org.raddatz.familienarchiv.repository.UserGroupRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.security.SecureRandom; +import java.util.*; + +@Service +@RequiredArgsConstructor +@Slf4j +public class InviteService { + + static final int MIN_PASSWORD_LENGTH = 8; + private static final String CODE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + private static final int CODE_LENGTH = 10; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final InviteTokenRepository inviteTokenRepository; + private final AppUserRepository appUserRepository; + private final UserGroupRepository userGroupRepository; + private final PasswordEncoder passwordEncoder; + + public String generateCode() { + String code; + do { + code = buildRandomCode(); + } while (inviteTokenRepository.findByCode(code).isPresent()); + return code; + } + + public InviteToken validateCode(String code) { + InviteToken token = inviteTokenRepository.findByCode(code) + .orElseThrow(() -> DomainException.notFound(ErrorCode.INVITE_NOT_FOUND, "Invite not found: " + code)); + checkTokenState(token); + return token; + } + + @Transactional + public InviteToken createInvite(CreateInviteRequest dto, AppUser creator) { + Set groupIds = new HashSet<>(); + if (dto.getGroupIds() != null && !dto.getGroupIds().isEmpty()) { + List groups = userGroupRepository.findAllById(dto.getGroupIds()); + groups.forEach(g -> groupIds.add(g.getId())); + } + + InviteToken token = InviteToken.builder() + .code(generateCode()) + .label(dto.getLabel()) + .maxUses(dto.getMaxUses()) + .prefillFirstName(dto.getPrefillFirstName()) + .prefillLastName(dto.getPrefillLastName()) + .prefillEmail(dto.getPrefillEmail()) + .groupIds(groupIds) + .expiresAt(dto.getExpiresAt()) + .createdBy(creator) + .build(); + + return inviteTokenRepository.save(token); + } + + @Transactional + public AppUser redeemInvite(RegisterRequest dto) { + InviteToken token = inviteTokenRepository.findByCodeForUpdate(dto.getCode()) + .orElseThrow(() -> DomainException.notFound(ErrorCode.INVITE_NOT_FOUND, "Invite not found: " + dto.getCode())); + + checkTokenState(token); + + if (dto.getPassword() == null || dto.getPassword().length() < MIN_PASSWORD_LENGTH) { + throw DomainException.badRequest(ErrorCode.VALIDATION_ERROR, + "Password must be at least " + MIN_PASSWORD_LENGTH + " characters"); + } + + if (dto.getEmail() != null) { + appUserRepository.findByEmail(dto.getEmail()).ifPresent(existing -> { + throw DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE, + "Email already registered: " + dto.getEmail()); + }); + } + + Set groups = new HashSet<>(); + if (!token.getGroupIds().isEmpty()) { + groups.addAll(userGroupRepository.findAllById(token.getGroupIds())); + } + + AppUser user = AppUser.builder() + .email(dto.getEmail()) + .password(passwordEncoder.encode(dto.getPassword())) + .firstName(dto.getFirstName()) + .lastName(dto.getLastName()) + .groups(groups) + .enabled(true) + .build(); + + AppUser saved = appUserRepository.save(user); + + token.setUseCount(token.getUseCount() + 1); + inviteTokenRepository.save(token); + + log.info("User {} registered via invite code {}", dto.getEmail(), dto.getCode()); + return saved; + } + + @Transactional + public void revokeInvite(UUID id) { + InviteToken token = inviteTokenRepository.findById(id) + .orElseThrow(() -> DomainException.notFound(ErrorCode.INVITE_NOT_FOUND, "Invite not found: " + id)); + token.setRevoked(true); + inviteTokenRepository.save(token); + } + + public List listInvites(boolean activeOnly, String appBaseUrl) { + List tokens = activeOnly + ? inviteTokenRepository.findActive() + : inviteTokenRepository.findAllOrderedByCreatedAt(); + return tokens.stream().map(t -> toListItemDTO(t, appBaseUrl)).toList(); + } + + public InviteListItemDTO toListItemDTO(InviteToken token, String appBaseUrl) { + String status; + if (token.isRevoked()) status = "revoked"; + else if (token.isExpired()) status = "expired"; + else if (token.isExhausted()) status = "exhausted"; + else status = "active"; + + return InviteListItemDTO.builder() + .id(token.getId()) + .code(token.getCode()) + .displayCode(formatDisplayCode(token.getCode())) + .label(token.getLabel()) + .useCount(token.getUseCount()) + .maxUses(token.getMaxUses()) + .expiresAt(token.getExpiresAt()) + .revoked(token.isRevoked()) + .status(status) + .createdAt(token.getCreatedAt()) + .shareableUrl(appBaseUrl + "/register?code=" + token.getCode()) + .build(); + } + + private void checkTokenState(InviteToken token) { + if (token.isRevoked()) { + throw DomainException.conflict(ErrorCode.INVITE_REVOKED, "Invite has been revoked"); + } + if (token.isExpired()) { + throw new DomainException(ErrorCode.INVITE_EXPIRED, org.springframework.http.HttpStatus.GONE, + "Invite has expired"); + } + if (token.isExhausted()) { + throw DomainException.conflict(ErrorCode.INVITE_EXHAUSTED, "Invite use limit reached"); + } + } + + private String buildRandomCode() { + StringBuilder sb = new StringBuilder(CODE_LENGTH); + for (int i = 0; i < CODE_LENGTH; i++) { + sb.append(CODE_ALPHABET.charAt(SECURE_RANDOM.nextInt(CODE_ALPHABET.length()))); + } + return sb.toString(); + } + + public static String formatDisplayCode(String code) { + if (code == null || code.length() != CODE_LENGTH) return code; + return code.substring(0, 5) + "-" + code.substring(5); + } +} diff --git a/backend/src/main/resources/db/migration/V45__add_invite_tokens.sql b/backend/src/main/resources/db/migration/V45__add_invite_tokens.sql new file mode 100644 index 00000000..882c6c87 --- /dev/null +++ b/backend/src/main/resources/db/migration/V45__add_invite_tokens.sql @@ -0,0 +1,22 @@ +CREATE TABLE invite_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(10) UNIQUE NOT NULL, + label VARCHAR(255), + max_uses INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + prefill_first_name VARCHAR(255), + prefill_last_name VARCHAR(255), + prefill_email VARCHAR(255), + expires_at TIMESTAMP, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + revoked BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX idx_invite_tokens_code ON invite_tokens(code); + +CREATE TABLE invite_token_group_ids ( + invite_token_id UUID NOT NULL REFERENCES invite_tokens(id), + group_id UUID NOT NULL, + PRIMARY KEY (invite_token_id, group_id) +); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/AuthControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/AuthControllerTest.java new file mode 100644 index 00000000..4401075c --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/AuthControllerTest.java @@ -0,0 +1,191 @@ +package org.raddatz.familienarchiv.controller; + +import tools.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.config.SecurityConfig; +import org.raddatz.familienarchiv.dto.InvitePrefillDTO; +import org.raddatz.familienarchiv.dto.RegisterRequest; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.InviteToken; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.InviteService; +import org.raddatz.familienarchiv.service.PasswordResetService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(AuthController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class AuthControllerTest { + + @Autowired MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean PasswordResetService passwordResetService; + @MockitoBean InviteService inviteService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + // ─── GET /api/auth/invite/{code} ────────────────────────────────────────── + + @Test + void getInvitePrefill_returns200_withPrefillData_whenCodeValid() throws Exception { + InviteToken token = InviteToken.builder() + .code("ABCDE12345") + .prefillFirstName("Helga") + .prefillLastName("Muster") + .prefillEmail("helga@muster.de") + .build(); + when(inviteService.validateCode("ABCDE12345")).thenReturn(token); + + mockMvc.perform(get("/api/auth/invite/ABCDE12345")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.firstName").value("Helga")) + .andExpect(jsonPath("$.lastName").value("Muster")) + .andExpect(jsonPath("$.email").value("helga@muster.de")); + } + + @Test + void getInvitePrefill_returns404_whenCodeNotFound() throws Exception { + when(inviteService.validateCode("UNKNOWN123")) + .thenThrow(DomainException.notFound(ErrorCode.INVITE_NOT_FOUND, "not found")); + + mockMvc.perform(get("/api/auth/invite/UNKNOWN123")) + .andExpect(status().isNotFound()); + } + + @Test + void getInvitePrefill_returns409_whenTokenRevoked() throws Exception { + when(inviteService.validateCode("REVOKED123")) + .thenThrow(DomainException.conflict(ErrorCode.INVITE_REVOKED, "revoked")); + + mockMvc.perform(get("/api/auth/invite/REVOKED123")) + .andExpect(status().isConflict()); + } + + @Test + void getInvitePrefill_returns409_whenTokenExhausted() throws Exception { + when(inviteService.validateCode("EXHAUST123")) + .thenThrow(DomainException.conflict(ErrorCode.INVITE_EXHAUSTED, "exhausted")); + + mockMvc.perform(get("/api/auth/invite/EXHAUST123")) + .andExpect(status().isConflict()); + } + + @Test + void getInvitePrefill_returns410_whenTokenExpired() throws Exception { + when(inviteService.validateCode("EXPIRED123")) + .thenThrow(new DomainException(ErrorCode.INVITE_EXPIRED, + org.springframework.http.HttpStatus.GONE, "expired")); + + mockMvc.perform(get("/api/auth/invite/EXPIRED123")) + .andExpect(status().isGone()); + } + + // ─── POST /api/auth/register ────────────────────────────────────────────── + + @Test + void register_returns201_withCreatedUser_onHappyPath() throws Exception { + AppUser user = AppUser.builder() + .id(UUID.randomUUID()) + .email("new@test.com") + .firstName("Max") + .lastName("Muster") + .build(); + when(inviteService.redeemInvite(any())).thenReturn(user); + + RegisterRequest req = new RegisterRequest(); + req.setCode("ABCDE12345"); + req.setEmail("new@test.com"); + req.setPassword("password123"); + req.setFirstName("Max"); + req.setLastName("Muster"); + + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.email").value("new@test.com")); + } + + @Test + void register_returns409_whenEmailAlreadyInUse() throws Exception { + when(inviteService.redeemInvite(any())) + .thenThrow(DomainException.conflict(ErrorCode.EMAIL_ALREADY_IN_USE, "already in use")); + + RegisterRequest req = new RegisterRequest(); + req.setCode("ABCDE12345"); + req.setEmail("dupe@test.com"); + req.setPassword("password123"); + + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isConflict()); + } + + @Test + void register_returns400_whenPasswordTooShort() throws Exception { + when(inviteService.redeemInvite(any())) + .thenThrow(DomainException.badRequest(ErrorCode.VALIDATION_ERROR, "too short")); + + RegisterRequest req = new RegisterRequest(); + req.setCode("ABCDE12345"); + req.setEmail("new@test.com"); + req.setPassword("abc"); + + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()); + } + + @Test + void register_returns404_whenInviteCodeNotFound() throws Exception { + when(inviteService.redeemInvite(any())) + .thenThrow(DomainException.notFound(ErrorCode.INVITE_NOT_FOUND, "not found")); + + RegisterRequest req = new RegisterRequest(); + req.setCode("INVALID123"); + req.setEmail("new@test.com"); + req.setPassword("password123"); + + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isNotFound()); + } + + @Test + void register_isPublic_noAuthRequired() throws Exception { + AppUser user = AppUser.builder().id(UUID.randomUUID()).email("pub@test.com").build(); + when(inviteService.redeemInvite(any())).thenReturn(user); + + RegisterRequest req = new RegisterRequest(); + req.setCode("ABCDE12345"); + req.setEmail("pub@test.com"); + req.setPassword("password123"); + + // No WithMockUser — must still succeed (no auth challenge) + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isCreated()); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/InviteControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/InviteControllerTest.java new file mode 100644 index 00000000..ebe682b9 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/InviteControllerTest.java @@ -0,0 +1,175 @@ +package org.raddatz.familienarchiv.controller; + +import tools.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.raddatz.familienarchiv.config.SecurityConfig; +import org.raddatz.familienarchiv.dto.CreateInviteRequest; +import org.raddatz.familienarchiv.dto.InviteListItemDTO; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.InviteToken; +import org.raddatz.familienarchiv.security.PermissionAspect; +import org.raddatz.familienarchiv.service.CustomUserDetailsService; +import org.raddatz.familienarchiv.service.InviteService; +import org.raddatz.familienarchiv.service.UserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(InviteController.class) +@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class}) +class InviteControllerTest { + + @Autowired MockMvc mockMvc; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @MockitoBean InviteService inviteService; + @MockitoBean UserService userService; + @MockitoBean CustomUserDetailsService customUserDetailsService; + + private InviteListItemDTO makeInviteDTO(UUID id, String code) { + return InviteListItemDTO.builder() + .id(id) + .code(code) + .displayCode(InviteService.formatDisplayCode(code)) + .useCount(0) + .revoked(false) + .status("active") + .createdAt(LocalDateTime.now()) + .shareableUrl("http://localhost:3000/register?code=" + code) + .build(); + } + + // ─── GET /api/invites ───────────────────────────────────────────────────── + + @Test + void listInvites_returns403_whenUserLacksAdminUserPermission() throws Exception { + mockMvc.perform(get("/api/invites")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "user@test.com") + void listInvites_returns403_whenAuthenticatedWithoutPermission() throws Exception { + mockMvc.perform(get("/api/invites")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"}) + void listInvites_returns200_withActiveInvites_byDefault() throws Exception { + UUID id = UUID.randomUUID(); + when(inviteService.listInvites(eq(true), anyString())) + .thenReturn(List.of(makeInviteDTO(id, "ABCDE12345"))); + + mockMvc.perform(get("/api/invites")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].code").value("ABCDE12345")) + .andExpect(jsonPath("$[0].status").value("active")); + } + + @Test + @WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"}) + void listInvites_returns200_withAllInvites_whenStatusAll() throws Exception { + UUID id = UUID.randomUUID(); + InviteListItemDTO revoked = makeInviteDTO(id, "REVOKED1234"); + when(inviteService.listInvites(eq(false), anyString())) + .thenReturn(List.of(revoked)); + + mockMvc.perform(get("/api/invites").param("status", "all")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].code").value("REVOKED1234")); + } + + // ─── POST /api/invites ──────────────────────────────────────────────────── + + @Test + void createInvite_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(post("/api/invites") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "user@test.com") + void createInvite_returns403_whenUserLacksAdminUserPermission() throws Exception { + mockMvc.perform(post("/api/invites") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"}) + void createInvite_returns201_withCreatedInvite() throws Exception { + AppUser admin = AppUser.builder().id(UUID.randomUUID()).email("admin@test.com").build(); + when(userService.findByEmail("admin@test.com")).thenReturn(admin); + + InviteToken savedToken = InviteToken.builder() + .id(UUID.randomUUID()) + .code("NEWCODE123") + .label("Für Familie") + .maxUses(1) + .useCount(0) + .build(); + when(inviteService.createInvite(any(), eq(admin))).thenReturn(savedToken); + + UUID id = savedToken.getId(); + InviteListItemDTO dto = makeInviteDTO(id, "NEWCODE123"); + dto.setLabel("Für Familie"); + when(inviteService.toListItemDTO(eq(savedToken), anyString())).thenReturn(dto); + + CreateInviteRequest req = new CreateInviteRequest(); + req.setLabel("Für Familie"); + req.setMaxUses(1); + + mockMvc.perform(post("/api/invites") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.code").value("NEWCODE123")) + .andExpect(jsonPath("$.label").value("Für Familie")); + } + + // ─── DELETE /api/invites/{id} ───────────────────────────────────────────── + + @Test + void revokeInvite_returns401_whenUnauthenticated() throws Exception { + mockMvc.perform(delete("/api/invites/" + UUID.randomUUID())) + .andExpect(status().isUnauthorized()); + } + + @Test + @WithMockUser(username = "user@test.com") + void revokeInvite_returns403_whenUserLacksAdminUserPermission() throws Exception { + mockMvc.perform(delete("/api/invites/" + UUID.randomUUID())) + .andExpect(status().isForbidden()); + } + + @Test + @WithMockUser(username = "admin@test.com", authorities = {"ADMIN_USER"}) + void revokeInvite_returns204_whenSuccessful() throws Exception { + UUID id = UUID.randomUUID(); + + mockMvc.perform(delete("/api/invites/" + id)) + .andExpect(status().isNoContent()); + + verify(inviteService).revokeInvite(id); + } +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/InviteServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/InviteServiceTest.java new file mode 100644 index 00000000..501020f7 --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/InviteServiceTest.java @@ -0,0 +1,269 @@ +package org.raddatz.familienarchiv.service; + +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.raddatz.familienarchiv.dto.CreateInviteRequest; +import org.raddatz.familienarchiv.dto.RegisterRequest; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.InviteToken; +import org.raddatz.familienarchiv.model.UserGroup; +import org.raddatz.familienarchiv.repository.AppUserRepository; +import org.raddatz.familienarchiv.repository.InviteTokenRepository; +import org.raddatz.familienarchiv.repository.UserGroupRepository; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class InviteServiceTest { + + @Mock InviteTokenRepository inviteTokenRepository; + @Mock AppUserRepository appUserRepository; + @Mock UserGroupRepository userGroupRepository; + @Mock PasswordEncoder passwordEncoder; + @InjectMocks InviteService inviteService; + + private AppUser admin; + + @BeforeEach + void setUp() { + admin = AppUser.builder().id(UUID.randomUUID()).email("admin@test.com").build(); + } + + // ─── generateCode ──────────────────────────────────────────────────────────── + + @Test + void generateCode_produces10CharUppercaseAlphanumeric() { + when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty()); + String code = inviteService.generateCode(); + assertThat(code).hasSize(10).matches("[A-Z0-9]{10}"); + } + + @Test + void generateCode_retriesOnCollision() { + when(inviteTokenRepository.findByCode(anyString())) + .thenReturn(Optional.of(InviteToken.builder().code("AAAAAAAAAA").build())) + .thenReturn(Optional.empty()); + String code = inviteService.generateCode(); + assertThat(code).hasSize(10); + verify(inviteTokenRepository, times(2)).findByCode(anyString()); + } + + // ─── validateCode ───────────────────────────────────────────────────────── + + @Test + void validateCode_throwsNotFound_whenCodeUnknown() { + when(inviteTokenRepository.findByCode("UNKNOWN123")).thenReturn(Optional.empty()); + assertThatThrownBy(() -> inviteService.validateCode("UNKNOWN123")) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVITE_NOT_FOUND); + } + + @Test + void validateCode_throwsRevoked_whenTokenIsRevoked() { + InviteToken token = InviteToken.builder().code("ABCDE12345").revoked(true).build(); + when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token)); + assertThatThrownBy(() -> inviteService.validateCode("ABCDE12345")) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVITE_REVOKED); + } + + @Test + void validateCode_throwsExpired_whenTokenIsPastExpiryDate() { + InviteToken token = InviteToken.builder().code("ABCDE12345") + .expiresAt(LocalDateTime.now().minusHours(1)) + .build(); + when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token)); + assertThatThrownBy(() -> inviteService.validateCode("ABCDE12345")) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVITE_EXPIRED); + } + + @Test + void validateCode_throwsExhausted_whenUseCountMeetsMaxUses() { + InviteToken token = InviteToken.builder().code("ABCDE12345").maxUses(1).useCount(1).build(); + when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token)); + assertThatThrownBy(() -> inviteService.validateCode("ABCDE12345")) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVITE_EXHAUSTED); + } + + @Test + void validateCode_returnsToken_whenValid() { + InviteToken token = InviteToken.builder().code("ABCDE12345").build(); + when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token)); + InviteToken result = inviteService.validateCode("ABCDE12345"); + assertThat(result.getCode()).isEqualTo("ABCDE12345"); + } + + @Test + void validateCode_acceptsUnlimitedInvite_afterManyUses() { + InviteToken token = InviteToken.builder().code("ABCDE12345").maxUses(null).useCount(100).build(); + when(inviteTokenRepository.findByCode("ABCDE12345")).thenReturn(Optional.of(token)); + assertThatNoException().isThrownBy(() -> inviteService.validateCode("ABCDE12345")); + } + + // ─── createInvite ──────────────────────────────────────────────────────────── + + @Test + void createInvite_savesTokenWithGeneratedCode() { + when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty()); + when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + CreateInviteRequest req = new CreateInviteRequest(); + req.setLabel("Für Oma Helga"); + req.setMaxUses(1); + + InviteToken result = inviteService.createInvite(req, admin); + + assertThat(result.getLabel()).isEqualTo("Für Oma Helga"); + assertThat(result.getMaxUses()).isEqualTo(1); + assertThat(result.getCode()).hasSize(10); + assertThat(result.getCreatedBy()).isEqualTo(admin); + } + + @Test + void createInvite_assignsGroups_whenGroupIdsProvided() { + UserGroup g = UserGroup.builder().id(UUID.randomUUID()).name("Familie").build(); + when(inviteTokenRepository.findByCode(anyString())).thenReturn(Optional.empty()); + when(userGroupRepository.findAllById(anyList())).thenReturn(List.of(g)); + when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + CreateInviteRequest req = new CreateInviteRequest(); + req.setGroupIds(List.of(g.getId())); + + InviteToken result = inviteService.createInvite(req, admin); + assertThat(result.getGroupIds()).contains(g.getId()); + } + + // ─── redeemInvite ───────────────────────────────────────────────────────── + + @Test + void redeemInvite_createsUserAndIncrementsUseCount() { + InviteToken token = InviteToken.builder() + .code("ABCDE12345").maxUses(2).useCount(0) + .groupIds(new HashSet<>()) + .build(); + when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token)); + when(appUserRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + when(passwordEncoder.encode(anyString())).thenReturn("encoded"); + when(appUserRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + RegisterRequest req = new RegisterRequest(); + req.setCode("ABCDE12345"); + req.setEmail("new@test.com"); + req.setPassword("password123"); + req.setFirstName("Max"); + req.setLastName("Muster"); + + AppUser created = inviteService.redeemInvite(req); + + assertThat(created.getEmail()).isEqualTo("new@test.com"); + assertThat(token.getUseCount()).isEqualTo(1); + verify(appUserRepository).save(any()); + verify(inviteTokenRepository).save(token); + } + + @Test + void redeemInvite_throwsBadRequest_whenPasswordTooShort() { + InviteToken token = InviteToken.builder().code("ABCDE12345").groupIds(new HashSet<>()).build(); + when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token)); + + RegisterRequest req = new RegisterRequest(); + req.setCode("ABCDE12345"); + req.setEmail("new@test.com"); + req.setPassword("short"); + + assertThatThrownBy(() -> inviteService.redeemInvite(req)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.VALIDATION_ERROR); + } + + @Test + void redeemInvite_throwsConflict_whenEmailAlreadyInUse() { + InviteToken token = InviteToken.builder().code("ABCDE12345").groupIds(new HashSet<>()).build(); + when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token)); + when(appUserRepository.findByEmail("dupe@test.com")) + .thenReturn(Optional.of(AppUser.builder().email("dupe@test.com").build())); + + RegisterRequest req = new RegisterRequest(); + req.setCode("ABCDE12345"); + req.setEmail("dupe@test.com"); + req.setPassword("password123"); + + assertThatThrownBy(() -> inviteService.redeemInvite(req)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.EMAIL_ALREADY_IN_USE); + } + + @Test + void redeemInvite_assignsGroupsFromToken() { + UUID groupId = UUID.randomUUID(); + UserGroup g = UserGroup.builder().id(groupId).name("Familie").build(); + InviteToken token = InviteToken.builder() + .code("ABCDE12345") + .groupIds(new HashSet<>(Set.of(groupId))) + .build(); + when(inviteTokenRepository.findByCodeForUpdate("ABCDE12345")).thenReturn(Optional.of(token)); + when(appUserRepository.findByEmail(anyString())).thenReturn(Optional.empty()); + when(userGroupRepository.findAllById(any())).thenReturn(List.of(g)); + when(passwordEncoder.encode(anyString())).thenReturn("encoded"); + when(appUserRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + RegisterRequest req = new RegisterRequest(); + req.setCode("ABCDE12345"); + req.setEmail("new@test.com"); + req.setPassword("password123"); + + AppUser created = inviteService.redeemInvite(req); + assertThat(created.getGroups()).contains(g); + } + + // ─── revokeInvite ───────────────────────────────────────────────────────── + + @Test + void revokeInvite_setsRevokedTrue() { + UUID id = UUID.randomUUID(); + InviteToken token = InviteToken.builder().id(id).code("ABCDE12345").build(); + when(inviteTokenRepository.findById(id)).thenReturn(Optional.of(token)); + when(inviteTokenRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + inviteService.revokeInvite(id); + + assertThat(token.isRevoked()).isTrue(); + verify(inviteTokenRepository).save(token); + } + + @Test + void revokeInvite_throwsNotFound_whenIdUnknown() { + UUID id = UUID.randomUUID(); + when(inviteTokenRepository.findById(id)).thenReturn(Optional.empty()); + assertThatThrownBy(() -> inviteService.revokeInvite(id)) + .isInstanceOf(DomainException.class) + .extracting(e -> ((DomainException) e).getCode()) + .isEqualTo(ErrorCode.INVITE_NOT_FOUND); + } +}