feat(sprint-3): Phase 1 — staff permissions + token revocation
- StaffPermission enum (8 granular permissions) - StaffAccount JPA entity with permissions collection - RevokedToken entity for JWT blacklisting - Flyway V3 migration (staff_accounts, staff_account_permissions, revoked_tokens) - StaffAccountRepository + RevokedTokenRepository - TokenRevocationService with Caffeine cache (60s TTL, 10k max) - StaffPermissionChecker SpEL bean (@staffPermissions.has) - PreventionOfficerChecker SpEL bean (@preventionOfficer.check) - JwtService: added jti claim + generateStaffAccessToken + extractJti/extractPermissions - JwtAuthFilter: token blacklist check via TokenRevocationService - SecurityConfig: STAFF role added to endpoint matchers - Controllers updated with @PreAuthorize for fine-grained access - TokenCleanupScheduler (daily 03:00 cleanup of expired revoked tokens) - Caffeine dependency added to cannamanage-service - Unit tests: StaffPermissionCheckerTest (7), TokenRevocationServiceTest (9)
This commit is contained in:
+123
@@ -0,0 +1,123 @@
|
||||
package de.cannamanage.service;
|
||||
|
||||
import de.cannamanage.domain.entity.RevokedToken;
|
||||
import de.cannamanage.service.repository.RevokedTokenRepository;
|
||||
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 java.time.Instant;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TokenRevocationServiceTest {
|
||||
|
||||
@Mock
|
||||
private RevokedTokenRepository revokedTokenRepository;
|
||||
|
||||
@InjectMocks
|
||||
private TokenRevocationService service;
|
||||
|
||||
private String testJti;
|
||||
private UUID testUserId;
|
||||
private UUID testTenantId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
testJti = UUID.randomUUID().toString();
|
||||
testUserId = UUID.randomUUID();
|
||||
testTenantId = UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_notRevoked_returnsFalse() {
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(false);
|
||||
|
||||
assertThat(service.isRevoked(testJti)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_revoked_returnsTrue() {
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(true);
|
||||
|
||||
assertThat(service.isRevoked(testJti)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_nullJti_returnsFalse() {
|
||||
assertThat(service.isRevoked(null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_blankJti_returnsFalse() {
|
||||
assertThat(service.isRevoked(" ")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void isRevoked_usesCache_onSecondCall() {
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(true);
|
||||
|
||||
// First call goes to DB
|
||||
assertThat(service.isRevoked(testJti)).isTrue();
|
||||
// Second call should use cache
|
||||
assertThat(service.isRevoked(testJti)).isTrue();
|
||||
|
||||
// DB should only be called once (cache handles second call)
|
||||
verify(revokedTokenRepository, times(1)).existsByJti(testJti);
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeToken_savesRevocation() {
|
||||
Instant expiresAt = Instant.now().plusSeconds(3600);
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(false);
|
||||
|
||||
service.revokeToken(testJti, testUserId, testTenantId, expiresAt, "logout");
|
||||
|
||||
ArgumentCaptor<RevokedToken> captor = ArgumentCaptor.forClass(RevokedToken.class);
|
||||
verify(revokedTokenRepository).save(captor.capture());
|
||||
|
||||
RevokedToken saved = captor.getValue();
|
||||
assertThat(saved.getJti()).isEqualTo(testJti);
|
||||
assertThat(saved.getUserId()).isEqualTo(testUserId);
|
||||
assertThat(saved.getTenantId()).isEqualTo(testTenantId);
|
||||
assertThat(saved.getExpiresAt()).isEqualTo(expiresAt);
|
||||
assertThat(saved.getReason()).isEqualTo("logout");
|
||||
assertThat(saved.getRevokedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void revokeToken_alreadyRevoked_doesNotSaveAgain() {
|
||||
when(revokedTokenRepository.existsByJti(testJti)).thenReturn(true);
|
||||
|
||||
service.revokeToken(testJti, testUserId, testTenantId, Instant.now().plusSeconds(3600), "duplicate");
|
||||
|
||||
verify(revokedTokenRepository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupExpiredTokens_deletesExpired() {
|
||||
when(revokedTokenRepository.deleteExpiredTokens(any(Instant.class))).thenReturn(5);
|
||||
|
||||
int deleted = service.cleanupExpiredTokens();
|
||||
|
||||
assertThat(deleted).isEqualTo(5);
|
||||
verify(revokedTokenRepository).deleteExpiredTokens(any(Instant.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupExpiredTokens_nothingToDelete_returnsZero() {
|
||||
when(revokedTokenRepository.deleteExpiredTokens(any(Instant.class))).thenReturn(0);
|
||||
|
||||
int deleted = service.cleanupExpiredTokens();
|
||||
|
||||
assertThat(deleted).isEqualTo(0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user