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:
Patrick Plate
2026-06-11 16:45:21 +02:00
parent a1ddec37da
commit 55d8434f35
24 changed files with 2333 additions and 23 deletions
@@ -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);
}
}