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);
+ }
+}