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:
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
+196
@@ -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);
|
||||
}
|
||||
}
|
||||
+182
@@ -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.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
|
||||
|
||||
Reference in New Issue
Block a user