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:
@@ -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