From a1ddec37da397cbd958c3bb79f2c725888e9c4a3 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Thu, 11 Jun 2026 13:30:07 +0200 Subject: [PATCH] test(sprint-2): add integration tests for Auth + Compliance controllers - AuthControllerIntegrationTest: 7 tests (login, refresh, error cases) - ComplianceControllerIntegrationTest: 5 tests (quota, auth, 404) - Fix Boot 4.0 @EntityScan relocation (boot.persistence.autoconfigure) - Fix BCrypt 72-byte limit for refresh tokens (use SHA-256 instead) - Configure H2 test DB with NON_KEYWORDS for reserved words (month/year) --- .../api/CannaManageApplication.java | 4 +- .../cannamanage/api/service/AuthService.java | 27 ++- .../AuthControllerIntegrationTest.java | 196 ++++++++++++++++++ .../ComplianceControllerIntegrationTest.java | 182 ++++++++++++++++ .../resources/application-test.properties | 7 +- 5 files changed, 408 insertions(+), 8 deletions(-) create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/controller/AuthControllerIntegrationTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/controller/ComplianceControllerIntegrationTest.java diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/CannaManageApplication.java b/cannamanage-api/src/main/java/de/cannamanage/api/CannaManageApplication.java index dc3c899..e0874e4 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/CannaManageApplication.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/CannaManageApplication.java @@ -2,6 +2,7 @@ package de.cannamanage.api; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.persistence.autoconfigure.EntityScan; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; /** @@ -11,10 +12,11 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; * Multi-module scanning: * - scanBasePackages: component scanning (controllers, services) * - EnableJpaRepositories: Spring Data JPA repository interfaces - * - Entity scanning configured via spring.jpa properties + * - EntityScan: JPA entity detection across modules (Boot 4.0 relocated package) */ @SpringBootApplication(scanBasePackages = "de.cannamanage") @EnableJpaRepositories(basePackages = "de.cannamanage.service.repository") +@EntityScan(basePackages = "de.cannamanage.domain.entity") public class CannaManageApplication { public static void main(String[] args) { diff --git a/cannamanage-api/src/main/java/de/cannamanage/api/service/AuthService.java b/cannamanage-api/src/main/java/de/cannamanage/api/service/AuthService.java index c874c5e..440b989 100644 --- a/cannamanage-api/src/main/java/de/cannamanage/api/service/AuthService.java +++ b/cannamanage-api/src/main/java/de/cannamanage/api/service/AuthService.java @@ -12,7 +12,11 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Instant; +import java.util.HexFormat; import java.util.UUID; /** @@ -48,8 +52,8 @@ public class AuthService { user.getId(), user.getTenantId(), roleName, user.getEmail()); String refreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId()); - // Store hashed refresh token for revocation - user.setRefreshTokenHash(passwordEncoder.encode(refreshToken)); + // Store SHA-256 hashed refresh token for revocation (BCrypt can't handle >72 bytes) + user.setRefreshTokenHash(sha256(refreshToken)); user.setLastLogin(Instant.now()); userRepository.save(user); @@ -76,7 +80,7 @@ public class AuthService { // Verify the refresh token matches stored hash (revocation check) if (user.getRefreshTokenHash() == null || - !passwordEncoder.matches(token, user.getRefreshTokenHash())) { + !sha256(token).equals(user.getRefreshTokenHash())) { throw new AuthenticationException("Refresh token has been revoked"); } @@ -86,12 +90,27 @@ public class AuthService { user.getId(), user.getTenantId(), roleName, user.getEmail()); String newRefreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId()); - user.setRefreshTokenHash(passwordEncoder.encode(newRefreshToken)); + user.setRefreshTokenHash(sha256(newRefreshToken)); userRepository.save(user); return new LoginResponse(newAccessToken, newRefreshToken, 3600L, roleName); } + /** + * SHA-256 hash for refresh token storage. + * JWTs exceed BCrypt's 72-byte limit (enforced in Spring Security 7+). + * SHA-256 is appropriate here: refresh tokens are already high-entropy random strings. + */ + 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 (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 not available", e); + } + } + /** * Custom authentication exception — caught by GlobalExceptionHandler. */ diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/controller/AuthControllerIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/controller/AuthControllerIntegrationTest.java new file mode 100644 index 0000000..2294bb4 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/controller/AuthControllerIntegrationTest.java @@ -0,0 +1,196 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.api.dto.auth.LoginRequest; +import de.cannamanage.api.dto.auth.LoginResponse; +import de.cannamanage.api.dto.auth.RefreshRequest; +import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.domain.entity.User; +import de.cannamanage.domain.enums.UserRole; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration tests for {@link AuthController}. + * Boots the full Spring context with H2 in-memory DB and a real HTTP server. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class AuthControllerIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private RestClient restClient; + + private static final UUID TENANT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final String TEST_EMAIL = "admin@test.club"; + private static final String TEST_PASSWORD = "SecurePass123!"; + + @BeforeEach + void setUp() { + restClient = RestClient.builder() + .baseUrl("http://localhost:" + port) + .build(); + + userRepository.deleteAll(); + + // Set TenantContext so @PrePersist can pick up tenantId + TenantContext.setCurrentTenant(TENANT_ID); + + User user = new User(); + user.setEmail(TEST_EMAIL); + user.setPasswordHash(passwordEncoder.encode(TEST_PASSWORD)); + user.setRole(UserRole.ROLE_ADMIN); + user.setActive(true); + userRepository.saveAndFlush(user); + + TenantContext.clear(); + } + + @Test + @DisplayName("POST /api/v1/auth/login — valid credentials returns tokens") + void login_withValidCredentials_returnsTokens() throws Exception { + LoginRequest request = new LoginRequest(TEST_EMAIL, TEST_PASSWORD); + + LoginResponse response = restClient.post() + .uri("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(LoginResponse.class); + + assertThat(response).isNotNull(); + assertThat(response.accessToken()).isNotBlank(); + assertThat(response.refreshToken()).isNotBlank(); + assertThat(response.expiresIn()).isEqualTo(3600L); + assertThat(response.role()).isEqualTo("ADMIN"); + } + + @Test + @DisplayName("POST /api/v1/auth/login — wrong password returns 401") + void login_withWrongPassword_returns401() { + LoginRequest request = new LoginRequest(TEST_EMAIL, "WrongPassword!"); + + assertThatThrownBy(() -> restClient.post() + .uri("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(String.class)) + .isInstanceOf(HttpClientErrorException.Unauthorized.class); + } + + @Test + @DisplayName("POST /api/v1/auth/login — unknown email returns 401") + void login_withUnknownEmail_returns401() { + LoginRequest request = new LoginRequest("nobody@test.club", TEST_PASSWORD); + + assertThatThrownBy(() -> restClient.post() + .uri("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(String.class)) + .isInstanceOf(HttpClientErrorException.Unauthorized.class); + } + + @Test + @DisplayName("POST /api/v1/auth/login — disabled user returns 401") + void login_withDisabledUser_returns401() { + // Disable the test user + TenantContext.setCurrentTenant(TENANT_ID); + User user = userRepository.findByEmail(TEST_EMAIL).orElseThrow(); + user.setActive(false); + userRepository.saveAndFlush(user); + TenantContext.clear(); + + LoginRequest request = new LoginRequest(TEST_EMAIL, TEST_PASSWORD); + + assertThatThrownBy(() -> restClient.post() + .uri("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(String.class)) + .isInstanceOf(HttpClientErrorException.Unauthorized.class); + } + + @Test + @DisplayName("POST /api/v1/auth/login — missing email returns 400") + void login_withMissingEmail_returns400() { + assertThatThrownBy(() -> restClient.post() + .uri("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .body("{\"password\": \"test123\"}") + .retrieve() + .body(String.class)) + .isInstanceOf(HttpClientErrorException.BadRequest.class); + } + + @Test + @DisplayName("POST /api/v1/auth/refresh — valid refresh token returns new token pair") + void refresh_withValidToken_returnsNewTokens() throws Exception { + // First login to get a refresh token + LoginRequest loginRequest = new LoginRequest(TEST_EMAIL, TEST_PASSWORD); + + LoginResponse loginResponse = restClient.post() + .uri("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .body(loginRequest) + .retrieve() + .body(LoginResponse.class); + + assertThat(loginResponse).isNotNull(); + String refreshToken = loginResponse.refreshToken(); + + // Use the refresh token + RefreshRequest refreshRequest = new RefreshRequest(refreshToken); + + LoginResponse refreshResponse = restClient.post() + .uri("/api/v1/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .body(refreshRequest) + .retrieve() + .body(LoginResponse.class); + + assertThat(refreshResponse).isNotNull(); + assertThat(refreshResponse.accessToken()).isNotBlank(); + assertThat(refreshResponse.refreshToken()).isNotBlank(); + assertThat(refreshResponse.role()).isEqualTo("ADMIN"); + } + + @Test + @DisplayName("POST /api/v1/auth/refresh — invalid token returns 401") + void refresh_withInvalidToken_returns401() { + RefreshRequest request = new RefreshRequest("invalid.jwt.token"); + + assertThatThrownBy(() -> restClient.post() + .uri("/api/v1/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(String.class)) + .isInstanceOf(HttpClientErrorException.Unauthorized.class); + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/controller/ComplianceControllerIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/controller/ComplianceControllerIntegrationTest.java new file mode 100644 index 0000000..b177a12 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/controller/ComplianceControllerIntegrationTest.java @@ -0,0 +1,182 @@ +package de.cannamanage.api.controller; + +import de.cannamanage.api.dto.compliance.QuotaResponse; +import de.cannamanage.api.security.JwtService; +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.domain.enums.MemberStatus; +import de.cannamanage.service.repository.MemberRepository; +import org.junit.jupiter.api.BeforeEach; +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.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestClient; + +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration tests for {@link ComplianceController}. + * Boots the full Spring context with H2 in-memory DB. + * Tests quota status endpoint with JWT authentication. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class ComplianceControllerIntegrationTest { + + @LocalServerPort + private int port; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private JwtService jwtService; + + private RestClient restClient; + + private static final UUID TENANT_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000099"); + + private UUID memberId; + private String adminToken; + + @BeforeEach + void setUp() { + memberRepository.deleteAll(); + + TenantContext.setCurrentTenant(TENANT_ID); + + // Create a test member (adult, 25 years old) + Member member = new Member(); + member.setClubId(UUID.fromString("00000000-0000-0000-0000-000000000010")); + member.setFirstName("Max"); + member.setLastName("Mustermann"); + member.setEmail("max@test.club"); + member.setDateOfBirth(LocalDate.now().minusYears(25)); + member.setMembershipDate(LocalDate.now().minusMonths(6)); + member.setMembershipNumber("CM-2025-001"); + member.setStatus(MemberStatus.ACTIVE); + member.setUnder21(false); + member = memberRepository.saveAndFlush(member); + memberId = member.getId(); + + TenantContext.clear(); + + // Generate a JWT token for authentication + adminToken = jwtService.generateAccessToken(USER_ID, TENANT_ID, "ADMIN", "admin@test.club"); + + restClient = RestClient.builder() + .baseUrl("http://localhost:" + port) + .defaultHeader("Authorization", "Bearer " + adminToken) + .build(); + } + + @Test + @DisplayName("GET /api/v1/compliance/quota/{memberId} — returns quota for adult member") + void getQuotaStatus_adultMember_returnsQuota() { + QuotaResponse response = restClient.get() + .uri("/api/v1/compliance/quota/{memberId}", memberId) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(QuotaResponse.class); + + assertThat(response).isNotNull(); + assertThat(response.totalAllowed()).isEqualByComparingTo("50"); + assertThat(response.totalUsed()).isEqualByComparingTo("0"); + assertThat(response.remaining()).isEqualByComparingTo("50"); + assertThat(response.under21()).isFalse(); + assertThat(response.year()).isEqualTo(LocalDate.now().getYear()); + assertThat(response.month()).isEqualTo(LocalDate.now().getMonthValue()); + } + + @Test + @DisplayName("GET /api/v1/compliance/quota/{memberId} — under-21 member gets reduced limit") + void getQuotaStatus_under21Member_returnsReducedLimit() { + // Create under-21 member + TenantContext.setCurrentTenant(TENANT_ID); + Member youngMember = new Member(); + youngMember.setClubId(UUID.fromString("00000000-0000-0000-0000-000000000010")); + youngMember.setFirstName("Jung"); + youngMember.setLastName("Mitglied"); + youngMember.setEmail("jung@test.club"); + youngMember.setDateOfBirth(LocalDate.now().minusYears(19)); + youngMember.setMembershipDate(LocalDate.now().minusMonths(3)); + youngMember.setMembershipNumber("CM-2025-002"); + youngMember.setStatus(MemberStatus.ACTIVE); + youngMember.setUnder21(true); + youngMember = memberRepository.saveAndFlush(youngMember); + UUID youngMemberId = youngMember.getId(); + TenantContext.clear(); + + QuotaResponse response = restClient.get() + .uri("/api/v1/compliance/quota/{memberId}", youngMemberId) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(QuotaResponse.class); + + assertThat(response).isNotNull(); + assertThat(response.totalAllowed()).isEqualByComparingTo("30"); + assertThat(response.under21()).isTrue(); + } + + @Test + @DisplayName("GET /api/v1/compliance/quota/{memberId} — non-existent member returns 404") + void getQuotaStatus_nonExistentMember_returns404() { + UUID nonExistentId = UUID.fromString("00000000-0000-0000-0000-999999999999"); + + assertThatThrownBy(() -> restClient.get() + .uri("/api/v1/compliance/quota/{memberId}", nonExistentId) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(String.class)) + .isInstanceOf(HttpClientErrorException.NotFound.class); + } + + @Test + @DisplayName("GET /api/v1/compliance/quota/{memberId} — no auth returns 401/403") + void getQuotaStatus_noAuth_returnsUnauthorized() { + RestClient unauthClient = RestClient.builder() + .baseUrl("http://localhost:" + port) + .build(); + + assertThatThrownBy(() -> unauthClient.get() + .uri("/api/v1/compliance/quota/{memberId}", memberId) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(String.class)) + .isInstanceOf(HttpClientErrorException.class) + .satisfies(ex -> { + int status = ((HttpClientErrorException) ex).getStatusCode().value(); + assertThat(status).isBetween(401, 403); + }); + } + + @Test + @DisplayName("GET /api/v1/compliance/quota/{memberId} — invalid token returns 401/403") + void getQuotaStatus_invalidToken_returnsUnauthorized() { + RestClient badTokenClient = RestClient.builder() + .baseUrl("http://localhost:" + port) + .defaultHeader("Authorization", "Bearer invalid.jwt.token") + .build(); + + assertThatThrownBy(() -> badTokenClient.get() + .uri("/api/v1/compliance/quota/{memberId}", memberId) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(String.class)) + .isInstanceOf(HttpClientErrorException.class) + .satisfies(ex -> { + int status = ((HttpClientErrorException) ex).getStatusCode().value(); + assertThat(status).isBetween(401, 403); + }); + } +} diff --git a/cannamanage-api/src/test/resources/application-test.properties b/cannamanage-api/src/test/resources/application-test.properties index 950cc47..ca2cf4f 100644 --- a/cannamanage-api/src/test/resources/application-test.properties +++ b/cannamanage-api/src/test/resources/application-test.properties @@ -1,12 +1,13 @@ spring.application.name=cannamanage-test -spring.datasource.url=jdbc:h2:mem:cannamanage_test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL +spring.datasource.url=jdbc:h2:mem:cannamanage_test;DB_CLOSE_DELAY=-1;MODE=PostgreSQL;NON_KEYWORDS=MONTH,YEAR spring.datasource.username=sa spring.datasource.password= spring.datasource.driver-class-name=org.h2.Driver -# Let Hibernate create schema from entities (H2 doesn't support all Postgres DDL) +# Let Hibernate create schema from entities spring.jpa.hibernate.ddl-auto=create-drop -spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.open-in-view=false +spring.jpa.show-sql=true spring.flyway.enabled=false # JWT test secret