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:
Patrick Plate
2026-06-11 18:03:12 +02:00
parent 36deb72cf0
commit 6c66783b58
22 changed files with 1205 additions and 3 deletions
+5
View File
@@ -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.
@@ -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");
}
}