feat: Sprint 13 — Production Hardening (security fixes, CI gate, rate limiting, tests)
Deploy to TrueNAS / deploy (push) Failing after 12s

This commit is contained in:
Patrick Plate
2026-06-18 16:08:05 +02:00
parent 279487067e
commit f9a87efb7a
17 changed files with 1962 additions and 107 deletions
+11
View File
@@ -140,6 +140,17 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Rate limiting (Bucket4j + Caffeine cache) -->
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.10.1</version>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>3.1.8</version>
</dependency>
</dependencies>
<build>
@@ -9,6 +9,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@@ -21,6 +22,7 @@ import java.util.UUID;
@RestController
@RequestMapping("/api/v1")
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF', 'MEMBER')")
public class DocumentController {
private final DocumentService documentService;
@@ -33,13 +35,14 @@ public class DocumentController {
* Verify the requested document belongs to the caller's current tenant (club).
* Prevents IDOR: a user from club A must not be able to download/delete a document of club B
* just by guessing or enumerating the document UUID.
* Returns 404 (not 403) to avoid revealing document existence to other tenants.
*/
private Document loadOwnedDocument(UUID documentId) {
Document doc = documentService.getDocument(documentId);
UUID currentTenantId = TenantContext.getCurrentTenant();
if (currentTenantId == null || doc.getClubId() == null || !doc.getClubId().equals(currentTenantId)) {
// Use 403 (not 404) — caller is authenticated, just not authorized for this resource.
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Access denied to document");
// Return 404 to prevent information leakage about document existence across tenants
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found");
}
return doc;
}
@@ -78,6 +81,7 @@ public class DocumentController {
}
@DeleteMapping("/documents/{id}")
@PreAuthorize("hasAnyRole('ADMIN', 'STAFF')")
public ResponseEntity<Void> deleteDocument(
@PathVariable UUID id,
@RequestParam UUID clubId,
@@ -87,7 +91,7 @@ public class DocumentController {
Document doc = loadOwnedDocument(id);
UUID currentTenantId = TenantContext.getCurrentTenant();
if (!clubId.equals(currentTenantId)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Tenant mismatch");
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Document not found");
}
UUID userId = UUID.fromString(principal.getName());
documentService.deleteDocument(id, userId, doc.getClubId());
@@ -0,0 +1,77 @@
package de.cannamanage.api.security;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.time.Duration;
/**
* Rate-limits login attempts per client IP using Bucket4j + Caffeine cache.
* Allows 5 login attempts per minute per IP; returns 429 when exhausted.
*/
@Component
@Order(1)
public class LoginRateLimitFilter extends OncePerRequestFilter {
private static final String LOGIN_PATH = "/api/v1/auth/login";
private static final int CAPACITY = 5;
private static final Duration REFILL_PERIOD = Duration.ofMinutes(1);
private final Cache<String, Bucket> buckets = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterAccess(Duration.ofMinutes(10))
.build();
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (!"POST".equalsIgnoreCase(request.getMethod()) || !LOGIN_PATH.equals(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
String clientIp = resolveClientIp(request);
Bucket bucket = buckets.get(clientIp, k -> createBucket());
ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
if (probe.isConsumed()) {
filterChain.doFilter(request, response);
} else {
long waitSeconds = probe.getNanosToWaitForRefill() / 1_000_000_000 + 1;
response.setStatus(429);
response.setHeader("Retry-After", String.valueOf(waitSeconds));
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Too many login attempts. Retry after " + waitSeconds + "s\"}");
}
}
private Bucket createBucket() {
return Bucket.builder()
.addLimit(Bandwidth.builder()
.capacity(CAPACITY)
.refillGreedy(CAPACITY, REFILL_PERIOD)
.build())
.build();
}
private String resolveClientIp(HttpServletRequest request) {
String xff = request.getHeader("X-Forwarded-For");
if (xff != null && !xff.isBlank()) {
// Take the first IP in the chain (original client)
return xff.split(",")[0].trim();
}
return request.getRemoteAddr();
}
}
@@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -71,10 +72,13 @@ public class SecurityConfig {
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers("/api/v1/compliance/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers("/api/v1/reports/**").hasRole("ADMIN")
// Documents endpoint — explicit listing for defense-in-depth so it can
// never accidentally end up in a permitAll() rule above. Per-document
// tenant ownership is additionally enforced in DocumentController.
.requestMatchers("/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
// Documents endpoint — method-specific matchers for defense-in-depth.
// POST (upload) and DELETE restricted to ADMIN/STAFF; GET allowed for all
// authenticated roles. Per-document tenant ownership is additionally
// enforced in DocumentController via TenantContext.
.requestMatchers(HttpMethod.GET, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers(HttpMethod.POST, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
.requestMatchers(HttpMethod.DELETE, "/api/v1/documents/**").hasAnyRole("ADMIN", "STAFF")
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
@@ -34,6 +34,8 @@ import java.util.UUID;
@RequiredArgsConstructor
public class AuthService {
private static final String INVALID_CREDENTIALS = "Invalid credentials";
private final UserRepository userRepository;
private final JwtService jwtService;
private final PasswordEncoder passwordEncoder;
@@ -43,14 +45,14 @@ public class AuthService {
@Transactional
public LoginResponse login(LoginRequest request) {
User user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new AuthenticationException("Invalid credentials"));
.orElseThrow(() -> new AuthenticationException(INVALID_CREDENTIALS));
if (!user.isActive()) {
throw new AuthenticationException("Account not activated");
}
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
throw new AuthenticationException("Invalid credentials");
throw new AuthenticationException(INVALID_CREDENTIALS);
}
// Generate tokens
@@ -147,7 +149,7 @@ public class AuthService {
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 not available", e);
throw new IllegalStateException("SHA-256 not available", e);
}
}
@@ -0,0 +1,164 @@
package de.cannamanage.api.controller;
import de.cannamanage.domain.entity.Document;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.enums.DocumentAccessLevel;
import de.cannamanage.domain.enums.DocumentCategory;
import de.cannamanage.service.DocumentService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.server.ResponseStatusException;
import java.io.IOException;
import java.security.Principal;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Security unit tests for {@link DocumentController}.
* Verifies tenant isolation (IDOR protection) at the controller layer.
*/
@ExtendWith(MockitoExtension.class)
class DocumentControllerSecurityTest {
@Mock
private DocumentService documentService;
@InjectMocks
private DocumentController documentController;
private static final UUID CLUB_A = UUID.fromString("00000000-0000-0000-0000-00000000000a");
private static final UUID CLUB_B = UUID.fromString("00000000-0000-0000-0000-00000000000b");
private static final UUID DOC_ID = UUID.fromString("00000000-0000-0000-0000-000000000099");
private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
@BeforeEach
void setUp() {
// Default tenant context: CLUB_A
TenantContext.setCurrentTenant(CLUB_A);
}
@AfterEach
void tearDown() {
TenantContext.clear();
}
// --- T-09: Download wrong tenant → 404 ---
@Test
@DisplayName("downloadDocument — wrong tenant throws 404 (IDOR protection)")
void testDownload_wrongTenant_returns404() {
// Document belongs to CLUB_B but user's tenant is CLUB_A
Document doc = new Document();
doc.setId(DOC_ID);
doc.setClubId(CLUB_B);
doc.setFilename("secret.pdf");
doc.setContentType("application/pdf");
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
assertThatThrownBy(() -> documentController.downloadDocument(DOC_ID))
.isInstanceOf(ResponseStatusException.class)
.satisfies(ex -> {
ResponseStatusException rse = (ResponseStatusException) ex;
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
});
}
// --- T-10: Download correct tenant → 200 ---
@Test
@DisplayName("downloadDocument — correct tenant returns content")
void testDownload_correctTenant_succeeds() throws IOException {
Document doc = new Document();
doc.setId(DOC_ID);
doc.setClubId(CLUB_A);
doc.setFilename("report.pdf");
doc.setContentType("application/pdf");
doc.setStoragePath(CLUB_A + "/" + DOC_ID + "_report.pdf");
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
when(documentService.downloadDocument(DOC_ID)).thenReturn("test content".getBytes());
ResponseEntity<byte[]> response = documentController.downloadDocument(DOC_ID);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
}
// --- T-11: Delete wrong tenant → 404 ---
@Test
@DisplayName("deleteDocument — wrong tenant throws 404 (IDOR protection)")
void testDelete_wrongTenant_returns404() {
Document doc = new Document();
doc.setId(DOC_ID);
doc.setClubId(CLUB_B);
doc.setTitle("Secret Doc");
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
Principal principal = mock(Principal.class);
assertThatThrownBy(() -> documentController.deleteDocument(DOC_ID, CLUB_A, principal))
.isInstanceOf(ResponseStatusException.class)
.satisfies(ex -> {
ResponseStatusException rse = (ResponseStatusException) ex;
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
});
}
// --- T-12: Delete correct tenant → 204 ---
@Test
@DisplayName("deleteDocument — correct tenant and matching clubId succeeds")
void testDelete_correctTenant_succeeds() throws IOException {
Document doc = new Document();
doc.setId(DOC_ID);
doc.setClubId(CLUB_A);
doc.setTitle("My Doc");
doc.setStoragePath(CLUB_A + "/" + DOC_ID + "_my.pdf");
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
Principal principal = mock(Principal.class);
when(principal.getName()).thenReturn(USER_ID.toString());
ResponseEntity<Void> response = documentController.deleteDocument(DOC_ID, CLUB_A, principal);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
// --- T-13/T-14: Upload role restriction is handled by Spring Security @PreAuthorize,
// not testable in a pure unit test. Covered by SecurityConfigIntegrationTest. ---
@Test
@DisplayName("deleteDocument — mismatched clubId param vs tenant throws 404")
void testDelete_mismatchedClubIdParam_returns404() {
// Document belongs to CLUB_A and tenant is CLUB_A, but clubId param is different
Document doc = new Document();
doc.setId(DOC_ID);
doc.setClubId(CLUB_A);
doc.setTitle("Doc");
when(documentService.getDocument(DOC_ID)).thenReturn(doc);
Principal principal = mock(Principal.class);
// Passing CLUB_B as the clubId param while tenant is CLUB_A
assertThatThrownBy(() -> documentController.deleteDocument(DOC_ID, CLUB_B, principal))
.isInstanceOf(ResponseStatusException.class)
.satisfies(ex -> {
ResponseStatusException rse = (ResponseStatusException) ex;
assertThat(rse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
});
}
}
@@ -0,0 +1,178 @@
package de.cannamanage.api.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.IOException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
/**
* Unit tests for {@link LoginRateLimitFilter} covering rate limiting with Bucket4j + Caffeine.
* Tests per-IP bucket isolation, blocking after threshold, and Retry-After header.
*/
class LoginRateLimitFilterTest {
private LoginRateLimitFilter filter;
private FilterChain filterChain;
@BeforeEach
void setUp() {
filter = new LoginRateLimitFilter();
filterChain = mock(FilterChain.class);
}
// --- T-26: First 5 requests pass ---
@Test
@DisplayName("Rate limit — first 5 requests from same IP are allowed")
void testRateLimit_allowsFirstFiveRequests() throws ServletException, IOException {
for (int i = 0; i < 5; i++) {
MockHttpServletRequest request = createLoginRequest("192.168.1.1");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilterInternal(request, response, filterChain);
assertThat(response.getStatus()).isNotEqualTo(429);
}
// FilterChain should have been invoked 5 times
verify(filterChain, times(5)).doFilter(any(), any());
}
// --- T-27: 6th request returns 429 ---
@Test
@DisplayName("Rate limit — 6th request from same IP returns 429")
void testRateLimit_blocks6thRequest_returns429() throws ServletException, IOException {
String ip = "10.0.0.1";
// Exhaust the 5-request bucket
for (int i = 0; i < 5; i++) {
MockHttpServletRequest request = createLoginRequest(ip);
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilterInternal(request, response, filterChain);
}
// 6th request should be rate-limited
MockHttpServletRequest request = createLoginRequest(ip);
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilterInternal(request, response, filterChain);
assertThat(response.getStatus()).isEqualTo(429);
assertThat(response.getContentAsString()).contains("Too many login attempts");
}
// --- Retry-After header ---
@Test
@DisplayName("Rate limit — 429 response includes Retry-After header")
void testRateLimit_includesRetryAfterHeader() throws ServletException, IOException {
String ip = "10.0.0.2";
// Exhaust the bucket
for (int i = 0; i < 5; i++) {
filter.doFilterInternal(createLoginRequest(ip), new MockHttpServletResponse(), filterChain);
}
// 6th request — check headers
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilterInternal(createLoginRequest(ip), response, filterChain);
assertThat(response.getStatus()).isEqualTo(429);
String retryAfter = response.getHeader("Retry-After");
assertThat(retryAfter).isNotNull();
assertThat(Integer.parseInt(retryAfter)).isGreaterThan(0);
}
// --- T-28: Separate buckets per IP ---
@Test
@DisplayName("Rate limit — different IPs have separate rate limit buckets")
void testRateLimit_separateBucketsPerIp() throws ServletException, IOException {
String ip1 = "192.168.1.100";
String ip2 = "192.168.1.200";
// Exhaust quota for ip1
for (int i = 0; i < 5; i++) {
filter.doFilterInternal(createLoginRequest(ip1), new MockHttpServletResponse(), filterChain);
}
// ip1 should be blocked
MockHttpServletResponse responseIp1 = new MockHttpServletResponse();
filter.doFilterInternal(createLoginRequest(ip1), responseIp1, filterChain);
assertThat(responseIp1.getStatus()).isEqualTo(429);
// ip2 should still be allowed
MockHttpServletResponse responseIp2 = new MockHttpServletResponse();
filter.doFilterInternal(createLoginRequest(ip2), responseIp2, filterChain);
assertThat(responseIp2.getStatus()).isNotEqualTo(429);
}
// --- Non-login requests pass through ---
@Test
@DisplayName("Non-login endpoint requests are not rate limited")
void testNonLoginEndpoint_notRateLimited() throws ServletException, IOException {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/members");
request.setRemoteAddr("10.0.0.5");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
assertThat(response.getStatus()).isNotEqualTo(429);
}
@Test
@DisplayName("GET request to login path is not rate limited")
void testGetLoginPath_notRateLimited() throws ServletException, IOException {
MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/v1/auth/login");
request.setRemoteAddr("10.0.0.6");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilterInternal(request, response, filterChain);
verify(filterChain).doFilter(request, response);
assertThat(response.getStatus()).isNotEqualTo(429);
}
// --- X-Forwarded-For header ---
@Test
@DisplayName("Rate limit uses X-Forwarded-For header for client IP resolution")
void testRateLimit_usesXForwardedFor() throws ServletException, IOException {
String realIp = "203.0.113.50";
// Exhaust bucket via X-Forwarded-For IP
for (int i = 0; i < 5; i++) {
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/login");
request.setRemoteAddr("127.0.0.1"); // proxy IP
request.addHeader("X-Forwarded-For", realIp + ", 10.0.0.1");
filter.doFilterInternal(request, new MockHttpServletResponse(), filterChain);
}
// 6th request from same forwarded IP should be blocked
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/login");
request.setRemoteAddr("127.0.0.1");
request.addHeader("X-Forwarded-For", realIp + ", 10.0.0.1");
MockHttpServletResponse response = new MockHttpServletResponse();
filter.doFilterInternal(request, response, filterChain);
assertThat(response.getStatus()).isEqualTo(429);
}
// --- Helper ---
private MockHttpServletRequest createLoginRequest(String ip) {
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/v1/auth/login");
request.setRemoteAddr(ip);
return request;
}
}
@@ -0,0 +1,89 @@
package de.cannamanage.api.security;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.web.client.RestClient;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link SecurityConfig} — verifying that the security filter chain
* correctly requires authentication for protected endpoints and allows public endpoints.
* Uses RestClient against an actual HTTP server (same pattern as AuthControllerIntegrationTest).
*
* Note: The existing SecurityConfigIntegrationTest (Testcontainers) covers the same cases
* with a full database. This test uses the simpler "test" profile for faster execution.
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class SecurityConfigTest {
@LocalServerPort
private int port;
private RestClient restClient() {
return RestClient.builder()
.baseUrl("http://localhost:" + port)
.build();
}
// --- T-21: Document endpoints require authentication ---
@Test
@DisplayName("GET /api/v1/documents — unauthenticated returns 401")
void testDocumentEndpoints_requireAuthentication() {
ResponseEntity<String> response = restClient().get()
.uri("/api/v1/documents?clubId=00000000-0000-0000-0000-000000000001")
.retrieve()
.toEntity(String.class);
assertThat(response.getStatusCode().value()).isEqualTo(401);
}
@Test
@DisplayName("GET /api/v1/documents/{id}/download — unauthenticated returns 401")
void testDocumentDownload_requiresAuthentication() {
ResponseEntity<String> response = restClient().get()
.uri("/api/v1/documents/00000000-0000-0000-0000-000000000099/download")
.retrieve()
.toEntity(String.class);
assertThat(response.getStatusCode().value()).isEqualTo(401);
}
// --- T-22: Auth endpoints are public ---
@Test
@DisplayName("POST /api/v1/auth/login — accessible without authentication (not 401)")
void testAuthEndpoints_arePublic() {
ResponseEntity<String> response = restClient().post()
.uri("/api/v1/auth/login")
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.body("{\"email\":\"test@test.de\",\"password\":\"test\"}")
.retrieve()
.toEntity(String.class);
// Auth endpoints are public — should NOT return 401/403
// May return 400 or 500 (user not found), that's fine
assertThat(response.getStatusCode().value()).isNotEqualTo(401);
assertThat(response.getStatusCode().value()).isNotEqualTo(403);
}
// --- T-23: Actuator health is public ---
@Test
@DisplayName("GET /actuator/health — accessible without authentication")
void testActuatorHealth_isPublic() {
ResponseEntity<String> response = restClient().get()
.uri("/actuator/health")
.retrieve()
.toEntity(String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
}
}
@@ -0,0 +1,194 @@
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.security.JwtService;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.UserRole;
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.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
/**
* Unit tests for {@link AuthService} covering login, token refresh, and SHA-256 hashing.
*/
@ExtendWith(MockitoExtension.class)
class AuthServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private JwtService jwtService;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private InviteTokenRepository inviteTokenRepository;
@Mock
private StaffAccountRepository staffAccountRepository;
@InjectMocks
private AuthService authService;
private User activeUser;
private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001");
private static final UUID TENANT_ID = UUID.fromString("00000000-0000-0000-0000-000000000010");
private static final String EMAIL = "admin@test.de";
private static final String PASSWORD = "SecurePass123!";
private static final String HASHED_PASSWORD = "$2a$10$hashedvalue";
@BeforeEach
void setUp() {
activeUser = new User();
activeUser.setId(USER_ID);
activeUser.setEmail(EMAIL);
activeUser.setPasswordHash(HASHED_PASSWORD);
activeUser.setRole(UserRole.ROLE_ADMIN);
activeUser.setActive(true);
activeUser.setTenantId(TENANT_ID);
}
// --- T-15: Login valid credentials → token pair ---
@Test
@DisplayName("login — valid credentials returns token pair")
void testLogin_validCredentials_returnsTokenPair() {
when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(activeUser));
when(passwordEncoder.matches(PASSWORD, HASHED_PASSWORD)).thenReturn(true);
when(jwtService.generateAccessToken(any(), any(), anyString(), anyString()))
.thenReturn("access-token-123");
when(jwtService.generateRefreshToken(any(), any()))
.thenReturn("refresh-token-456");
when(userRepository.save(any(User.class))).thenReturn(activeUser);
LoginResponse response = authService.login(new LoginRequest(EMAIL, PASSWORD));
assertThat(response).isNotNull();
assertThat(response.accessToken()).isEqualTo("access-token-123");
assertThat(response.refreshToken()).isEqualTo("refresh-token-456");
assertThat(response.expiresIn()).isEqualTo(3600L);
assertThat(response.role()).isEqualTo("ADMIN");
}
// --- T-16: Login invalid password → 401 ---
@Test
@DisplayName("login — invalid password throws AuthenticationException")
void testLogin_invalidPassword_throws401() {
when(userRepository.findByEmail(EMAIL)).thenReturn(Optional.of(activeUser));
when(passwordEncoder.matches("wrong-password", HASHED_PASSWORD)).thenReturn(false);
assertThatThrownBy(() -> authService.login(new LoginRequest(EMAIL, "wrong-password")))
.isInstanceOf(AuthService.AuthenticationException.class)
.hasMessageContaining("Invalid credentials");
}
// --- T-17: Login non-existent user → 401 ---
@Test
@DisplayName("login — non-existent user throws AuthenticationException")
void testLogin_nonExistentUser_throws401() {
when(userRepository.findByEmail("nobody@test.de")).thenReturn(Optional.empty());
assertThatThrownBy(() -> authService.login(new LoginRequest("nobody@test.de", PASSWORD)))
.isInstanceOf(AuthService.AuthenticationException.class)
.hasMessageContaining("Invalid credentials");
}
// --- T-18: Refresh token valid → new access token ---
@Test
@DisplayName("refresh — valid token returns new access token")
void testRefreshToken_validToken_returnsNewAccessToken() {
String oldRefreshToken = "valid-refresh-token";
// Compute expected hash
String expectedHash = sha256(oldRefreshToken);
activeUser.setRefreshTokenHash(expectedHash);
when(jwtService.isTokenValid(oldRefreshToken)).thenReturn(true);
when(jwtService.extractUserId(oldRefreshToken)).thenReturn(USER_ID);
when(userRepository.findById(USER_ID)).thenReturn(Optional.of(activeUser));
when(jwtService.generateAccessToken(any(), any(), anyString(), anyString()))
.thenReturn("new-access-token");
when(jwtService.generateRefreshToken(any(), any()))
.thenReturn("new-refresh-token");
when(userRepository.save(any(User.class))).thenReturn(activeUser);
LoginResponse response = authService.refresh(new RefreshRequest(oldRefreshToken));
assertThat(response).isNotNull();
assertThat(response.accessToken()).isEqualTo("new-access-token");
assertThat(response.refreshToken()).isEqualTo("new-refresh-token");
}
// --- T-19: Refresh token expired → 401 ---
@Test
@DisplayName("refresh — expired/invalid token throws AuthenticationException")
void testRefreshToken_expired_throws401() {
String expiredToken = "expired-refresh-token";
when(jwtService.isTokenValid(expiredToken)).thenReturn(false);
assertThatThrownBy(() -> authService.refresh(new RefreshRequest(expiredToken)))
.isInstanceOf(AuthService.AuthenticationException.class)
.hasMessageContaining("Invalid or expired refresh token");
}
// --- T-20: SHA-256 hashing is deterministic ---
@Test
@DisplayName("SHA-256 hashing is deterministic — same input always produces same hash")
void testSha256_deterministic() {
String input = "test-refresh-token-abc123";
String hash1 = sha256(input);
String hash2 = sha256(input);
assertThat(hash1).isEqualTo(hash2);
assertThat(hash1).hasSize(64); // SHA-256 produces 64 hex chars
assertThat(hash1).matches("[0-9a-f]{64}");
}
@Test
@DisplayName("SHA-256 hashing — different inputs produce different hashes")
void testSha256_differentInputs_differentHashes() {
String hash1 = sha256("token-one");
String hash2 = sha256("token-two");
assertThat(hash1).isNotEqualTo(hash2);
}
// Helper to replicate AuthService's sha256 logic for test verification
private String sha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (Exception e) {
throw new IllegalStateException("SHA-256 not available", e);
}
}
}