feat(sprint-3): Phase 7 — integration tests (Testcontainers PostgreSQL)
- 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
This commit is contained in:
-112
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+195
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -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<String> 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<LoginResponse> 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<String> 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<String> 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<String> 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<String> 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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+126
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+187
@@ -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<String> 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<byte[]> 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<byte[]> 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<String> 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<String> response = restClient().get()
|
||||||
|
.uri("/api/v1/reports/monthly?month=2026-01&format=json")
|
||||||
|
.retrieve()
|
||||||
|
.toEntity(String.class);
|
||||||
|
|
||||||
|
assertThat(response.getStatusCode().value()).isIn(401, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
+175
@@ -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<StaffResponse> 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<InviteToken> 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<String> 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<String> 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<String> 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<String> 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<StaffResponse> 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<StaffResponse> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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("[]");
|
||||||
|
}
|
||||||
|
}
|
||||||
+212
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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<StaffPermission> permissions) {
|
||||||
|
CreateStaffRequest createRequest = new CreateStaffRequest(
|
||||||
|
email, "Staff " + email.split("@")[0], permissions, null);
|
||||||
|
|
||||||
|
ResponseEntity<StaffResponse> 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<InviteToken> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user