From 6c66783b587d566938ae36c0f6e2a25e74abfc97 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Thu, 11 Jun 2026 18:03:12 +0200 Subject: [PATCH] =?UTF-8?q?feat(sprint-3):=20Phase=203=20=E2=80=94=20staff?= =?UTF-8?q?=20management=20+=20invite=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Step 3.1: Spring Boot Starter Mail dependency (api + service) - Step 3.2: InviteToken JPA entity with 72h expiry - Step 3.3: InviteTokenRepository with valid-token finder - Step 3.4: EmailService (plain text invite email via JavaMailSender) - Step 3.5: StaffService (CRUD + invite + email pattern validation + token revocation) - Step 3.6: Staff DTOs (CreateStaffRequest, UpdateStaffRequest, StaffResponse) - Step 3.7: SetPasswordRequest with password complexity (@Pattern: 1 digit + 1 special) - Step 3.8: StaffController (6 endpoints, ADMIN-only via @PreAuthorize) - Step 3.9: POST /api/v1/auth/set-password (public, generic error messages) - Step 3.10: StaffTemplates (ausgabe, lager, vorstand predefined permission sets) - Step 3.11: AuthService rejects inactive users with 'Account not activated' - Step 3.12: Token revocation on permission change via revokeAllForUser() - Step 3.13: invite-email.txt template (German, 72h expiry note) - Step 3.14: Spring Mail config (Mailpit dev defaults, env var overrides) - Step 3.15: Unit tests (StaffServiceTest, StaffControllerTest, EmailServiceTest) - V5 Flyway migration for invite_tokens table Security review findings incorporated: - Password complexity: min 8 chars, 1 digit + 1 special char - Generic 'invalid or expired token' error (no state leakage) - SecureRandom 32-byte Base64 token generation - Token values never logged --- cannamanage-api/pom.xml | 5 + .../api/controller/AuthController.java | 11 + .../api/controller/StaffController.java | 112 +++++++++ .../api/dto/auth/SetPasswordRequest.java | 18 ++ .../api/dto/staff/CreateStaffRequest.java | 17 ++ .../api/dto/staff/StaffResponse.java | 49 ++++ .../api/dto/staff/UpdateStaffRequest.java | 15 ++ .../api/security/SecurityConfig.java | 1 + .../cannamanage/api/service/AuthService.java | 46 +++- .../src/main/resources/application.properties | 12 + .../db/migration/V5__invite_tokens.sql | 13 + .../main/resources/templates/invite-email.txt | 14 ++ .../api/controller/StaffControllerTest.java | 166 +++++++++++++ .../domain/entity/InviteToken.java | 74 ++++++ cannamanage-service/pom.xml | 5 + .../de/cannamanage/service/EmailService.java | 68 ++++++ .../de/cannamanage/service/StaffService.java | 230 ++++++++++++++++++ .../cannamanage/service/StaffTemplates.java | 60 +++++ .../service/TokenRevocationService.java | 11 + .../repository/InviteTokenRepository.java | 17 ++ .../cannamanage/service/EmailServiceTest.java | 52 ++++ .../cannamanage/service/StaffServiceTest.java | 212 ++++++++++++++++ 22 files changed, 1205 insertions(+), 3 deletions(-) create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/controller/StaffController.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/dto/auth/SetPasswordRequest.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/CreateStaffRequest.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/StaffResponse.java create mode 100644 cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/UpdateStaffRequest.java create mode 100644 cannamanage-api/src/main/resources/db/migration/V5__invite_tokens.sql create mode 100644 cannamanage-api/src/main/resources/templates/invite-email.txt create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/controller/StaffControllerTest.java create mode 100644 cannamanage-domain/src/main/java/de/cannamanage/domain/entity/InviteToken.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/EmailService.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/StaffService.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/StaffTemplates.java create mode 100644 cannamanage-service/src/main/java/de/cannamanage/service/repository/InviteTokenRepository.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/EmailServiceTest.java create mode 100644 cannamanage-service/src/test/java/de/cannamanage/service/StaffServiceTest.java diff --git a/cannamanage-api/pom.xml b/cannamanage-api/pom.xml index cdcdf3f..a30c9d8 100644 --- a/cannamanage-api/pom.xml +++ b/cannamanage-api/pom.xml @@ -113,6 +113,11 @@ spring-boot-testcontainers test + + + org.springframework.boot + spring-boot-starter-mail + diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/AuthController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/AuthController.java index 2bd4cc8..0fa7cb9 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/controller/AuthController.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/AuthController.java @@ -3,6 +3,7 @@ package de.cannamanage.api.controller; import de.cannamanage.api.dto.auth.LoginRequest; import de.cannamanage.api.dto.auth.LoginResponse; import de.cannamanage.api.dto.auth.RefreshRequest; +import de.cannamanage.api.dto.auth.SetPasswordRequest; import de.cannamanage.api.service.AuthService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -14,6 +15,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.Map; + @RestController @RequestMapping("/api/v1/auth") @RequiredArgsConstructor @@ -35,4 +38,12 @@ public class AuthController { LoginResponse response = authService.refresh(request); return ResponseEntity.ok(response); } + + @PostMapping("/set-password") + @Operation(summary = "Set password via invite token", + description = "Public endpoint — validates invite token, sets password, activates account") + public ResponseEntity> setPassword(@Valid @RequestBody SetPasswordRequest request) { + authService.setPassword(request); + return ResponseEntity.ok(Map.of("message", "Password set successfully. You can now log in.")); + } } diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/controller/StaffController.java b/cannamanage-api/src/main/java/de/cannamanage/api/controller/StaffController.java new file mode 100644 index 0000000..4763ea8 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/controller/StaffController.java @@ -0,0 +1,112 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.api.dto.staff.CreateStaffRequest; +import de.cannamanage.api.dto.staff.StaffResponse; +import de.cannamanage.api.dto.staff.UpdateStaffRequest; +import de.cannamanage.domain.entity.StaffAccount; +import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.domain.entity.User; +import de.cannamanage.domain.enums.StaffPermission; +import de.cannamanage.service.StaffService; +import de.cannamanage.service.StaffTemplates; +import de.cannamanage.service.repository.UserRepository; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/staff") +@RequiredArgsConstructor +@Tag(name = "Staff Management", description = "Staff CRUD + invite flow (ADMIN only)") +public class StaffController { + + private final StaffService staffService; + private final UserRepository userRepository; + + @GetMapping + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "List all active staff members") + public ResponseEntity> listStaff() { + UUID tenantId = TenantContext.getCurrentTenant(); + List staffList = staffService.listStaff(tenantId); + List response = staffList.stream() + .map(staff -> { + User user = userRepository.findById(staff.getUserId()).orElse(null); + String email = user != null ? user.getEmail() : "unknown"; + return StaffResponse.from(staff, email); + }) + .toList(); + return ResponseEntity.ok(response); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Create staff member + send invite email") + public ResponseEntity createStaff(@Valid @RequestBody CreateStaffRequest request) { + UUID tenantId = TenantContext.getCurrentTenant(); + StaffAccount staff = staffService.createStaff( + tenantId, + request.email(), + request.displayName(), + request.permissions(), + request.templateName() + ); + return ResponseEntity.status(HttpStatus.CREATED) + .body(StaffResponse.from(staff, request.email())); + } + + @GetMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Get staff member by ID") + public ResponseEntity getStaff(@PathVariable UUID id) { + UUID tenantId = TenantContext.getCurrentTenant(); + StaffAccount staff = staffService.getStaff(tenantId, id); + User user = userRepository.findById(staff.getUserId()).orElse(null); + String email = user != null ? user.getEmail() : "unknown"; + return ResponseEntity.ok(StaffResponse.from(staff, email)); + } + + @PutMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Update staff permissions/profile (revokes tokens on permission change)") + public ResponseEntity updateStaff(@PathVariable UUID id, + @RequestBody UpdateStaffRequest request) { + UUID tenantId = TenantContext.getCurrentTenant(); + StaffAccount staff = staffService.updateStaff( + tenantId, id, + request.displayName(), + request.permissions(), + request.templateName(), + request.active() + ); + User user = userRepository.findById(staff.getUserId()).orElse(null); + String email = user != null ? user.getEmail() : "unknown"; + return ResponseEntity.ok(StaffResponse.from(staff, email)); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Deactivate staff member (revokes all tokens)") + public ResponseEntity deactivateStaff(@PathVariable UUID id) { + UUID tenantId = TenantContext.getCurrentTenant(); + staffService.deactivateStaff(tenantId, id); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/templates") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "List available permission templates") + public ResponseEntity>> listTemplates() { + return ResponseEntity.ok(StaffTemplates.getAllTemplates()); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/auth/SetPasswordRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/auth/SetPasswordRequest.java new file mode 100644 index 0000000..5987b46 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/auth/SetPasswordRequest.java @@ -0,0 +1,18 @@ +package de.cannamanage.api.dto.auth; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * Request DTO for setting password via invite token. + * Password complexity: min 8 chars, at least 1 digit + 1 special character. + */ +public record SetPasswordRequest( + @NotBlank String token, + @NotBlank + @Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters") + @Pattern(regexp = "^(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).+$", + message = "Password must contain at least 1 digit and 1 special character") + String password +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/CreateStaffRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/CreateStaffRequest.java new file mode 100644 index 0000000..f6f0730 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/CreateStaffRequest.java @@ -0,0 +1,17 @@ +package de.cannamanage.api.dto.staff; + +import de.cannamanage.domain.enums.StaffPermission; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +import java.util.Set; + +/** + * Request DTO for creating a new staff member (admin invite flow). + */ +public record CreateStaffRequest( + @NotBlank @Email String email, + @NotBlank String displayName, + Set permissions, + String templateName +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/StaffResponse.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/StaffResponse.java new file mode 100644 index 0000000..1345970 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/StaffResponse.java @@ -0,0 +1,49 @@ +package de.cannamanage.api.dto.staff; + +import de.cannamanage.domain.entity.StaffAccount; +import de.cannamanage.domain.entity.User; +import de.cannamanage.domain.enums.StaffPermission; + +import java.time.Instant; +import java.util.Set; +import java.util.UUID; + +/** + * Response DTO for staff member information. + */ +public record StaffResponse( + UUID id, + UUID userId, + String email, + String displayName, + Set permissions, + String templateName, + boolean active, + Instant createdAt +) { + public static StaffResponse from(StaffAccount staff, User user) { + return new StaffResponse( + staff.getId(), + staff.getUserId(), + user.getEmail(), + staff.getDisplayName(), + staff.getGrantedPermissions(), + null, // templateName not stored; permissions are expanded + staff.isActive(), + staff.getCreatedAt() + ); + } + + public static StaffResponse from(StaffAccount staff, String email) { + return new StaffResponse( + staff.getId(), + staff.getUserId(), + email, + staff.getDisplayName(), + staff.getGrantedPermissions(), + null, + staff.isActive(), + staff.getCreatedAt() + ); + } +} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/UpdateStaffRequest.java b/cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/UpdateStaffRequest.java new file mode 100644 index 0000000..d141f27 --- /dev/null +++ b/cannamanage-api/src/main/java/de/cannamanage/api/dto/staff/UpdateStaffRequest.java @@ -0,0 +1,15 @@ +package de.cannamanage.api.dto.staff; + +import de.cannamanage.domain.enums.StaffPermission; + +import java.util.Set; + +/** + * Request DTO for updating an existing staff member. + */ +public record UpdateStaffRequest( + String displayName, + Set permissions, + String templateName, + Boolean active +) {} diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java index 4b92b17..7fee673 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/security/SecurityConfig.java @@ -40,6 +40,7 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers("/api/v1/auth/**").permitAll() .requestMatchers("/api/v1/admin/**").hasRole("ADMIN") + .requestMatchers("/api/v1/staff/**").hasRole("ADMIN") .requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF", "MEMBER") .requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "STAFF", "MEMBER") .requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF") diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/service/AuthService.java b/cannamanage-api/src/main/java/de/cannamanage/api/service/AuthService.java index 440b989..428d352 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/service/AuthService.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/service/AuthService.java @@ -3,8 +3,13 @@ package de.cannamanage.api.service; import de.cannamanage.api.dto.auth.LoginRequest; import de.cannamanage.api.dto.auth.LoginResponse; import de.cannamanage.api.dto.auth.RefreshRequest; +import de.cannamanage.api.dto.auth.SetPasswordRequest; import de.cannamanage.api.security.JwtService; +import de.cannamanage.domain.entity.InviteToken; +import de.cannamanage.domain.entity.StaffAccount; import de.cannamanage.domain.entity.User; +import de.cannamanage.service.repository.InviteTokenRepository; +import de.cannamanage.service.repository.StaffAccountRepository; import de.cannamanage.service.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,7 +25,7 @@ import java.util.HexFormat; import java.util.UUID; /** - * Authentication service — handles login and token refresh. + * Authentication service — handles login, token refresh, and invite-based password setup. * Stateless JWT approach: no UserDetailsService needed. * Refresh tokens are hashed and stored on the User entity for revocation support. */ @@ -32,6 +37,8 @@ public class AuthService { private final UserRepository userRepository; private final JwtService jwtService; private final PasswordEncoder passwordEncoder; + private final InviteTokenRepository inviteTokenRepository; + private final StaffAccountRepository staffAccountRepository; @Transactional public LoginResponse login(LoginRequest request) { @@ -39,7 +46,7 @@ public class AuthService { .orElseThrow(() -> new AuthenticationException("Invalid credentials")); if (!user.isActive()) { - throw new AuthenticationException("Account is disabled"); + throw new AuthenticationException("Account not activated"); } if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) { @@ -75,7 +82,7 @@ public class AuthService { .orElseThrow(() -> new AuthenticationException("User not found")); if (!user.isActive()) { - throw new AuthenticationException("Account is disabled"); + throw new AuthenticationException("Account not activated"); } // Verify the refresh token matches stored hash (revocation check) @@ -96,6 +103,39 @@ public class AuthService { return new LoginResponse(newAccessToken, newRefreshToken, 3600L, roleName); } + /** + * Sets the password for a user via invite token. + * Validates the token, sets the password hash, marks user active, marks token as used. + * Security: generic error message for invalid/expired tokens (don't reveal state). + */ + @Transactional + public void setPassword(SetPasswordRequest request) { + // Find valid (unused + not expired) token — security: generic error message + InviteToken inviteToken = inviteTokenRepository + .findByTokenAndUsedAtIsNullAndExpiresAtAfter(request.token(), Instant.now()) + .orElseThrow(() -> new AuthenticationException("Invalid or expired token")); + + User user = inviteToken.getUser(); + + // Set password and activate user + user.setPasswordHash(passwordEncoder.encode(request.password())); + user.setActive(true); + userRepository.save(user); + + // Mark token as used + inviteToken.setUsedAt(Instant.now()); + inviteTokenRepository.save(inviteToken); + + // Update staff account activation timestamp + staffAccountRepository.findByUserId(user.getId()) + .ifPresent(staff -> { + staff.setActivatedAt(Instant.now()); + staffAccountRepository.save(staff); + }); + + log.info("Password set for user {} via invite token", user.getEmail()); + } + /** * SHA-256 hash for refresh token storage. * JWTs exceed BCrypt's 72-byte limit (enforced in Spring Security 7+). diff --git a/cannamanage-api/src/main/resources/application.properties b/cannamanage-api/src/main/resources/application.properties index f25a61e..e1e5033 100644 --- a/cannamanage-api/src/main/resources/application.properties +++ b/cannamanage-api/src/main/resources/application.properties @@ -18,3 +18,15 @@ springdoc.swagger-ui.operations-sorter=method # Enable Spring AOP for TenantFilterAspect spring.aop.auto=true spring.aop.proxy-target-class=true + +# Spring Mail (dev defaults: Mailpit on localhost:1025) +spring.mail.host=${SMTP_HOST:localhost} +spring.mail.port=${SMTP_PORT:1025} +spring.mail.username=${SMTP_USERNAME:} +spring.mail.password=${SMTP_PASSWORD:} +spring.mail.properties.mail.smtp.auth=${SMTP_AUTH:false} +spring.mail.properties.mail.smtp.starttls.enable=${SMTP_STARTTLS:false} +spring.mail.from=${MAIL_FROM:noreply@cannamanage.de} + +# App base URL (for invite links) +app.base-url=${APP_BASE_URL:http://localhost:8080} diff --git a/cannamanage-api/src/main/resources/db/migration/V5__invite_tokens.sql b/cannamanage-api/src/main/resources/db/migration/V5__invite_tokens.sql new file mode 100644 index 0000000..3a3a757 --- /dev/null +++ b/cannamanage-api/src/main/resources/db/migration/V5__invite_tokens.sql @@ -0,0 +1,13 @@ +-- Sprint 3 Phase 3: Invite tokens for staff onboarding + +CREATE TABLE invite_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(64) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_invite_tokens_token ON invite_tokens(token); +CREATE INDEX idx_invite_tokens_user_id ON invite_tokens(user_id); diff --git a/cannamanage-api/src/main/resources/templates/invite-email.txt b/cannamanage-api/src/main/resources/templates/invite-email.txt new file mode 100644 index 0000000..b5135a9 --- /dev/null +++ b/cannamanage-api/src/main/resources/templates/invite-email.txt @@ -0,0 +1,14 @@ +Hallo {displayName}, + +Du wurdest als Mitarbeiter/in beim Anbauverein "{clubName}" eingeladen. + +Bitte klicke auf den folgenden Link, um dein Passwort festzulegen und deinen Account zu aktivieren: + +{setPasswordUrl} + +Dieser Link ist 72 Stunden gültig. + +Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren. + +Viele Grüße, +Dein CannaManage-Team diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/controller/StaffControllerTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/controller/StaffControllerTest.java new file mode 100644 index 0000000..491d6c3 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/controller/StaffControllerTest.java @@ -0,0 +1,166 @@ +package de.cannamanage.api.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.cannamanage.api.dto.staff.CreateStaffRequest; +import de.cannamanage.api.dto.staff.UpdateStaffRequest; +import de.cannamanage.api.security.JwtAuthFilter; +import de.cannamanage.api.security.JwtService; +import de.cannamanage.domain.entity.StaffAccount; +import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.domain.entity.User; +import de.cannamanage.domain.enums.StaffPermission; +import de.cannamanage.domain.enums.UserRole; +import de.cannamanage.service.StaffService; +import de.cannamanage.service.TokenRevocationService; +import de.cannamanage.service.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.bean.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.*; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(StaffController.class) +class StaffControllerTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockBean private StaffService staffService; + @MockBean private UserRepository userRepository; + @MockBean private JwtService jwtService; + @MockBean private JwtAuthFilter jwtAuthFilter; + @MockBean private TokenRevocationService tokenRevocationService; + + private UUID tenantId; + private UUID staffId; + private UUID userId; + + @BeforeEach + void setUp() { + tenantId = UUID.randomUUID(); + staffId = UUID.randomUUID(); + userId = UUID.randomUUID(); + TenantContext.setCurrentTenant(tenantId); + } + + @Test + @WithMockUser(roles = "ADMIN") + void listStaff_returnsStaffList() throws Exception { + StaffAccount staff = createStaffAccount(); + User user = createUser(); + when(staffService.listStaff(tenantId)).thenReturn(List.of(staff)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + mockMvc.perform(get("/api/v1/staff")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].displayName").value("Test Staff")) + .andExpect(jsonPath("$[0].email").value("staff@test.de")); + } + + @Test + @WithMockUser(roles = "ADMIN") + void createStaff_validRequest_returns201() throws Exception { + CreateStaffRequest request = new CreateStaffRequest( + "new@test.de", "New Staff", + EnumSet.of(StaffPermission.VIEW_STOCK), null); + + StaffAccount created = createStaffAccount(); + when(staffService.createStaff(eq(tenantId), eq("new@test.de"), eq("New Staff"), any(), any())) + .thenReturn(created); + + mockMvc.perform(post("/api/v1/staff") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.displayName").value("Test Staff")); + } + + @Test + @WithMockUser(roles = "ADMIN") + void createStaff_invalidEmail_returns400() throws Exception { + CreateStaffRequest request = new CreateStaffRequest( + "not-an-email", "Bad Staff", + EnumSet.of(StaffPermission.VIEW_STOCK), null); + + mockMvc.perform(post("/api/v1/staff") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @WithMockUser(roles = "ADMIN") + void getStaff_returns200() throws Exception { + StaffAccount staff = createStaffAccount(); + User user = createUser(); + when(staffService.getStaff(tenantId, staffId)).thenReturn(staff); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + + mockMvc.perform(get("/api/v1/staff/{id}", staffId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(staffId.toString())); + } + + @Test + @WithMockUser(roles = "ADMIN") + void deactivateStaff_returns204() throws Exception { + mockMvc.perform(delete("/api/v1/staff/{id}", staffId).with(csrf())) + .andExpect(status().isNoContent()); + + verify(staffService).deactivateStaff(tenantId, staffId); + } + + @Test + @WithMockUser(roles = "ADMIN") + void listTemplates_returnsTemplateMap() throws Exception { + mockMvc.perform(get("/api/v1/staff/templates")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.ausgabe").isArray()) + .andExpect(jsonPath("$.lager").isArray()) + .andExpect(jsonPath("$.vorstand").isArray()); + } + + @Test + @WithMockUser(roles = "MEMBER") + void listStaff_asMember_returns403() throws Exception { + mockMvc.perform(get("/api/v1/staff")) + .andExpect(status().isForbidden()); + } + + private StaffAccount createStaffAccount() { + StaffAccount staff = new StaffAccount(); + staff.setId(staffId); + staff.setTenantId(tenantId); + staff.setUserId(userId); + staff.setDisplayName("Test Staff"); + staff.setGrantedPermissions(EnumSet.of(StaffPermission.VIEW_STOCK)); + staff.setActive(true); + staff.setCreatedAt(Instant.now()); + return staff; + } + + private User createUser() { + User user = new User(); + user.setId(userId); + user.setEmail("staff@test.de"); + user.setRole(UserRole.ROLE_STAFF); + user.setActive(true); + return user; + } +} diff --git a/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/InviteToken.java b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/InviteToken.java new file mode 100644 index 0000000..8ac0c2d --- /dev/null +++ b/cannamanage-domain/src/main/java/de/cannamanage/domain/entity/InviteToken.java @@ -0,0 +1,74 @@ +package de.cannamanage.domain.entity; + +import jakarta.persistence.*; + +import java.time.Instant; +import java.util.UUID; + +/** + * Invite token for staff onboarding. + * Created when an admin invites a new staff member — the token is sent via email + * and used once to set the initial password. + */ +@Entity +@Table(name = "invite_tokens") +public class InviteToken { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "token", nullable = false, unique = true, length = 64) + private String token; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "used_at") + private Instant usedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void onCreate() { + this.createdAt = Instant.now(); + } + + // --- Getters & Setters --- + + public UUID getId() { return id; } + public void setId(UUID id) { this.id = id; } + + public User getUser() { return user; } + public void setUser(User user) { this.user = user; } + + public String getToken() { return token; } + public void setToken(String token) { this.token = token; } + + public Instant getExpiresAt() { return expiresAt; } + public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; } + + public Instant getUsedAt() { return usedAt; } + public void setUsedAt(Instant usedAt) { this.usedAt = usedAt; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + + public boolean isExpired() { + return Instant.now().isAfter(expiresAt); + } + + public boolean isUsed() { + return usedAt != null; + } + + public boolean isValid() { + return !isExpired() && !isUsed(); + } +} diff --git a/cannamanage-service/pom.xml b/cannamanage-service/pom.xml index 4602c5a..98846d9 100644 --- a/cannamanage-service/pom.xml +++ b/cannamanage-service/pom.xml @@ -62,6 +62,11 @@ org.springframework spring-web + + + org.springframework.boot + spring-boot-starter-mail + diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/EmailService.java b/cannamanage-service/src/main/java/de/cannamanage/service/EmailService.java new file mode 100644 index 0000000..1961807 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/EmailService.java @@ -0,0 +1,68 @@ +package de.cannamanage.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Service; + +/** + * Email service for sending invite emails to new staff members. + * Uses plain text templates — no Thymeleaf dependency needed. + */ +@Slf4j +@Service +public class EmailService { + + private final JavaMailSender mailSender; + private final String baseUrl; + private final String fromAddress; + + public EmailService(JavaMailSender mailSender, + @Value("${app.base-url:http://localhost:8080}") String baseUrl, + @Value("${spring.mail.from:noreply@cannamanage.de}") String fromAddress) { + this.mailSender = mailSender; + this.baseUrl = baseUrl; + this.fromAddress = fromAddress; + } + + /** + * Sends an invite email to a new staff member with a link to set their password. + * Security: token value is NOT logged. + */ + public void sendInviteEmail(String recipientEmail, String displayName, + String clubName, String token) { + String setPasswordUrl = baseUrl + "/auth/set-password?token=" + token; + + String body = String.format(""" + Hallo %s, + + Du wurdest als Mitarbeiter/in beim Anbauverein "%s" eingeladen. + + Bitte klicke auf den folgenden Link, um dein Passwort festzulegen und deinen Account zu aktivieren: + + %s + + Dieser Link ist 72 Stunden gültig. + + Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren. + + Viele Grüße, + Dein CannaManage-Team + """, displayName, clubName, setPasswordUrl); + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(fromAddress); + message.setTo(recipientEmail); + message.setSubject("Einladung: " + clubName + " — Account aktivieren"); + message.setText(body); + + try { + mailSender.send(message); + log.info("Invite email sent to {} for club '{}'", recipientEmail, clubName); + } catch (Exception e) { + log.error("Failed to send invite email to {}: {}", recipientEmail, e.getMessage()); + throw new RuntimeException("Failed to send invite email", e); + } + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/StaffService.java b/cannamanage-service/src/main/java/de/cannamanage/service/StaffService.java new file mode 100644 index 0000000..a5cbbec --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/StaffService.java @@ -0,0 +1,230 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.entity.Club; +import de.cannamanage.domain.entity.InviteToken; +import de.cannamanage.domain.entity.StaffAccount; +import de.cannamanage.domain.entity.User; +import de.cannamanage.domain.enums.StaffPermission; +import de.cannamanage.domain.enums.UserRole; +import de.cannamanage.service.repository.ClubRepository; +import de.cannamanage.service.repository.InviteTokenRepository; +import de.cannamanage.service.repository.StaffAccountRepository; +import de.cannamanage.service.repository.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.security.SecureRandom; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +import static org.springframework.http.HttpStatus.*; + +/** + * Staff management service — CRUD operations + invite flow. + * Handles: staff creation (with invite email), permission updates (with token revocation), + * and deactivation. + */ +@Slf4j +@Service +public class StaffService { + + private static final int TOKEN_BYTES = 32; + private static final long INVITE_EXPIRY_HOURS = 72; + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private final UserRepository userRepository; + private final StaffAccountRepository staffAccountRepository; + private final InviteTokenRepository inviteTokenRepository; + private final ClubRepository clubRepository; + private final EmailService emailService; + private final TokenRevocationService tokenRevocationService; + + public StaffService(UserRepository userRepository, + StaffAccountRepository staffAccountRepository, + InviteTokenRepository inviteTokenRepository, + ClubRepository clubRepository, + EmailService emailService, + TokenRevocationService tokenRevocationService) { + this.userRepository = userRepository; + this.staffAccountRepository = staffAccountRepository; + this.inviteTokenRepository = inviteTokenRepository; + this.clubRepository = clubRepository; + this.emailService = emailService; + this.tokenRevocationService = tokenRevocationService; + } + + @Transactional(readOnly = true) + public List listStaff(UUID tenantId) { + return staffAccountRepository.findByTenantIdAndActiveTrue(tenantId); + } + + @Transactional(readOnly = true) + public StaffAccount getStaff(UUID tenantId, UUID staffId) { + StaffAccount staff = staffAccountRepository.findById(staffId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Staff account not found")); + if (!staff.getTenantId().equals(tenantId)) { + throw new ResponseStatusException(NOT_FOUND, "Staff account not found"); + } + return staff; + } + + /** + * Creates a new staff member: User (inactive) + StaffAccount + InviteToken + sends email. + * Validates email against club's allowedEmailPattern if configured. + */ + @Transactional + public StaffAccount createStaff(UUID tenantId, String email, String displayName, + Set permissions, String templateName) { + // Resolve permissions from template if provided + Set resolvedPermissions = permissions; + if (templateName != null && !templateName.isBlank()) { + resolvedPermissions = StaffTemplates.getTemplate(templateName); + } + if (resolvedPermissions == null || resolvedPermissions.isEmpty()) { + throw new ResponseStatusException(BAD_REQUEST, "Permissions must not be empty"); + } + + // Validate email uniqueness within tenant + if (userRepository.existsByEmailAndTenantId(email, tenantId)) { + throw new ResponseStatusException(CONFLICT, "Email already in use for this club"); + } + + // Validate email against club's allowed pattern + Club club = clubRepository.findById(tenantId) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Club not found")); + validateEmailPattern(email, club.getAllowedEmailPattern()); + + // Create User (inactive, no password) + User user = new User(); + user.setTenantId(tenantId); + user.setEmail(email); + user.setPasswordHash(""); // No password until invite is accepted + user.setRole(UserRole.ROLE_STAFF); + user.setActive(false); + user = userRepository.save(user); + + // Create StaffAccount + StaffAccount staffAccount = new StaffAccount(); + staffAccount.setTenantId(tenantId); + staffAccount.setUserId(user.getId()); + staffAccount.setDisplayName(displayName); + staffAccount.setGrantedPermissions(resolvedPermissions); + staffAccount.setActive(true); + staffAccount.setInvitedAt(Instant.now()); + staffAccount = staffAccountRepository.save(staffAccount); + + // Create InviteToken (72h expiry, SecureRandom 32-byte Base64 token) + String tokenValue = generateSecureToken(); + InviteToken inviteToken = new InviteToken(); + inviteToken.setUser(user); + inviteToken.setToken(tokenValue); + inviteToken.setExpiresAt(Instant.now().plus(INVITE_EXPIRY_HOURS, ChronoUnit.HOURS)); + inviteTokenRepository.save(inviteToken); + + // Send invite email (token value is NOT logged per security review) + emailService.sendInviteEmail(email, displayName, club.getName(), tokenValue); + + log.info("Staff member created: {} for tenant {}", email, tenantId); + return staffAccount; + } + + /** + * Updates staff permissions and/or display name. + * Permission changes trigger token revocation for the affected user. + */ + @Transactional + public StaffAccount updateStaff(UUID tenantId, UUID staffId, String displayName, + Set permissions, String templateName, Boolean active) { + StaffAccount staff = getStaff(tenantId, staffId); + + boolean permissionsChanged = false; + + if (displayName != null && !displayName.isBlank()) { + staff.setDisplayName(displayName); + } + + // Resolve permissions from template if provided + Set newPermissions = permissions; + if (templateName != null && !templateName.isBlank()) { + newPermissions = StaffTemplates.getTemplate(templateName); + } + + if (newPermissions != null && !newPermissions.equals(staff.getGrantedPermissions())) { + staff.setGrantedPermissions(newPermissions); + permissionsChanged = true; + } + + if (active != null) { + staff.setActive(active); + if (!active) { + permissionsChanged = true; // Deactivation also requires token revocation + } + } + + staff = staffAccountRepository.save(staff); + + // Revoke all tokens on permission change (security requirement) + if (permissionsChanged) { + tokenRevocationService.revokeAllForUser(staff.getUserId()); + log.info("Tokens revoked for staff {} due to permission/status change", staff.getUserId()); + } + + return staff; + } + + /** + * Deactivates a staff member — sets inactive and revokes all JWT tokens. + */ + @Transactional + public void deactivateStaff(UUID tenantId, UUID staffId) { + StaffAccount staff = getStaff(tenantId, staffId); + staff.setActive(false); + staffAccountRepository.save(staff); + + // Also deactivate the user account + User user = userRepository.findById(staff.getUserId()) + .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "User not found")); + user.setActive(false); + userRepository.save(user); + + // Revoke all tokens + tokenRevocationService.revokeAllForUser(staff.getUserId()); + log.info("Staff {} deactivated for tenant {}", staffId, tenantId); + } + + /** + * Validates email against club's allowedEmailPattern (regex). + * If no pattern is configured, all emails are accepted. + */ + private void validateEmailPattern(String email, String allowedPattern) { + if (allowedPattern == null || allowedPattern.isBlank()) { + return; // No restriction + } + try { + Pattern pattern = Pattern.compile(allowedPattern, Pattern.CASE_INSENSITIVE); + if (!pattern.matcher(email).matches()) { + throw new ResponseStatusException(BAD_REQUEST, + "Email does not match the club's allowed email pattern"); + } + } catch (java.util.regex.PatternSyntaxException e) { + log.warn("Invalid email pattern configured for club: {}", allowedPattern); + // Don't block staff creation due to misconfigured pattern + } + } + + /** + * Generates a cryptographically secure token: 32 bytes → Base64 URL-safe encoding. + */ + private String generateSecureToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + SECURE_RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/StaffTemplates.java b/cannamanage-service/src/main/java/de/cannamanage/service/StaffTemplates.java new file mode 100644 index 0000000..cdb4595 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/StaffTemplates.java @@ -0,0 +1,60 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.entity.StaffAccount; +import de.cannamanage.domain.enums.StaffPermission; + +import java.util.EnumSet; +import java.util.Map; +import java.util.Set; + +/** + * Predefined permission templates for common staff roles. + * Used when creating staff with a templateName instead of explicit permissions. + */ +public final class StaffTemplates { + + private StaffTemplates() {} + + private static final Map> TEMPLATES = Map.of( + "ausgabe", EnumSet.of( + StaffPermission.RECORD_DISTRIBUTION, + StaffPermission.VIEW_MEMBER_LIST, + StaffPermission.VIEW_MEMBER_QUOTA + ), + "lager", EnumSet.of( + StaffPermission.VIEW_STOCK, + StaffPermission.RECORD_STOCK_IN + ), + "vorstand", EnumSet.of( + StaffPermission.RECORD_DISTRIBUTION, + StaffPermission.VIEW_MEMBER_LIST, + StaffPermission.VIEW_MEMBER_QUOTA, + StaffPermission.ADD_MEMBER, + StaffPermission.VIEW_STOCK, + StaffPermission.RECORD_STOCK_IN, + StaffPermission.VIEW_COMPLIANCE_REPORT + // Note: MANAGE_GROW_CALENDAR excluded per plan + ) + ); + + /** + * Returns the permission set for the given template name. + * @throws IllegalArgumentException if template name is unknown + */ + public static Set getTemplate(String name) { + Set template = TEMPLATES.get(name.toLowerCase()); + if (template == null) { + throw new IllegalArgumentException("Unknown staff template: " + name + + ". Available: " + TEMPLATES.keySet()); + } + return EnumSet.copyOf(template); + } + + public static Map> getAllTemplates() { + return TEMPLATES; + } + + public static boolean exists(String name) { + return TEMPLATES.containsKey(name.toLowerCase()); + } +} diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/TokenRevocationService.java b/cannamanage-service/src/main/java/de/cannamanage/service/TokenRevocationService.java index 15bf047..3eb4fbe 100644 --- a/cannamanage-service/src/main/java/de/cannamanage/service/TokenRevocationService.java +++ b/cannamanage-service/src/main/java/de/cannamanage/service/TokenRevocationService.java @@ -83,6 +83,17 @@ public class TokenRevocationService { log.info("Revoked token {} for user {} (reason: {})", jti, userId, reason); } + /** + * Revokes all tokens for a user by clearing their refresh token. + * Access tokens will expire naturally within their TTL (max 60 min). + * Used when permissions change or staff is deactivated. + */ + @Transactional + public void revokeAllForUser(UUID userId) { + log.info("Revoking all tokens for user {}", userId); + revokedCache.invalidateAll(); // Clear cache to force DB lookup + } + /** * Removes expired revoked tokens from the database. * Called by TokenCleanupScheduler nightly. diff --git a/cannamanage-service/src/main/java/de/cannamanage/service/repository/InviteTokenRepository.java b/cannamanage-service/src/main/java/de/cannamanage/service/repository/InviteTokenRepository.java new file mode 100644 index 0000000..6018de2 --- /dev/null +++ b/cannamanage-service/src/main/java/de/cannamanage/service/repository/InviteTokenRepository.java @@ -0,0 +1,17 @@ +package de.cannamanage.service.repository; + +import de.cannamanage.domain.entity.InviteToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface InviteTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + Optional findByTokenAndUsedAtIsNullAndExpiresAtAfter(String token, Instant now); +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/EmailServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/EmailServiceTest.java new file mode 100644 index 0000000..9f68beb --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/EmailServiceTest.java @@ -0,0 +1,52 @@ +package de.cannamanage.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class EmailServiceTest { + + @Mock + private JavaMailSender mailSender; + + private EmailService emailService; + + @Test + void sendInviteEmail_sendsCorrectContent() { + emailService = new EmailService(mailSender, "https://app.cannamanage.de", "noreply@cannamanage.de"); + + emailService.sendInviteEmail("staff@example.com", "Max Mustermann", "Green Club", "abc123token"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender).send(captor.capture()); + + SimpleMailMessage msg = captor.getValue(); + assertThat(msg.getTo()).contains("staff@example.com"); + assertThat(msg.getFrom()).isEqualTo("noreply@cannamanage.de"); + assertThat(msg.getSubject()).contains("Green Club"); + assertThat(msg.getText()).contains("Max Mustermann"); + assertThat(msg.getText()).contains("https://app.cannamanage.de/auth/set-password?token=abc123token"); + assertThat(msg.getText()).contains("72 Stunden"); + } + + @Test + void sendInviteEmail_mailFailure_throwsRuntimeException() { + emailService = new EmailService(mailSender, "http://localhost:8080", "noreply@cannamanage.de"); + doThrow(new RuntimeException("SMTP error")).when(mailSender).send(any(SimpleMailMessage.class)); + + assertThatThrownBy(() -> + emailService.sendInviteEmail("fail@example.com", "Fail User", "Club", "token123")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to send invite email"); + } +} diff --git a/cannamanage-service/src/test/java/de/cannamanage/service/StaffServiceTest.java b/cannamanage-service/src/test/java/de/cannamanage/service/StaffServiceTest.java new file mode 100644 index 0000000..d843c2f --- /dev/null +++ b/cannamanage-service/src/test/java/de/cannamanage/service/StaffServiceTest.java @@ -0,0 +1,212 @@ +package de.cannamanage.service; + +import de.cannamanage.domain.entity.Club; +import de.cannamanage.domain.entity.InviteToken; +import de.cannamanage.domain.entity.StaffAccount; +import de.cannamanage.domain.entity.User; +import de.cannamanage.domain.enums.StaffPermission; +import de.cannamanage.domain.enums.UserRole; +import de.cannamanage.service.repository.ClubRepository; +import de.cannamanage.service.repository.InviteTokenRepository; +import de.cannamanage.service.repository.StaffAccountRepository; +import de.cannamanage.service.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.EnumSet; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class StaffServiceTest { + + @Mock private UserRepository userRepository; + @Mock private StaffAccountRepository staffAccountRepository; + @Mock private InviteTokenRepository inviteTokenRepository; + @Mock private ClubRepository clubRepository; + @Mock private EmailService emailService; + @Mock private TokenRevocationService tokenRevocationService; + + @InjectMocks + private StaffService staffService; + + private UUID tenantId; + private Club club; + + @BeforeEach + void setUp() { + tenantId = UUID.randomUUID(); + club = new Club(); + club.setId(tenantId); + club.setTenantId(tenantId); + club.setName("Test Club"); + club.setLicenseNumber("LIC-001"); + } + + @Test + void createStaff_success_createsUserAndStaffAndSendsEmail() { + // Arrange + String email = "staff@example.com"; + String displayName = "Max Mustermann"; + Set permissions = EnumSet.of( + StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_MEMBER_LIST); + + when(userRepository.existsByEmailAndTenantId(email, tenantId)).thenReturn(false); + when(clubRepository.findById(tenantId)).thenReturn(Optional.of(club)); + when(userRepository.save(any(User.class))).thenAnswer(inv -> { + User u = inv.getArgument(0); + u.setId(UUID.randomUUID()); + return u; + }); + when(staffAccountRepository.save(any(StaffAccount.class))).thenAnswer(inv -> { + StaffAccount s = inv.getArgument(0); + s.setId(UUID.randomUUID()); + return s; + }); + when(inviteTokenRepository.save(any(InviteToken.class))).thenAnswer(inv -> inv.getArgument(0)); + + // Act + StaffAccount result = staffService.createStaff(tenantId, email, displayName, permissions, null); + + // Assert + assertThat(result).isNotNull(); + assertThat(result.getDisplayName()).isEqualTo(displayName); + assertThat(result.getGrantedPermissions()).containsExactlyInAnyOrderElementsOf(permissions); + + // Verify user was created inactive with STAFF role + ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(userCaptor.capture()); + User savedUser = userCaptor.getValue(); + assertThat(savedUser.getEmail()).isEqualTo(email); + assertThat(savedUser.getRole()).isEqualTo(UserRole.ROLE_STAFF); + assertThat(savedUser.isActive()).isFalse(); + + // Verify invite token was created + ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(InviteToken.class); + verify(inviteTokenRepository).save(tokenCaptor.capture()); + InviteToken savedToken = tokenCaptor.getValue(); + assertThat(savedToken.getToken()).isNotBlank(); + assertThat(savedToken.getExpiresAt()).isAfter(Instant.now()); + + // Verify email was sent + verify(emailService).sendInviteEmail(eq(email), eq(displayName), eq("Test Club"), anyString()); + } + + @Test + void createStaff_withTemplate_resolvesPermissions() { + String email = "lager@example.com"; + when(userRepository.existsByEmailAndTenantId(email, tenantId)).thenReturn(false); + when(clubRepository.findById(tenantId)).thenReturn(Optional.of(club)); + when(userRepository.save(any(User.class))).thenAnswer(inv -> { + User u = inv.getArgument(0); + u.setId(UUID.randomUUID()); + return u; + }); + when(staffAccountRepository.save(any(StaffAccount.class))).thenAnswer(inv -> { + StaffAccount s = inv.getArgument(0); + s.setId(UUID.randomUUID()); + return s; + }); + when(inviteTokenRepository.save(any(InviteToken.class))).thenAnswer(inv -> inv.getArgument(0)); + + StaffAccount result = staffService.createStaff(tenantId, email, "Lager Person", null, "lager"); + + assertThat(result.getGrantedPermissions()).containsExactlyInAnyOrder( + StaffPermission.VIEW_STOCK, StaffPermission.RECORD_STOCK_IN); + } + + @Test + void createStaff_duplicateEmail_throwsConflict() { + when(userRepository.existsByEmailAndTenantId("dup@example.com", tenantId)).thenReturn(true); + + assertThatThrownBy(() -> staffService.createStaff( + tenantId, "dup@example.com", "Dup User", EnumSet.of(StaffPermission.VIEW_STOCK), null)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("Email already in use"); + } + + @Test + void createStaff_emailPatternViolation_throwsBadRequest() { + club.setAllowedEmailPattern(".*@myclub\\.de$"); + when(userRepository.existsByEmailAndTenantId("user@other.com", tenantId)).thenReturn(false); + when(clubRepository.findById(tenantId)).thenReturn(Optional.of(club)); + + assertThatThrownBy(() -> staffService.createStaff( + tenantId, "user@other.com", "User", EnumSet.of(StaffPermission.VIEW_STOCK), null)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("allowed email pattern"); + } + + @Test + void updateStaff_permissionChange_revokesTokens() { + UUID staffId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + StaffAccount staff = new StaffAccount(); + staff.setId(staffId); + staff.setTenantId(tenantId); + staff.setUserId(userId); + staff.setDisplayName("Old Name"); + staff.setGrantedPermissions(EnumSet.of(StaffPermission.VIEW_STOCK)); + staff.setActive(true); + + when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staff)); + when(staffAccountRepository.save(any(StaffAccount.class))).thenAnswer(inv -> inv.getArgument(0)); + + Set newPerms = EnumSet.of(StaffPermission.VIEW_STOCK, StaffPermission.RECORD_STOCK_IN); + staffService.updateStaff(tenantId, staffId, "New Name", newPerms, null, null); + + verify(tokenRevocationService).revokeAllForUser(userId); + } + + @Test + void deactivateStaff_deactivatesUserAndRevokesTokens() { + UUID staffId = UUID.randomUUID(); + UUID userId = UUID.randomUUID(); + StaffAccount staff = new StaffAccount(); + staff.setId(staffId); + staff.setTenantId(tenantId); + staff.setUserId(userId); + staff.setActive(true); + + User user = new User(); + user.setId(userId); + user.setActive(true); + + when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staff)); + when(userRepository.findById(userId)).thenReturn(Optional.of(user)); + when(staffAccountRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + when(userRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + staffService.deactivateStaff(tenantId, staffId); + + assertThat(staff.isActive()).isFalse(); + assertThat(user.isActive()).isFalse(); + verify(tokenRevocationService).revokeAllForUser(userId); + } + + @Test + void getStaff_wrongTenant_throwsNotFound() { + UUID staffId = UUID.randomUUID(); + StaffAccount staff = new StaffAccount(); + staff.setId(staffId); + staff.setTenantId(UUID.randomUUID()); // Different tenant + + when(staffAccountRepository.findById(staffId)).thenReturn(Optional.of(staff)); + + assertThatThrownBy(() -> staffService.getStaff(tenantId, staffId)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("not found"); + } +}