feat(sprint-3): Phase 3 — staff management + invite flow
- 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
This commit is contained in:
@@ -113,6 +113,11 @@
|
||||
<artifactId>spring-boot-testcontainers</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Spring Boot Mail (invite flow) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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<Map<String, String>> setPassword(@Valid @RequestBody SetPasswordRequest request) {
|
||||
authService.setPassword(request);
|
||||
return ResponseEntity.ok(Map.of("message", "Password set successfully. You can now log in."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<StaffResponse>> listStaff() {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
List<StaffAccount> staffList = staffService.listStaff(tenantId);
|
||||
List<StaffResponse> 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<StaffResponse> 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<StaffResponse> 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<StaffResponse> 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<Void> 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<Map<String, Set<StaffPermission>>> listTemplates() {
|
||||
return ResponseEntity.ok(StaffTemplates.getAllTemplates());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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<StaffPermission> permissions,
|
||||
String templateName
|
||||
) {}
|
||||
@@ -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<StaffPermission> 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<StaffPermission> permissions,
|
||||
String templateName,
|
||||
Boolean active
|
||||
) {}
|
||||
@@ -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")
|
||||
|
||||
@@ -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+).
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,11 @@
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
</dependency>
|
||||
<!-- Spring Mail (invite flow) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<StaffAccount> 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<StaffPermission> permissions, String templateName) {
|
||||
// Resolve permissions from template if provided
|
||||
Set<StaffPermission> 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<StaffPermission> 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<StaffPermission> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Set<StaffPermission>> 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<StaffPermission> getTemplate(String name) {
|
||||
Set<StaffPermission> 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<String, Set<StaffPermission>> getAllTemplates() {
|
||||
return TEMPLATES;
|
||||
}
|
||||
|
||||
public static boolean exists(String name) {
|
||||
return TEMPLATES.containsKey(name.toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+17
@@ -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<InviteToken, UUID> {
|
||||
|
||||
Optional<InviteToken> findByToken(String token);
|
||||
|
||||
Optional<InviteToken> findByTokenAndUsedAtIsNullAndExpiresAtAfter(String token, Instant now);
|
||||
}
|
||||
@@ -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<SimpleMailMessage> 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");
|
||||
}
|
||||
}
|
||||
@@ -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<StaffPermission> 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<User> 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<InviteToken> 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<StaffPermission> 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user