From 864bbbdde1cbd24c8ca1bdbbb80d107212d34d7d Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Fri, 12 Jun 2026 11:05:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(sprint-3):=20Phase=207=20=E2=80=94=20integ?= =?UTF-8?q?ration=20tests=20(Testcontainers=20PostgreSQL)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AbstractIntegrationTest base class with Testcontainers PostgreSQL, RestClient helpers, and test data factories - AuthIntegrationTest: login, refresh, token rotation, error cases - TenantIsolationTest: multi-tenant data isolation verification - StaffPermissionIntegrationTest: invite → activate → permission enforcement - PortalIntegrationTest: session-based portal auth flow - ReportIntegrationTest: JSON/PDF/CSV report generation E2E - TokenRevocationIntegrationTest: permission change → JWT revocation - application-integration.properties: Flyway-enabled test profile - Remove obsolete Boot 3 @WebMvcTest/@MockBean tests (Boot 4 incompatible) replaced by comprehensive integration tests with real PostgreSQL --- .../api/controller/PortalControllerTest.java | 112 --------- .../api/controller/StaffControllerTest.java | 168 -------------- .../integration/AbstractIntegrationTest.java | 195 ++++++++++++++++ .../api/integration/AuthIntegrationTest.java | 161 +++++++++++++ .../integration/PortalIntegrationTest.java | 126 +++++++++++ .../integration/ReportIntegrationTest.java | 187 +++++++++++++++ .../StaffPermissionIntegrationTest.java | 175 +++++++++++++++ .../api/integration/TenantIsolationTest.java | 135 +++++++++++ .../TokenRevocationIntegrationTest.java | 212 ++++++++++++++++++ .../application-integration.properties | 31 +++ 10 files changed, 1222 insertions(+), 280 deletions(-) delete mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/controller/PortalControllerTest.java delete mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/controller/StaffControllerTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/integration/AbstractIntegrationTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/integration/AuthIntegrationTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/integration/PortalIntegrationTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/integration/ReportIntegrationTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/integration/StaffPermissionIntegrationTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/integration/TenantIsolationTest.java create mode 100644 cannamanage-api/src/test/java/de/cannamanage/api/integration/TokenRevocationIntegrationTest.java create mode 100644 cannamanage-api/src/test/resources/application-integration.properties diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/controller/PortalControllerTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/controller/PortalControllerTest.java deleted file mode 100644 index f25f29f..0000000 --- a/cannamanage-api/src/test/java/de/cannamanage/api/controller/PortalControllerTest.java +++ /dev/null @@ -1,112 +0,0 @@ -package de.cannamanage.api.controller; - -import de.cannamanage.api.security.JwtAuthFilter; -import de.cannamanage.api.security.JwtService; -import de.cannamanage.api.security.PortalPrincipal; -import de.cannamanage.api.security.PortalUserDetailsService; -import de.cannamanage.service.PortalService; -import de.cannamanage.service.TokenRevocationService; -import de.cannamanage.service.dto.portal.PortalDashboard; -import de.cannamanage.service.dto.portal.PortalProfile; -import de.cannamanage.service.dto.portal.PortalQuota; -import de.cannamanage.service.repository.UserRepository; -import de.cannamanage.domain.enums.MemberStatus; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.bean.MockBean; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.test.web.servlet.MockMvc; - -import java.math.BigDecimal; -import java.time.LocalDate; -import java.util.List; -import java.util.UUID; - -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@WebMvcTest(PortalController.class) -class PortalControllerTest { - - @Autowired private MockMvc mockMvc; - - @MockBean private PortalService portalService; - @MockBean private UserRepository userRepository; - @MockBean private JwtService jwtService; - @MockBean private JwtAuthFilter jwtAuthFilter; - @MockBean private TokenRevocationService tokenRevocationService; - @MockBean private PortalUserDetailsService portalUserDetailsService; - - private final UUID tenantId = UUID.randomUUID(); - private final UUID memberId = UUID.randomUUID(); - - private PortalPrincipal createPrincipal() { - return new PortalPrincipal( - "member@test.de", "password", - List.of(new SimpleGrantedAuthority("ROLE_MEMBER")), - tenantId, memberId - ); - } - - @Test - void dashboard_returnsCorrectQuotaData() throws Exception { - PortalPrincipal principal = createPrincipal(); - PortalDashboard dashboard = new PortalDashboard( - "Max Mustermann", "CM-001", - new BigDecimal("12.5"), new BigDecimal("37.5"), - new BigDecimal("5.0"), new BigDecimal("20.0"), - List.of() - ); - when(portalService.getDashboard(tenantId, memberId)).thenReturn(dashboard); - - mockMvc.perform(get("/portal/dashboard").with(user(principal))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.memberName").value("Max Mustermann")) - .andExpect(jsonPath("$.membershipNumber").value("CM-001")) - .andExpect(jsonPath("$.monthlyQuotaUsed").value(12.5)) - .andExpect(jsonPath("$.dailyQuotaRemaining").value(20.0)); - } - - @Test - void portal_returns403_forUnauthenticated() throws Exception { - mockMvc.perform(get("/portal/dashboard")) - .andExpect(status().isUnauthorized()); - } - - @Test - void profile_returnsOwnData() throws Exception { - PortalPrincipal principal = createPrincipal(); - PortalProfile profile = new PortalProfile( - "Max", "Mustermann", "CM-001", - LocalDate.of(2025, 1, 15), MemberStatus.ACTIVE, "max@example.com" - ); - when(portalService.getProfile(tenantId, memberId)).thenReturn(profile); - - mockMvc.perform(get("/portal/me").with(user(principal))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.firstName").value("Max")) - .andExpect(jsonPath("$.membershipNumber").value("CM-001")); - } - - @Test - void quota_returnsCurrentMonthStatus() throws Exception { - PortalPrincipal principal = createPrincipal(); - PortalQuota quota = new PortalQuota( - 2026, 6, - new BigDecimal("5.0"), new BigDecimal("25.0"), - new BigDecimal("20.0"), new BigDecimal("50.0"), - false, new BigDecimal("30.0") - ); - when(portalService.getQuota(tenantId, memberId)).thenReturn(quota); - - mockMvc.perform(get("/portal/quota").with(user(principal))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.year").value(2026)) - .andExpect(jsonPath("$.month").value(6)) - .andExpect(jsonPath("$.dailyUsed").value(5.0)) - .andExpect(jsonPath("$.monthlyLimit").value(50.0)); - } -} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/controller/StaffControllerTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/controller/StaffControllerTest.java deleted file mode 100644 index 5481217..0000000 --- a/cannamanage-api/src/test/java/de/cannamanage/api/controller/StaffControllerTest.java +++ /dev/null @@ -1,168 +0,0 @@ -package de.cannamanage.api.controller; - -import com.fasterxml.jackson.databind.ObjectMapper; -import de.cannamanage.api.dto.staff.CreateStaffRequest; -import de.cannamanage.api.dto.staff.UpdateStaffRequest; -import de.cannamanage.api.security.JwtAuthFilter; -import de.cannamanage.api.security.JwtService; -import de.cannamanage.domain.entity.StaffAccount; -import de.cannamanage.domain.entity.TenantContext; -import de.cannamanage.domain.entity.User; -import de.cannamanage.domain.enums.StaffPermission; -import de.cannamanage.domain.enums.UserRole; -import de.cannamanage.service.PreventionOfficerService; -import de.cannamanage.service.StaffService; -import de.cannamanage.service.TokenRevocationService; -import de.cannamanage.service.repository.UserRepository; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.bean.MockBean; -import org.springframework.http.MediaType; -import org.springframework.security.test.context.support.WithMockUser; -import org.springframework.test.web.servlet.MockMvc; - -import java.time.Instant; -import java.util.*; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@WebMvcTest(StaffController.class) -class StaffControllerTest { - - @Autowired private MockMvc mockMvc; - @Autowired private ObjectMapper objectMapper; - - @MockBean private StaffService staffService; - @MockBean private PreventionOfficerService preventionOfficerService; - @MockBean private UserRepository userRepository; - @MockBean private JwtService jwtService; - @MockBean private JwtAuthFilter jwtAuthFilter; - @MockBean private TokenRevocationService tokenRevocationService; - - private UUID tenantId; - private UUID staffId; - private UUID userId; - - @BeforeEach - void setUp() { - tenantId = UUID.randomUUID(); - staffId = UUID.randomUUID(); - userId = UUID.randomUUID(); - TenantContext.setCurrentTenant(tenantId); - } - - @Test - @WithMockUser(roles = "ADMIN") - void listStaff_returnsStaffList() throws Exception { - StaffAccount staff = createStaffAccount(); - User user = createUser(); - when(staffService.listStaff(tenantId)).thenReturn(List.of(staff)); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - mockMvc.perform(get("/api/v1/staff")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].displayName").value("Test Staff")) - .andExpect(jsonPath("$[0].email").value("staff@test.de")); - } - - @Test - @WithMockUser(roles = "ADMIN") - void createStaff_validRequest_returns201() throws Exception { - CreateStaffRequest request = new CreateStaffRequest( - "new@test.de", "New Staff", - EnumSet.of(StaffPermission.VIEW_STOCK), null); - - StaffAccount created = createStaffAccount(); - when(staffService.createStaff(eq(tenantId), eq("new@test.de"), eq("New Staff"), any(), any())) - .thenReturn(created); - - mockMvc.perform(post("/api/v1/staff") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.displayName").value("Test Staff")); - } - - @Test - @WithMockUser(roles = "ADMIN") - void createStaff_invalidEmail_returns400() throws Exception { - CreateStaffRequest request = new CreateStaffRequest( - "not-an-email", "Bad Staff", - EnumSet.of(StaffPermission.VIEW_STOCK), null); - - mockMvc.perform(post("/api/v1/staff") - .with(csrf()) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isBadRequest()); - } - - @Test - @WithMockUser(roles = "ADMIN") - void getStaff_returns200() throws Exception { - StaffAccount staff = createStaffAccount(); - User user = createUser(); - when(staffService.getStaff(tenantId, staffId)).thenReturn(staff); - when(userRepository.findById(userId)).thenReturn(Optional.of(user)); - - mockMvc.perform(get("/api/v1/staff/{id}", staffId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(staffId.toString())); - } - - @Test - @WithMockUser(roles = "ADMIN") - void deactivateStaff_returns204() throws Exception { - mockMvc.perform(delete("/api/v1/staff/{id}", staffId).with(csrf())) - .andExpect(status().isNoContent()); - - verify(staffService).deactivateStaff(tenantId, staffId); - } - - @Test - @WithMockUser(roles = "ADMIN") - void listTemplates_returnsTemplateMap() throws Exception { - mockMvc.perform(get("/api/v1/staff/templates")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.ausgabe").isArray()) - .andExpect(jsonPath("$.lager").isArray()) - .andExpect(jsonPath("$.vorstand").isArray()); - } - - @Test - @WithMockUser(roles = "MEMBER") - void listStaff_asMember_returns403() throws Exception { - mockMvc.perform(get("/api/v1/staff")) - .andExpect(status().isForbidden()); - } - - private StaffAccount createStaffAccount() { - StaffAccount staff = new StaffAccount(); - staff.setId(staffId); - staff.setTenantId(tenantId); - staff.setUserId(userId); - staff.setDisplayName("Test Staff"); - staff.setGrantedPermissions(EnumSet.of(StaffPermission.VIEW_STOCK)); - staff.setActive(true); - staff.setCreatedAt(Instant.now()); - return staff; - } - - private User createUser() { - User user = new User(); - user.setId(userId); - user.setEmail("staff@test.de"); - user.setRole(UserRole.ROLE_STAFF); - user.setActive(true); - return user; - } -} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/integration/AbstractIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/integration/AbstractIntegrationTest.java new file mode 100644 index 0000000..1d058d3 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/integration/AbstractIntegrationTest.java @@ -0,0 +1,195 @@ +package de.cannamanage.api.integration; + +import de.cannamanage.api.dto.auth.LoginRequest; +import de.cannamanage.api.dto.auth.LoginResponse; +import de.cannamanage.api.dto.member.CreateMemberRequest; +import de.cannamanage.api.dto.member.MemberResponse; +import de.cannamanage.api.dto.stock.BatchResponse; +import de.cannamanage.api.dto.stock.CreateBatchRequest; +import de.cannamanage.domain.entity.Club; +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.User; +import de.cannamanage.domain.enums.ClubStatus; +import de.cannamanage.domain.enums.UserRole; +import de.cannamanage.service.repository.ClubRepository; +import de.cannamanage.service.repository.MemberRepository; +import de.cannamanage.service.repository.UserRepository; +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.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.client.RestClient; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +/** + * Base class for integration tests using Testcontainers PostgreSQL. + * Uses RestClient (Spring Boot 4 — TestRestTemplate was removed in Boot 4). + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Testcontainers +@ActiveProfiles("integration") +public abstract class AbstractIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("cannamanage_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + @LocalServerPort + protected int port; + + @Autowired + protected UserRepository userRepository; + + @Autowired + protected ClubRepository clubRepository; + + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected PasswordEncoder passwordEncoder; + + /** + * Creates a RestClient configured with the test server's base URL. + * Configured to NOT throw on 4xx/5xx responses (so tests can assert status codes). + */ + protected RestClient restClient() { + return RestClient.builder() + .baseUrl("http://localhost:" + port) + .defaultStatusHandler(org.springframework.http.HttpStatusCode::isError, (req, res) -> { + // Don't throw — let tests inspect status codes directly + }) + .build(); + } + + // --- Auth helper methods --- + + /** + * Logs in with given credentials and returns the full LoginResponse. + */ + protected LoginResponse login(String email, String password) { + return restClient().post() + .uri("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .body(new LoginRequest(email, password)) + .retrieve() + .body(LoginResponse.class); + } + + /** + * Convenience: login and return just the access token. + */ + protected String getAccessToken(String email, String password) { + return login(email, password).accessToken(); + } + + // --- Test data creation helpers --- + + /** + * Creates a club (tenant) and returns its ID. + */ + protected UUID createTestClub(String name) { + Club club = new Club(); + club.setName(name); + club.setStatus(ClubStatus.ACTIVE); + club.setMaxMembers(500); + club.setMaxPreventionOfficers(3); + club = clubRepository.save(club); + return club.getId(); + } + + /** + * Creates an admin user for the given tenant and returns the User entity. + */ + protected User createAdminUser(UUID tenantId, String email, String password) { + User user = new User(); + user.setTenantId(tenantId); + user.setEmail(email); + user.setPasswordHash(passwordEncoder.encode(password)); + user.setRole(UserRole.ROLE_ADMIN); + user.setActive(true); + return userRepository.save(user); + } + + /** + * Creates a member user for the portal (ROLE_MEMBER) linked to a Member entity. + */ + protected User createMemberUser(UUID tenantId, UUID memberId, String email, String password) { + User user = new User(); + user.setTenantId(tenantId); + user.setMemberId(memberId); + user.setEmail(email); + user.setPasswordHash(passwordEncoder.encode(password)); + user.setRole(UserRole.ROLE_MEMBER); + user.setActive(true); + return userRepository.save(user); + } + + /** + * Creates a Member entity via API (requires admin token). + */ + protected MemberResponse createTestMember(String adminToken, String firstName, String lastName, + String email, LocalDate dateOfBirth) { + CreateMemberRequest request = new CreateMemberRequest( + firstName, lastName, email, dateOfBirth, + LocalDate.now(), "M-" + UUID.randomUUID().toString().substring(0, 8)); + return restClient().post() + .uri("/api/v1/members") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(MemberResponse.class); + } + + /** + * Creates a Batch entity via API (requires admin token). + */ + protected BatchResponse createTestBatch(String adminToken, UUID strainId, BigDecimal quantity, String batchCode) { + CreateBatchRequest request = new CreateBatchRequest(strainId, quantity, LocalDate.now(), batchCode); + return restClient().post() + .uri("/api/v1/stock/batches") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(request) + .retrieve() + .body(BatchResponse.class); + } + + /** + * Creates a Member entity directly in the DB (bypassing API / tenant filter). + */ + protected Member createMemberDirectly(UUID tenantId, String firstName, String lastName, + String email, LocalDate dateOfBirth) { + Member member = new Member(); + member.setTenantId(tenantId); + member.setClubId(tenantId); + member.setFirstName(firstName); + member.setLastName(lastName); + member.setEmail(email); + member.setDateOfBirth(dateOfBirth); + member.setMembershipDate(LocalDate.now()); + member.setMembershipNumber("M-" + UUID.randomUUID().toString().substring(0, 8)); + member.setUnder21(java.time.Period.between(dateOfBirth, LocalDate.now()).getYears() < 21); + return memberRepository.save(member); + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/integration/AuthIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/integration/AuthIntegrationTest.java new file mode 100644 index 0000000..2d7cc09 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/integration/AuthIntegrationTest.java @@ -0,0 +1,161 @@ +package de.cannamanage.api.integration; + +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.User; +import de.cannamanage.domain.enums.UserRole; +import org.junit.jupiter.api.*; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test: Full authentication flow. + * Tests login, token refresh, revocation, and error cases. + */ +class AuthIntegrationTest extends AbstractIntegrationTest { + + private UUID tenantId; + private static final String ADMIN_EMAIL = "auth-admin@test.de"; + private static final String ADMIN_PASSWORD = "AdminPass123!"; + + @BeforeEach + void setUp() { + tenantId = createTestClub("Auth Test Club"); + createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD); + } + + @Test + @DisplayName("Login with valid credentials returns JWT + refresh token") + void loginWithValidCredentials_returnsTokens() { + LoginResponse response = login(ADMIN_EMAIL, ADMIN_PASSWORD); + + assertThat(response).isNotNull(); + assertThat(response.accessToken()).isNotBlank(); + assertThat(response.refreshToken()).isNotBlank(); + assertThat(response.expiresIn()).isEqualTo(3600L); + assertThat(response.role()).isEqualTo("ADMIN"); + } + + @Test + @DisplayName("Access protected endpoint with JWT returns 200") + void accessProtectedEndpoint_withValidJwt_returns200() { + String token = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD); + + ResponseEntity response = restClient().get() + .uri("/api/v1/members") + .header("Authorization", "Bearer " + token) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + } + + @Test + @DisplayName("Refresh token returns new JWT pair") + void refreshToken_returnsNewTokenPair() { + LoginResponse loginResponse = login(ADMIN_EMAIL, ADMIN_PASSWORD); + + ResponseEntity response = restClient().post() + .uri("/api/v1/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .body(new RefreshRequest(loginResponse.refreshToken())) + .retrieve() + .toEntity(LoginResponse.class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + LoginResponse refreshed = response.getBody(); + assertThat(refreshed).isNotNull(); + assertThat(refreshed.accessToken()).isNotBlank(); + assertThat(refreshed.refreshToken()).isNotBlank(); + assertThat(refreshed.accessToken()).isNotEqualTo(loginResponse.accessToken()); + assertThat(refreshed.refreshToken()).isNotEqualTo(loginResponse.refreshToken()); + } + + @Test + @DisplayName("Old refresh token is invalidated after rotation") + void oldRefreshToken_afterRotation_isInvalid() { + LoginResponse loginResponse = login(ADMIN_EMAIL, ADMIN_PASSWORD); + String oldRefreshToken = loginResponse.refreshToken(); + + // Use refresh token once (rotation) + restClient().post() + .uri("/api/v1/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .body(new RefreshRequest(oldRefreshToken)) + .retrieve() + .toEntity(LoginResponse.class); + + // Try to use the old refresh token again — should fail + ResponseEntity response = restClient().post() + .uri("/api/v1/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .body(new RefreshRequest(oldRefreshToken)) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(401); + } + + @Test + @DisplayName("Login with wrong password returns 401") + void loginWithWrongPassword_returns401() { + ResponseEntity response = restClient().post() + .uri("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .body(new LoginRequest(ADMIN_EMAIL, "WrongPassword!")) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(401); + } + + @Test + @DisplayName("Login with non-existent email returns 401") + void loginWithNonExistentEmail_returns401() { + ResponseEntity response = restClient().post() + .uri("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .body(new LoginRequest("nobody@test.de", "whatever")) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(401); + } + + @Test + @DisplayName("Access protected endpoint without token returns 401/403") + void accessProtectedEndpoint_withoutToken_returnsUnauthorized() { + ResponseEntity response = restClient().get() + .uri("/api/v1/members") + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isIn(401, 403); + } + + @Test + @DisplayName("Inactive user cannot login") + void inactiveUser_cannotLogin() { + User inactiveUser = new User(); + inactiveUser.setTenantId(tenantId); + inactiveUser.setEmail("inactive@test.de"); + inactiveUser.setPasswordHash(passwordEncoder.encode("Test123!")); + inactiveUser.setRole(UserRole.ROLE_ADMIN); + inactiveUser.setActive(false); + userRepository.save(inactiveUser); + + ResponseEntity response = restClient().post() + .uri("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .body(new LoginRequest("inactive@test.de", "Test123!")) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(401); + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/integration/PortalIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/integration/PortalIntegrationTest.java new file mode 100644 index 0000000..c1dbd2d --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/integration/PortalIntegrationTest.java @@ -0,0 +1,126 @@ +package de.cannamanage.api.integration; + +import de.cannamanage.domain.entity.Member; +import de.cannamanage.domain.entity.User; +import org.junit.jupiter.api.*; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test: Portal session-based authentication. + * Verifies form login, session cookie, own-data access, and access denial. + */ +class PortalIntegrationTest extends AbstractIntegrationTest { + + private UUID tenantId; + private UUID memberId; + + @BeforeEach + void setUp() { + tenantId = createTestClub("Portal Test Club"); + + // Create a member directly in DB + Member member = createMemberDirectly(tenantId, "Portal", "User", + "portal@test.de", LocalDate.of(1990, 5, 15)); + memberId = member.getId(); + + // Create a MEMBER user linked to the member + createMemberUser(tenantId, memberId, "portal@test.de", "PortalPass123!"); + } + + @Test + @DisplayName("Portal login with valid credentials returns 200 + session cookie") + void portalLogin_validCredentials_returnsOk() { + // Portal login is form-based — POST with x-www-form-urlencoded + ResponseEntity response = restClient().post() + .uri("/portal/login") + .header("Content-Type", "application/x-www-form-urlencoded") + .body("username=portal@test.de&password=PortalPass123!") + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).contains("ok"); + + // Session cookie should be set + assertThat(response.getHeaders().get("Set-Cookie")).isNotNull(); + } + + @Test + @DisplayName("Portal dashboard accessible with session") + void portalDashboard_withSession_returns200() { + // Login first + ResponseEntity loginResponse = restClient().post() + .uri("/portal/login") + .header("Content-Type", "application/x-www-form-urlencoded") + .body("username=portal@test.de&password=PortalPass123!") + .retrieve() + .toEntity(String.class); + + // Extract session cookie + String sessionCookie = loginResponse.getHeaders().getFirst("Set-Cookie"); + if (sessionCookie != null) { + String cookieValue = sessionCookie.split(";")[0]; + + ResponseEntity dashResponse = restClient().get() + .uri("/portal/dashboard") + .header("Cookie", cookieValue) + .retrieve() + .toEntity(String.class); + + assertThat(dashResponse.getStatusCode().value()).isEqualTo(200); + } + } + + @Test + @DisplayName("Portal quota endpoint returns member's quota data") + void portalQuota_withSession_returns200() { + ResponseEntity loginResponse = restClient().post() + .uri("/portal/login") + .header("Content-Type", "application/x-www-form-urlencoded") + .body("username=portal@test.de&password=PortalPass123!") + .retrieve() + .toEntity(String.class); + + String sessionCookie = loginResponse.getHeaders().getFirst("Set-Cookie"); + if (sessionCookie != null) { + String cookieValue = sessionCookie.split(";")[0]; + + ResponseEntity quotaResponse = restClient().get() + .uri("/portal/quota") + .header("Cookie", cookieValue) + .retrieve() + .toEntity(String.class); + + assertThat(quotaResponse.getStatusCode().value()).isEqualTo(200); + } + } + + @Test + @DisplayName("Portal access without session returns unauthorized/redirect") + void portalAccess_withoutSession_returnsUnauthorized() { + ResponseEntity response = restClient().get() + .uri("/portal/dashboard") + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isIn(401, 403, 302); + } + + @Test + @DisplayName("Portal login with invalid credentials returns 401") + void portalLogin_invalidCredentials_returns401() { + ResponseEntity response = restClient().post() + .uri("/portal/login") + .header("Content-Type", "application/x-www-form-urlencoded") + .body("username=portal@test.de&password=WrongPassword!") + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(401); + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/integration/ReportIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/integration/ReportIntegrationTest.java new file mode 100644 index 0000000..1c730f0 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/integration/ReportIntegrationTest.java @@ -0,0 +1,187 @@ +package de.cannamanage.api.integration; + +import de.cannamanage.api.dto.distribution.CreateDistributionRequest; +import de.cannamanage.api.dto.member.MemberResponse; +import de.cannamanage.api.dto.stock.BatchResponse; +import de.cannamanage.domain.entity.Strain; +import de.cannamanage.domain.entity.TenantContext; +import de.cannamanage.service.repository.StrainRepository; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test: Report generation E2E. + * Verifies JSON, PDF, and CSV output for monthly reports and recall reports. + */ +class ReportIntegrationTest extends AbstractIntegrationTest { + + @Autowired + private StrainRepository strainRepository; + + private UUID tenantId; + private String adminToken; + + @BeforeEach + void setUp() { + tenantId = createTestClub("Report Test Club"); + createAdminUser(tenantId, "report-admin@test.de", "AdminPass123!"); + adminToken = getAccessToken("report-admin@test.de", "AdminPass123!"); + } + + private Strain createTestStrain(String name) { + Strain strain = new Strain(); + strain.setTenantId(tenantId); + strain.setName(name); + strain.setThcPercentage(new BigDecimal("18.5")); + strain.setCbdPercentage(new BigDecimal("0.5")); + TenantContext.setCurrentTenant(tenantId); + strain = strainRepository.save(strain); + TenantContext.clear(); + return strain; + } + + @Test + @DisplayName("Monthly report JSON — returns totals and distribution data") + void monthlyReportJson_returnsTotals() { + MemberResponse member = createTestMember(adminToken, "Report", "Member", + "report-member@test.de", LocalDate.of(1990, 3, 15)); + + Strain strain = createTestStrain("Test Strain"); + BatchResponse batch = createTestBatch(adminToken, strain.getId(), + new BigDecimal("500.0"), "BATCH-R-001"); + + // Create a distribution + restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(new CreateDistributionRequest( + member.id(), batch.id(), new BigDecimal("5.0"), "Report test")) + .retrieve() + .toEntity(String.class); + + // Get monthly report as JSON + String currentMonth = YearMonth.now().toString(); + ResponseEntity response = restClient().get() + .uri("/api/v1/reports/monthly?month=" + currentMonth + "&format=json") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).contains("totalDistributions"); + assertThat(response.getBody()).contains("totalGrams"); + } + + @Test + @DisplayName("Monthly report PDF — returns valid PDF bytes") + void monthlyReportPdf_returnsValidPdf() { + createTestMember(adminToken, "PDF", "Member", + "pdf-member@test.de", LocalDate.of(1990, 3, 15)); + + String currentMonth = YearMonth.now().toString(); + ResponseEntity response = restClient().get() + .uri("/api/v1/reports/monthly?month=" + currentMonth + "&format=pdf") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(byte[].class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().length).isGreaterThan(0); + // PDF starts with %PDF + assertThat(new String(response.getBody(), 0, 4)).isEqualTo("%PDF"); + } + + @Test + @DisplayName("Monthly report CSV — returns UTF-8 BOM + headers") + void monthlyReportCsv_returnsValidCsv() { + createTestMember(adminToken, "CSV", "Member", + "csv-member@test.de", LocalDate.of(1990, 3, 15)); + + String currentMonth = YearMonth.now().toString(); + ResponseEntity response = restClient().get() + .uri("/api/v1/reports/monthly?month=" + currentMonth + "&format=csv") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(byte[].class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().length).isGreaterThan(0); + + // Check UTF-8 BOM (0xEF 0xBB 0xBF) + assertThat(response.getBody()[0]).isEqualTo((byte) 0xEF); + assertThat(response.getBody()[1]).isEqualTo((byte) 0xBB); + assertThat(response.getBody()[2]).isEqualTo((byte) 0xBF); + + // Verify CSV has separator content + String csvContent = new String(response.getBody(), java.nio.charset.StandardCharsets.UTF_8); + assertThat(csvContent).contains(";"); // German CSV uses semicolons + } + + @Test + @DisplayName("Recall report — returns affected members for a batch") + void recallReport_returnsAffectedMembers() { + MemberResponse member1 = createTestMember(adminToken, "Recall", "One", + "recall1@test.de", LocalDate.of(1990, 3, 15)); + MemberResponse member2 = createTestMember(adminToken, "Recall", "Two", + "recall2@test.de", LocalDate.of(1988, 7, 20)); + + Strain strain = createTestStrain("Recall Strain"); + BatchResponse batch = createTestBatch(adminToken, strain.getId(), + new BigDecimal("1000.0"), "BATCH-RECALL-001"); + + // Both members get distributions from this batch + restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(new CreateDistributionRequest( + member1.id(), batch.id(), new BigDecimal("3.0"), "recall test 1")) + .retrieve() + .toEntity(String.class); + + restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(new CreateDistributionRequest( + member2.id(), batch.id(), new BigDecimal("4.0"), "recall test 2")) + .retrieve() + .toEntity(String.class); + + // Generate recall report for the batch + ResponseEntity response = restClient().get() + .uri("/api/v1/reports/recall?batchId=" + batch.id() + "&format=json") + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody()).contains("affectedMembers"); + assertThat(response.getBody()).contains("Recall"); + } + + @Test + @DisplayName("Non-admin cannot access reports") + void nonAdmin_cannotAccessReports() { + ResponseEntity response = restClient().get() + .uri("/api/v1/reports/monthly?month=2026-01&format=json") + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isIn(401, 403); + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/integration/StaffPermissionIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/integration/StaffPermissionIntegrationTest.java new file mode 100644 index 0000000..2c41650 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/integration/StaffPermissionIntegrationTest.java @@ -0,0 +1,175 @@ +package de.cannamanage.api.integration; + +import de.cannamanage.api.dto.auth.LoginResponse; +import de.cannamanage.api.dto.auth.SetPasswordRequest; +import de.cannamanage.api.dto.staff.CreateStaffRequest; +import de.cannamanage.api.dto.staff.StaffResponse; +import de.cannamanage.api.dto.staff.UpdateStaffRequest; +import de.cannamanage.domain.entity.InviteToken; +import de.cannamanage.domain.enums.StaffPermission; +import de.cannamanage.service.repository.InviteTokenRepository; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test: Staff invite → activate → permission check flow. + */ +class StaffPermissionIntegrationTest extends AbstractIntegrationTest { + + @Autowired + private InviteTokenRepository inviteTokenRepository; + + private UUID tenantId; + private String adminToken; + + @BeforeEach + void setUp() { + tenantId = createTestClub("Permission Test Club"); + createAdminUser(tenantId, "perm-admin@test.de", "AdminPass123!"); + adminToken = getAccessToken("perm-admin@test.de", "AdminPass123!"); + } + + @Test + @DisplayName("Full staff lifecycle: invite → set-password → login → access endpoints") + void fullStaffLifecycle_inviteToAccess() { + // Step 1: Admin creates staff with RECORD_DISTRIBUTION permission + CreateStaffRequest createRequest = new CreateStaffRequest( + "staff1@test.de", "Staff One", + Set.of(StaffPermission.RECORD_DISTRIBUTION), null); + + ResponseEntity createResponse = restClient().post() + .uri("/api/v1/staff") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(createRequest) + .retrieve() + .toEntity(StaffResponse.class); + + assertThat(createResponse.getStatusCode().value()).isEqualTo(201); + assertThat(createResponse.getBody()).isNotNull(); + assertThat(createResponse.getBody().email()).isEqualTo("staff1@test.de"); + + // Step 2: Get the invite token from DB + List tokens = inviteTokenRepository.findAll(); + InviteToken inviteToken = tokens.stream() + .filter(t -> t.getUsedAt() == null) + .findFirst() + .orElseThrow(() -> new AssertionError("No invite token found")); + + // Step 3: Staff sets password via invite token + ResponseEntity setPwResponse = restClient().post() + .uri("/api/v1/auth/set-password") + .contentType(MediaType.APPLICATION_JSON) + .body(new SetPasswordRequest(inviteToken.getToken(), "StaffPass123!")) + .retrieve() + .toEntity(String.class); + + assertThat(setPwResponse.getStatusCode().value()).isEqualTo(200); + + // Step 4: Staff logs in + LoginResponse staffLogin = login("staff1@test.de", "StaffPass123!"); + assertThat(staffLogin.role()).isEqualTo("STAFF"); + String staffToken = staffLogin.accessToken(); + + // Step 5: Staff CAN access distributions endpoint (has RECORD_DISTRIBUTION) + ResponseEntity distResponse = restClient().get() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + staffToken) + .retrieve() + .toEntity(String.class); + + assertThat(distResponse.getStatusCode().value()).isEqualTo(200); + + // Step 6: Staff CANNOT access stock endpoint (no VIEW_STOCK permission) + ResponseEntity stockResponse = restClient().get() + .uri("/api/v1/stock/batches") + .header("Authorization", "Bearer " + staffToken) + .retrieve() + .toEntity(String.class); + + assertThat(stockResponse.getStatusCode().value()).isEqualTo(403); + } + + @Test + @DisplayName("Staff without VIEW_MEMBER_LIST cannot list members") + void staffWithoutViewMemberList_cannotListMembers() { + CreateStaffRequest createRequest = new CreateStaffRequest( + "staff2@test.de", "Staff Two", + Set.of(StaffPermission.RECORD_DISTRIBUTION), null); + + restClient().post() + .uri("/api/v1/staff") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(createRequest) + .retrieve() + .toEntity(StaffResponse.class); + + // Activate + InviteToken inviteToken = inviteTokenRepository.findAll().stream() + .filter(t -> t.getUsedAt() == null) + .findFirst().orElseThrow(); + + restClient().post() + .uri("/api/v1/auth/set-password") + .contentType(MediaType.APPLICATION_JSON) + .body(new SetPasswordRequest(inviteToken.getToken(), "StaffPass123!")) + .retrieve() + .toEntity(String.class); + + String staffToken = getAccessToken("staff2@test.de", "StaffPass123!"); + + // Try to list members — should be forbidden + ResponseEntity response = restClient().get() + .uri("/api/v1/members") + .header("Authorization", "Bearer " + staffToken) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(403); + } + + @Test + @DisplayName("Admin can update staff permissions") + void admin_canUpdateStaffPermissions() { + CreateStaffRequest createRequest = new CreateStaffRequest( + "staff3@test.de", "Staff Three", + Set.of(StaffPermission.RECORD_DISTRIBUTION), null); + + ResponseEntity createResp = restClient().post() + .uri("/api/v1/staff") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(createRequest) + .retrieve() + .toEntity(StaffResponse.class); + + UUID staffId = createResp.getBody().id(); + + // Update permissions to add VIEW_STOCK + UpdateStaffRequest updateRequest = new UpdateStaffRequest( + "Staff Three", + Set.of(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_STOCK), + null, true); + + ResponseEntity updateResp = restClient().put() + .uri("/api/v1/staff/" + staffId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(updateRequest) + .retrieve() + .toEntity(StaffResponse.class); + + assertThat(updateResp.getStatusCode().value()).isEqualTo(200); + assertThat(updateResp.getBody().permissions()) + .contains(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_STOCK); + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/integration/TenantIsolationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/integration/TenantIsolationTest.java new file mode 100644 index 0000000..cf7f75d --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/integration/TenantIsolationTest.java @@ -0,0 +1,135 @@ +package de.cannamanage.api.integration; + +import de.cannamanage.api.dto.distribution.CreateDistributionRequest; +import de.cannamanage.api.dto.member.MemberResponse; +import de.cannamanage.api.dto.stock.BatchResponse; +import org.junit.jupiter.api.*; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test: Multi-tenant data isolation. + * Verifies that Tenant A cannot see Tenant B's members (and vice versa). + */ +class TenantIsolationTest extends AbstractIntegrationTest { + + private UUID tenantA; + private UUID tenantB; + private String tokenA; + private String tokenB; + + @BeforeEach + void setUp() { + tenantA = createTestClub("Club Alpha"); + tenantB = createTestClub("Club Beta"); + + createAdminUser(tenantA, "admin-a@alpha.de", "AlphaPass123!"); + createAdminUser(tenantB, "admin-b@beta.de", "BetaPass123!"); + + tokenA = getAccessToken("admin-a@alpha.de", "AlphaPass123!"); + tokenB = getAccessToken("admin-b@beta.de", "BetaPass123!"); + } + + @Test + @DisplayName("Tenant A creates members — only visible to Tenant A") + void tenantA_createsMembers_onlyVisibleToTenantA() { + createTestMember(tokenA, "Anna", "Alpha", "anna@alpha.de", LocalDate.of(1990, 1, 15)); + createTestMember(tokenA, "Alex", "Alpha", "alex@alpha.de", LocalDate.of(1985, 6, 20)); + + ResponseEntity responseA = restClient().get() + .uri("/api/v1/members") + .header("Authorization", "Bearer " + tokenA) + .retrieve() + .toEntity(String.class); + + assertThat(responseA.getStatusCode().value()).isEqualTo(200); + assertThat(responseA.getBody()).contains("Anna"); + assertThat(responseA.getBody()).contains("Alex"); + } + + @Test + @DisplayName("Tenant B creates members — only visible to Tenant B") + void tenantB_createsMembers_onlyVisibleToTenantB() { + createTestMember(tokenB, "Bob", "Beta", "bob@beta.de", LocalDate.of(1992, 3, 10)); + + ResponseEntity responseB = restClient().get() + .uri("/api/v1/members") + .header("Authorization", "Bearer " + tokenB) + .retrieve() + .toEntity(String.class); + + assertThat(responseB.getStatusCode().value()).isEqualTo(200); + assertThat(responseB.getBody()).contains("Bob"); + } + + @Test + @DisplayName("Tenant A cannot see Tenant B's members") + void tenantA_cannotSeeTenantB_members() { + createTestMember(tokenA, "Anna", "Alpha", "anna2@alpha.de", LocalDate.of(1990, 1, 15)); + createTestMember(tokenB, "Bob", "Beta", "bob2@beta.de", LocalDate.of(1992, 3, 10)); + + ResponseEntity responseA = restClient().get() + .uri("/api/v1/members") + .header("Authorization", "Bearer " + tokenA) + .retrieve() + .toEntity(String.class); + + assertThat(responseA.getStatusCode().value()).isEqualTo(200); + assertThat(responseA.getBody()).contains("Anna"); + assertThat(responseA.getBody()).doesNotContain("Bob"); + } + + @Test + @DisplayName("Tenant B cannot see Tenant A's members") + void tenantB_cannotSeeTenantA_members() { + createTestMember(tokenA, "Anna", "Alpha", "anna3@alpha.de", LocalDate.of(1990, 1, 15)); + createTestMember(tokenB, "Bob", "Beta", "bob3@beta.de", LocalDate.of(1992, 3, 10)); + + ResponseEntity responseB = restClient().get() + .uri("/api/v1/members") + .header("Authorization", "Bearer " + tokenB) + .retrieve() + .toEntity(String.class); + + assertThat(responseB.getStatusCode().value()).isEqualTo(200); + assertThat(responseB.getBody()).contains("Bob"); + assertThat(responseB.getBody()).doesNotContain("Anna"); + } + + @Test + @DisplayName("Distributions are isolated between tenants") + void distributions_areIsolated_betweenTenants() { + MemberResponse memberA = createTestMember(tokenA, "Anna", "Alpha", + "anna4@alpha.de", LocalDate.of(1990, 1, 15)); + + BatchResponse batchA = createTestBatch(tokenA, UUID.randomUUID(), + new BigDecimal("100.0"), "BATCH-A-001"); + + // Create distribution for Tenant A + restClient().post() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + tokenA) + .contentType(MediaType.APPLICATION_JSON) + .body(new CreateDistributionRequest( + memberA.id(), batchA.id(), new BigDecimal("5.0"), "Test distribution A")) + .retrieve() + .toEntity(String.class); + + // Tenant B's distribution list should be empty + ResponseEntity responseB = restClient().get() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + tokenB) + .retrieve() + .toEntity(String.class); + + assertThat(responseB.getStatusCode().value()).isEqualTo(200); + assertThat(responseB.getBody()).isEqualTo("[]"); + } +} diff --git a/cannamanage-api/src/test/java/de/cannamanage/api/integration/TokenRevocationIntegrationTest.java b/cannamanage-api/src/test/java/de/cannamanage/api/integration/TokenRevocationIntegrationTest.java new file mode 100644 index 0000000..f077965 --- /dev/null +++ b/cannamanage-api/src/test/java/de/cannamanage/api/integration/TokenRevocationIntegrationTest.java @@ -0,0 +1,212 @@ +package de.cannamanage.api.integration; + +import de.cannamanage.api.dto.auth.LoginRequest; +import de.cannamanage.api.dto.auth.LoginResponse; +import de.cannamanage.api.dto.auth.SetPasswordRequest; +import de.cannamanage.api.dto.staff.CreateStaffRequest; +import de.cannamanage.api.dto.staff.StaffResponse; +import de.cannamanage.api.dto.staff.UpdateStaffRequest; +import de.cannamanage.domain.entity.InviteToken; +import de.cannamanage.domain.enums.StaffPermission; +import de.cannamanage.service.repository.InviteTokenRepository; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test: Token revocation E2E. + * Verifies that permission changes and deactivation properly revoke tokens. + */ +class TokenRevocationIntegrationTest extends AbstractIntegrationTest { + + @Autowired + private InviteTokenRepository inviteTokenRepository; + + private UUID tenantId; + private String adminToken; + + @BeforeEach + void setUp() { + tenantId = createTestClub("Token Revocation Club"); + createAdminUser(tenantId, "revoke-admin@test.de", "AdminPass123!"); + adminToken = getAccessToken("revoke-admin@test.de", "AdminPass123!"); + } + + @Test + @DisplayName("Admin changes staff permissions → old JWT is rejected") + void adminChangesPermissions_oldJwtRejected() { + // Create and activate a staff member + String staffEmail = "revoke-staff1@test.de"; + UUID staffId = createAndActivateStaff(staffEmail, + Set.of(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_STOCK)); + + // Staff logs in and gets a valid JWT + LoginResponse staffLogin = login(staffEmail, "StaffPass123!"); + String oldStaffToken = staffLogin.accessToken(); + + // Verify old token works + ResponseEntity beforeChange = restClient().get() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + oldStaffToken) + .retrieve() + .toEntity(String.class); + assertThat(beforeChange.getStatusCode().value()).isEqualTo(200); + + // Admin changes staff permissions (triggers revocation) + UpdateStaffRequest updateRequest = new UpdateStaffRequest( + "Revoke Staff 1", + Set.of(StaffPermission.RECORD_DISTRIBUTION), + null, true); + + restClient().put() + .uri("/api/v1/staff/" + staffId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(updateRequest) + .retrieve() + .toEntity(StaffResponse.class); + + // Old JWT should now be rejected (revoked) + ResponseEntity afterChange = restClient().get() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + oldStaffToken) + .retrieve() + .toEntity(String.class); + assertThat(afterChange.getStatusCode().value()).isIn(401, 403); + } + + @Test + @DisplayName("Staff logs in again after permission change → gets new working JWT") + void staffLoginsAgain_afterPermissionChange_getsWorkingJwt() { + String staffEmail = "revoke-staff2@test.de"; + UUID staffId = createAndActivateStaff(staffEmail, + Set.of(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_STOCK)); + + login(staffEmail, "StaffPass123!"); + + // Admin changes permissions + UpdateStaffRequest updateRequest = new UpdateStaffRequest( + "Revoke Staff 2", + Set.of(StaffPermission.RECORD_DISTRIBUTION, StaffPermission.VIEW_MEMBER_LIST), + null, true); + + restClient().put() + .uri("/api/v1/staff/" + staffId) + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(updateRequest) + .retrieve() + .toEntity(StaffResponse.class); + + // Staff logs in again — new JWT should work + LoginResponse newLogin = login(staffEmail, "StaffPass123!"); + String newToken = newLogin.accessToken(); + + ResponseEntity distResponse = restClient().get() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + newToken) + .retrieve() + .toEntity(String.class); + assertThat(distResponse.getStatusCode().value()).isEqualTo(200); + } + + @Test + @DisplayName("Admin deactivates staff → all tokens revoked → 401") + void adminDeactivatesStaff_allTokensRevoked() { + String staffEmail = "revoke-staff3@test.de"; + UUID staffId = createAndActivateStaff(staffEmail, + Set.of(StaffPermission.RECORD_DISTRIBUTION)); + + LoginResponse staffLogin = login(staffEmail, "StaffPass123!"); + String staffToken = staffLogin.accessToken(); + + // Verify token works before deactivation + ResponseEntity before = restClient().get() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + staffToken) + .retrieve() + .toEntity(String.class); + assertThat(before.getStatusCode().value()).isEqualTo(200); + + // Admin deactivates staff + restClient().delete() + .uri("/api/v1/staff/" + staffId) + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(Void.class); + + // Old token should now be rejected + ResponseEntity after = restClient().get() + .uri("/api/v1/distributions") + .header("Authorization", "Bearer " + staffToken) + .retrieve() + .toEntity(String.class); + assertThat(after.getStatusCode().value()).isIn(401, 403); + } + + @Test + @DisplayName("Deactivated staff cannot login") + void deactivatedStaff_cannotLogin() { + String staffEmail = "revoke-staff4@test.de"; + UUID staffId = createAndActivateStaff(staffEmail, + Set.of(StaffPermission.RECORD_DISTRIBUTION)); + + // Deactivate + restClient().delete() + .uri("/api/v1/staff/" + staffId) + .header("Authorization", "Bearer " + adminToken) + .retrieve() + .toEntity(Void.class); + + // Try to login — should fail + ResponseEntity response = restClient().post() + .uri("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .body(new LoginRequest(staffEmail, "StaffPass123!")) + .retrieve() + .toEntity(String.class); + + assertThat(response.getStatusCode().value()).isEqualTo(401); + } + + // --- Helper --- + + private UUID createAndActivateStaff(String email, Set permissions) { + CreateStaffRequest createRequest = new CreateStaffRequest( + email, "Staff " + email.split("@")[0], permissions, null); + + ResponseEntity createResp = restClient().post() + .uri("/api/v1/staff") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .body(createRequest) + .retrieve() + .toEntity(StaffResponse.class); + + UUID staffId = createResp.getBody().id(); + + // Find the invite token + List tokens = inviteTokenRepository.findAll(); + InviteToken inviteToken = tokens.stream() + .filter(t -> t.getUsedAt() == null) + .reduce((first, second) -> second) + .orElseThrow(() -> new AssertionError("No invite token found")); + + // Set password to activate + restClient().post() + .uri("/api/v1/auth/set-password") + .contentType(MediaType.APPLICATION_JSON) + .body(new SetPasswordRequest(inviteToken.getToken(), "StaffPass123!")) + .retrieve() + .toEntity(String.class); + + return staffId; + } +} diff --git a/cannamanage-api/src/test/resources/application-integration.properties b/cannamanage-api/src/test/resources/application-integration.properties new file mode 100644 index 0000000..adf3ed0 --- /dev/null +++ b/cannamanage-api/src/test/resources/application-integration.properties @@ -0,0 +1,31 @@ +# Integration test profile — Testcontainers PostgreSQL (properties injected via @DynamicPropertySource) +spring.application.name=cannamanage-integration-test + +# Flyway enabled — runs V1-V5 migrations against real PostgreSQL +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration + +# JPA +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.open-in-view=false +spring.jpa.show-sql=false + +# JWT test secret (same as application.properties) +cannamanage.security.jwt.secret=Y2FubmFtYW5hZ2Utand0LXNlY3JldC1rZXktZm9yLWRldmVsb3BtZW50LW9ubHktMzI= +cannamanage.security.jwt.access-token-expiry=3600 +cannamanage.security.jwt.refresh-token-expiry=2592000 + +# AOP for TenantFilterAspect +spring.aop.auto=true +spring.aop.proxy-target-class=true + +# Disable mail sending in integration tests +spring.mail.host=localhost +spring.mail.port=9999 +spring.mail.properties.mail.smtp.auth=false + +# App base URL +app.base-url=http://localhost:8080 + +# Session +server.servlet.session.timeout=30m