feat(invites): implement invite-based self-service registration backend
- V45 migration: invite_tokens + invite_token_group_ids tables
- InviteToken entity with @ElementCollection group IDs
- InviteService: code generation, validation, redemption (pessimistic lock prevents TOCTOU), revoke, list
- RateLimitInterceptor (Caffeine-backed, 10 req/min per IP) registered via WebMvcConfigurer
- AuthController: GET /api/auth/invite/{code} + POST /api/auth/register (both public)
- InviteController: GET/POST/DELETE /api/invites (ADMIN_USER permission)
- SecurityConfig: permitAll for new public auth endpoints
- ErrorCode: INVITE_NOT_FOUND, INVITE_EXHAUSTED, INVITE_REVOKED, INVITE_EXPIRED
- 36 new tests (InviteServiceTest, AuthControllerTest, InviteControllerTest)
Closes #269
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,12 @@
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Caffeine cache for in-memory rate limiting -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- OpenAPI / Swagger UI — enabled only in the dev Spring profile -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
|
||||
@@ -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<String, AtomicInteger> 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<AppUser> register(@RequestBody RegisterRequest request) {
|
||||
AppUser user = inviteService.redeemInvite(request);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(user);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<InviteListItemDTO> 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<InviteListItemDTO> 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<Void> revokeInvite(@PathVariable UUID id) {
|
||||
inviteService.revokeInvite(id);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -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<UUID> groupIds;
|
||||
private LocalDateTime expiresAt;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<UUID> 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();
|
||||
}
|
||||
}
|
||||
@@ -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<InviteToken, UUID> {
|
||||
|
||||
Optional<InviteToken> findByCode(String code);
|
||||
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT t FROM InviteToken t WHERE t.code = :code")
|
||||
Optional<InviteToken> 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<InviteToken> findActive();
|
||||
|
||||
@Query("SELECT t FROM InviteToken t ORDER BY t.createdAt DESC")
|
||||
List<InviteToken> findAllOrderedByCreatedAt();
|
||||
}
|
||||
@@ -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<UUID> groupIds = new HashSet<>();
|
||||
if (dto.getGroupIds() != null && !dto.getGroupIds().isEmpty()) {
|
||||
List<UserGroup> 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<UserGroup> 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<InviteListItemDTO> listInvites(boolean activeOnly, String appBaseUrl) {
|
||||
List<InviteToken> 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user