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)
This commit is contained in:
Patrick Plate
2026-06-11 13:30:07 +02:00
parent 2ede872d11
commit a1ddec37da
5 changed files with 408 additions and 8 deletions
@@ -2,6 +2,7 @@ package de.cannamanage.api;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/** /**
@@ -11,10 +12,11 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
* Multi-module scanning: * Multi-module scanning:
* - scanBasePackages: component scanning (controllers, services) * - scanBasePackages: component scanning (controllers, services)
* - EnableJpaRepositories: Spring Data JPA repository interfaces * - 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") @SpringBootApplication(scanBasePackages = "de.cannamanage")
@EnableJpaRepositories(basePackages = "de.cannamanage.service.repository") @EnableJpaRepositories(basePackages = "de.cannamanage.service.repository")
@EntityScan(basePackages = "de.cannamanage.domain.entity")
public class CannaManageApplication { public class CannaManageApplication {
public static void main(String[] args) { public static void main(String[] args) {
@@ -12,7 +12,11 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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.time.Instant;
import java.util.HexFormat;
import java.util.UUID; import java.util.UUID;
/** /**
@@ -48,8 +52,8 @@ public class AuthService {
user.getId(), user.getTenantId(), roleName, user.getEmail()); user.getId(), user.getTenantId(), roleName, user.getEmail());
String refreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId()); String refreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId());
// Store hashed refresh token for revocation // Store SHA-256 hashed refresh token for revocation (BCrypt can't handle >72 bytes)
user.setRefreshTokenHash(passwordEncoder.encode(refreshToken)); user.setRefreshTokenHash(sha256(refreshToken));
user.setLastLogin(Instant.now()); user.setLastLogin(Instant.now());
userRepository.save(user); userRepository.save(user);
@@ -76,7 +80,7 @@ public class AuthService {
// Verify the refresh token matches stored hash (revocation check) // Verify the refresh token matches stored hash (revocation check)
if (user.getRefreshTokenHash() == null || if (user.getRefreshTokenHash() == null ||
!passwordEncoder.matches(token, user.getRefreshTokenHash())) { !sha256(token).equals(user.getRefreshTokenHash())) {
throw new AuthenticationException("Refresh token has been revoked"); throw new AuthenticationException("Refresh token has been revoked");
} }
@@ -86,12 +90,27 @@ public class AuthService {
user.getId(), user.getTenantId(), roleName, user.getEmail()); user.getId(), user.getTenantId(), roleName, user.getEmail());
String newRefreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId()); String newRefreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId());
user.setRefreshTokenHash(passwordEncoder.encode(newRefreshToken)); user.setRefreshTokenHash(sha256(newRefreshToken));
userRepository.save(user); userRepository.save(user);
return new LoginResponse(newAccessToken, newRefreshToken, 3600L, roleName); 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. * Custom authentication exception — caught by GlobalExceptionHandler.
*/ */
@@ -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);
}
}
@@ -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);
});
}
}
@@ -1,12 +1,13 @@
spring.application.name=cannamanage-test 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.username=sa
spring.datasource.password= spring.datasource.password=
spring.datasource.driver-class-name=org.h2.Driver 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.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 spring.flyway.enabled=false
# JWT test secret # JWT test secret