feat: Sprint 11 test coverage — +166 unit tests, schema drift fix (V34), Testcontainers 1.21.3
Deploy to TrueNAS / deploy (push) Failing after 2m11s
Deploy to TrueNAS / deploy (push) Failing after 2m11s
Phase 2: AssemblyServiceTest (22), EventServiceTest (13), ForumServiceTest (14), InfoBoardServiceTest (10) Phase 3: Camt053ParserTest (19), CsvBankParserTest (14), BankImportServiceTest (14), BankStatementParserServiceTest (9) Phase 4: JwtServiceTest (17), LoginRateLimiterTest (8), TenantFilterAspectTest (8), DocumentServiceTest (12), GlobalExceptionHandlerTest (6) Phase 5: V34 schema drift fix migration, MigrationIntegrationTest + AbstractIntegrationTest fixes Infrastructure: V27 fix (added timestamps), Testcontainers upgrade 1.20.4 -> 1.21.3, test resources (bankimport samples)
This commit is contained in:
@@ -10,7 +10,9 @@ CREATE TABLE generated_reports (
|
||||
storage_path VARCHAR(500),
|
||||
parameters JSONB,
|
||||
generated_by UUID NOT NULL REFERENCES users(id),
|
||||
generated_at TIMESTAMP DEFAULT NOW()
|
||||
generated_at TIMESTAMP DEFAULT NOW(),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_generated_reports_tenant ON generated_reports(tenant_id);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Fix schema drift: members table is missing columns that the JPA Member entity expects.
|
||||
-- user_id: links member to their login user account (nullable, set on portal registration)
|
||||
-- iban: member's IBAN for bank statement matching (Sprint 10, nullable, consent-gated)
|
||||
-- iban_consent_date: timestamp when BANK_DATA consent was granted
|
||||
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS user_id UUID;
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS iban VARCHAR(34);
|
||||
ALTER TABLE members ADD COLUMN IF NOT EXISTS iban_consent_date TIMESTAMPTZ;
|
||||
|
||||
-- Index for user_id lookups (portal login → member resolution)
|
||||
CREATE INDEX IF NOT EXISTS idx_members_user_id ON members(user_id);
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package de.cannamanage.api.exception;
|
||||
|
||||
import de.cannamanage.service.exception.QuotaExceededException;
|
||||
import de.cannamanage.service.exception.QuotaViolationCode;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ProblemDetail;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.validation.BeanPropertyBindingResult;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.core.MethodParameter;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link GlobalExceptionHandler} verifying RFC 9457 ProblemDetail
|
||||
* responses and ensuring no internal details (stack traces, paths) are leaked.
|
||||
*/
|
||||
class GlobalExceptionHandlerTest {
|
||||
|
||||
private GlobalExceptionHandler handler;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
handler = new GlobalExceptionHandler();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleValidation_returnsStatus400WithFieldErrors() throws Exception {
|
||||
// Simulate a validation failure with field errors
|
||||
BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "request");
|
||||
bindingResult.addError(new FieldError("request", "email", "must not be blank"));
|
||||
bindingResult.addError(new FieldError("request", "name", "size must be between 2 and 100"));
|
||||
|
||||
// MethodParameter is needed for MethodArgumentNotValidException constructor
|
||||
MethodParameter param = new MethodParameter(
|
||||
this.getClass().getDeclaredMethod("setUp"), -1);
|
||||
MethodArgumentNotValidException ex = new MethodArgumentNotValidException(param, bindingResult);
|
||||
|
||||
ProblemDetail problem = handler.handleValidation(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Bad Request");
|
||||
assertThat(problem.getType().toString()).contains("VALIDATION_FAILED");
|
||||
assertThat(problem.getProperties()).containsKey("errors");
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> errors = (List<String>) problem.getProperties().get("errors");
|
||||
assertThat(errors).hasSize(2);
|
||||
assertThat(errors).anyMatch(e -> e.contains("email"));
|
||||
assertThat(errors).anyMatch(e -> e.contains("name"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleAccessDenied_returnsStatus403WithNoStackTrace() {
|
||||
AccessDeniedException ex = new AccessDeniedException("You shall not pass");
|
||||
|
||||
ProblemDetail problem = handler.handleAccessDenied(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.FORBIDDEN.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Forbidden");
|
||||
assertThat(problem.getDetail()).isEqualTo("Access denied");
|
||||
// SECURITY: original exception message NOT exposed
|
||||
assertThat(problem.getDetail()).doesNotContain("shall not pass");
|
||||
// SECURITY: no stack trace or internal paths
|
||||
assertThat(problem.getProperties()).doesNotContainKey("stackTrace");
|
||||
assertThat(problem.getProperties()).doesNotContainKey("trace");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleGenericException_returnsStatus500WithGenericMessage() {
|
||||
RuntimeException ex = new RuntimeException(
|
||||
"NullPointerException at com.internal.Service.process(Service.java:42)");
|
||||
|
||||
ProblemDetail problem = handler.handleGeneric(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Internal Server Error");
|
||||
assertThat(problem.getDetail()).isEqualTo("An unexpected error occurred");
|
||||
// SECURITY: internal details NOT leaked
|
||||
assertThat(problem.getDetail()).doesNotContain("NullPointerException");
|
||||
assertThat(problem.getDetail()).doesNotContain("Service.java");
|
||||
assertThat(problem.getDetail()).doesNotContain("com.internal");
|
||||
assertThat(problem.getProperties().get("code")).isEqualTo("INTERNAL_ERROR");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleQuotaExceeded_returnsStatus409WithCode() {
|
||||
QuotaExceededException ex = new QuotaExceededException(
|
||||
QuotaViolationCode.MEMBER_INACTIVE, "Member is inactive");
|
||||
|
||||
ProblemDetail problem = handler.handleQuotaExceeded(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.CONFLICT.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Compliance Violation");
|
||||
assertThat(problem.getDetail()).isEqualTo("Member is inactive");
|
||||
assertThat(problem.getProperties().get("code")).isEqualTo("MEMBER_INACTIVE");
|
||||
assertThat(problem.getProperties()).containsKey("timestamp");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testHandleMemberNotFound_returnsStatus404WithRfc9457Body() {
|
||||
var ex = new de.cannamanage.service.exception.MemberNotFoundException(UUID.randomUUID());
|
||||
|
||||
ProblemDetail problem = handler.handleMemberNotFound(ex);
|
||||
|
||||
assertThat(problem.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value());
|
||||
assertThat(problem.getTitle()).isEqualTo("Not Found");
|
||||
assertThat(problem.getType().toString()).contains("MEMBER_NOT_FOUND");
|
||||
assertThat(problem.getProperties().get("code")).isEqualTo("MEMBER_NOT_FOUND");
|
||||
assertThat(problem.getProperties()).containsKey("timestamp");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAllHandlers_includeTimestamp_neverExposeInternalState() {
|
||||
// Verify that all handlers set the timestamp property
|
||||
ProblemDetail p1 = handler.handleAccessDenied(new AccessDeniedException("x"));
|
||||
ProblemDetail p2 = handler.handleGeneric(new RuntimeException("internal error details"));
|
||||
ProblemDetail p3 = handler.handleQuotaExceeded(
|
||||
new QuotaExceededException(QuotaViolationCode.MEMBER_INACTIVE, "msg"));
|
||||
|
||||
assertThat(p1.getProperties()).containsKey("timestamp");
|
||||
assertThat(p2.getProperties()).containsKey("timestamp");
|
||||
assertThat(p3.getProperties()).containsKey("timestamp");
|
||||
|
||||
// None should expose stack traces or class paths
|
||||
for (ProblemDetail p : List.of(p1, p2, p3)) {
|
||||
assertThat(p.getProperties()).doesNotContainKey("stackTrace");
|
||||
assertThat(p.getProperties()).doesNotContainKey("exception");
|
||||
if (p.getDetail() != null) {
|
||||
assertThat(p.getDetail()).doesNotContain(".java:");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -8,6 +8,7 @@ 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.TenantContext;
|
||||
import de.cannamanage.domain.entity.User;
|
||||
import de.cannamanage.domain.enums.ClubStatus;
|
||||
import de.cannamanage.domain.enums.UserRole;
|
||||
@@ -105,16 +106,23 @@ public abstract class AbstractIntegrationTest {
|
||||
// --- Test data creation helpers ---
|
||||
|
||||
/**
|
||||
* Creates a club (tenant) and returns its ID.
|
||||
* Creates a club (tenant) and returns its tenant ID.
|
||||
* IMPORTANT: Sets TenantContext for all subsequent entity creation.
|
||||
* The returned UUID is the tenantId (same value used for all entities).
|
||||
*/
|
||||
protected UUID createTestClub(String name) {
|
||||
// Pre-generate the tenant UUID — all entities will share this
|
||||
UUID tenantId = UUID.randomUUID();
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Club club = new Club();
|
||||
club.setName(name);
|
||||
club.setLicenseNumber("LIC-" + UUID.randomUUID().toString().substring(0, 8));
|
||||
club.setStatus(ClubStatus.ACTIVE);
|
||||
club.setMaxMembers(500);
|
||||
club.setMaxPreventionOfficers(3);
|
||||
club = clubRepository.save(club);
|
||||
return club.getId();
|
||||
// TenantContext remains set — @PrePersist will use it for subsequent entities
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+406
@@ -0,0 +1,406 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import de.cannamanage.api.controller.AssemblyController;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.AgendaItemType;
|
||||
import de.cannamanage.domain.enums.AssemblyType;
|
||||
import de.cannamanage.domain.enums.VoteDecision;
|
||||
import de.cannamanage.domain.enums.VoteType;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test verifying the full assembly (Mitgliederversammlung) lifecycle end-to-end.
|
||||
* Tests creation, quorum enforcement, voting with majority thresholds, and protocol generation.
|
||||
*/
|
||||
class AssemblyLifecycleIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private UUID tenantId;
|
||||
private String adminToken;
|
||||
private UUID member1Id;
|
||||
private UUID member2Id;
|
||||
private UUID member3Id;
|
||||
|
||||
private static final String ADMIN_EMAIL = "asm-admin@test.de";
|
||||
private static final String ADMIN_PASSWORD = "AdminPass123!";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = createTestClub("Assembly Test Club");
|
||||
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
adminToken = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
|
||||
// Create 3 members for quorum and voting tests
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member m1 = createMemberDirectly(tenantId, "Alice", "Meier", "alice@test.de", LocalDate.of(1990, 1, 1));
|
||||
Member m2 = createMemberDirectly(tenantId, "Bob", "Schmidt", "bob@test.de", LocalDate.of(1985, 6, 15));
|
||||
Member m3 = createMemberDirectly(tenantId, "Clara", "Weber", "clara@test.de", LocalDate.of(1992, 9, 30));
|
||||
member1Id = m1.getId();
|
||||
member2Id = m2.getId();
|
||||
member3Id = m3.getId();
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Full assembly lifecycle: Create → Add agenda → Start → Vote → Complete")
|
||||
void testFullLifecycle_CreateStartVoteComplete() {
|
||||
// Step 1: Create assembly
|
||||
Instant scheduledAt = Instant.now().plus(1, ChronoUnit.HOURS);
|
||||
Map<String, Object> createRequest = Map.of(
|
||||
"title", "Ordentliche Mitgliederversammlung 2026",
|
||||
"assemblyType", "REGULAR",
|
||||
"scheduledAt", scheduledAt.toString(),
|
||||
"location", "Vereinsheim",
|
||||
"quorumRequired", 2,
|
||||
"agendaItems", List.of(
|
||||
Map.of("title", "Kassenbericht", "description", "Bericht des Schatzmeisters", "itemType", "DISCUSSION"),
|
||||
Map.of("title", "Vorstandswahl", "description", "Neuwahl des Vorstands", "itemType", "VOTE")
|
||||
)
|
||||
);
|
||||
|
||||
ResponseEntity<String> createResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(createRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(createResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(createResponse.getBody()).contains("Ordentliche Mitgliederversammlung 2026");
|
||||
|
||||
// Extract assembly ID from response
|
||||
String assemblyId = extractId(createResponse.getBody());
|
||||
assertThat(assemblyId).isNotNull();
|
||||
|
||||
// Step 2: Check in attendees (quorum = 2, we check in 2 members)
|
||||
checkInAttendee(assemblyId, member1Id);
|
||||
checkInAttendee(assemblyId, member2Id);
|
||||
|
||||
// Step 3: Start assembly (quorum met with 2 attendees)
|
||||
ResponseEntity<String> startResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/start")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(startResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(startResponse.getBody()).contains("IN_PROGRESS");
|
||||
|
||||
// Step 4: Create a vote on the second agenda item
|
||||
// First get assembly detail to find agenda item IDs
|
||||
ResponseEntity<String> detailResponse = restClient().get()
|
||||
.uri("/api/v1/assemblies/" + assemblyId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
assertThat(detailResponse.getStatusCode().value()).isEqualTo(200);
|
||||
|
||||
String agendaItemId = extractSecondAgendaItemId(detailResponse.getBody());
|
||||
assertThat(agendaItemId).isNotNull();
|
||||
|
||||
Map<String, Object> voteRequest = Map.of(
|
||||
"agendaItemId", agendaItemId,
|
||||
"title", "Vorstandswahl Abstimmung",
|
||||
"description", "Wahl des neuen Vorstands",
|
||||
"voteType", "SIMPLE_MAJORITY"
|
||||
);
|
||||
|
||||
ResponseEntity<String> voteCreateResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/votes")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(voteRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(voteCreateResponse.getStatusCode().value()).isEqualTo(200);
|
||||
String voteId = extractId(voteCreateResponse.getBody());
|
||||
|
||||
// Step 5: Cast votes — both members vote YES (simple majority passes)
|
||||
castVote(voteId, member1Id, "YES");
|
||||
castVote(voteId, member2Id, "YES");
|
||||
|
||||
// Step 6: Close vote
|
||||
ResponseEntity<String> closeVoteResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/votes/" + voteId + "/close")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(closeVoteResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(closeVoteResponse.getBody()).contains("PASSED");
|
||||
|
||||
// Step 7: Complete assembly
|
||||
ResponseEntity<String> completeResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/complete")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(completeResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(completeResponse.getBody()).contains("COMPLETED");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Quorum check: not enough attendees — cannot start")
|
||||
void testQuorumCheck_InsufficientAttendees_CannotStart() {
|
||||
// Create assembly requiring quorum of 3
|
||||
Map<String, Object> createRequest = Map.of(
|
||||
"title", "Quorum Test Assembly",
|
||||
"assemblyType", "REGULAR",
|
||||
"scheduledAt", Instant.now().plus(1, ChronoUnit.HOURS).toString(),
|
||||
"location", "Online",
|
||||
"quorumRequired", 3
|
||||
);
|
||||
|
||||
ResponseEntity<String> createResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(createRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
String assemblyId = extractId(createResponse.getBody());
|
||||
|
||||
// Check in only 2 members (quorum needs 3)
|
||||
checkInAttendee(assemblyId, member1Id);
|
||||
checkInAttendee(assemblyId, member2Id);
|
||||
|
||||
// Try to start — should fail due to quorum
|
||||
ResponseEntity<String> startResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/start")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Expect failure — quorum not met
|
||||
assertThat(startResponse.getStatusCode().value()).isIn(400, 422, 409);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Extraordinary assembly creation succeeds")
|
||||
void testExtraordinaryAssembly_CreationSucceeds() {
|
||||
Map<String, Object> createRequest = Map.of(
|
||||
"title", "Außerordentliche Versammlung",
|
||||
"assemblyType", "EXTRAORDINARY",
|
||||
"scheduledAt", Instant.now().plus(2, ChronoUnit.DAYS).toString(),
|
||||
"location", "Vereinsheim",
|
||||
"quorumRequired", 2
|
||||
);
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/assemblies")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(createRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(response.getBody()).contains("EXTRAORDINARY");
|
||||
assertThat(response.getBody()).contains("Außerordentliche Versammlung");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Vote with SIMPLE_MAJORITY: exact threshold (50%+1) passes")
|
||||
void testVote_SimpleMajority_ExactThreshold_Passes() {
|
||||
// Create and start assembly with 3 attendees
|
||||
String assemblyId = createAndStartAssemblyWith3Attendees();
|
||||
|
||||
// Get first agenda item ID
|
||||
ResponseEntity<String> detailResponse = restClient().get()
|
||||
.uri("/api/v1/assemblies/" + assemblyId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
String agendaItemId = extractFirstAgendaItemId(detailResponse.getBody());
|
||||
|
||||
// Create vote
|
||||
Map<String, Object> voteRequest = Map.of(
|
||||
"agendaItemId", agendaItemId,
|
||||
"title", "Majority Test",
|
||||
"description", "Testing exact majority threshold",
|
||||
"voteType", "SIMPLE_MAJORITY"
|
||||
);
|
||||
|
||||
ResponseEntity<String> voteResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/votes")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(voteRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
String voteId = extractId(voteResponse.getBody());
|
||||
|
||||
// Cast votes: 2 YES, 1 NO — 2/3 > 50% → should pass
|
||||
castVote(voteId, member1Id, "YES");
|
||||
castVote(voteId, member2Id, "YES");
|
||||
castVote(voteId, member3Id, "NO");
|
||||
|
||||
// Close vote
|
||||
ResponseEntity<String> closeResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies/votes/" + voteId + "/close")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(closeResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(closeResponse.getBody()).contains("PASSED");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Archive assembly generates protocol document (PDF downloadable)")
|
||||
void testComplete_GeneratesProtocol_Downloadable() {
|
||||
// Create, start, and complete assembly
|
||||
String assemblyId = createAndStartAssemblyWith3Attendees();
|
||||
|
||||
// Complete the assembly
|
||||
restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/complete")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Download protocol PDF
|
||||
ResponseEntity<byte[]> protocolResponse = restClient().get()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/protocol")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(byte[].class);
|
||||
|
||||
assertThat(protocolResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(protocolResponse.getHeaders().getContentType())
|
||||
.isEqualTo(MediaType.APPLICATION_PDF);
|
||||
assertThat(protocolResponse.getBody()).isNotNull();
|
||||
assertThat(protocolResponse.getBody().length).isGreaterThan(0);
|
||||
}
|
||||
|
||||
// === Helper methods ===
|
||||
|
||||
private void checkInAttendee(String assemblyId, UUID memberId) {
|
||||
Map<String, Object> request = Map.of("memberId", memberId.toString());
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/attendees")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
private void castVote(String voteId, UUID memberId, String decision) {
|
||||
Map<String, Object> request = Map.of(
|
||||
"memberId", memberId.toString(),
|
||||
"decision", decision
|
||||
);
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/assemblies/votes/" + voteId + "/cast")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
private String createAndStartAssemblyWith3Attendees() {
|
||||
Map<String, Object> createRequest = Map.of(
|
||||
"title", "Test Assembly",
|
||||
"assemblyType", "REGULAR",
|
||||
"scheduledAt", Instant.now().plus(1, ChronoUnit.HOURS).toString(),
|
||||
"location", "Online",
|
||||
"quorumRequired", 2,
|
||||
"agendaItems", List.of(
|
||||
Map.of("title", "Tagesordnungspunkt 1", "description", "Test", "itemType", "VOTE")
|
||||
)
|
||||
);
|
||||
|
||||
ResponseEntity<String> createResponse = restClient().post()
|
||||
.uri("/api/v1/assemblies")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(createRequest)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
String assemblyId = extractId(createResponse.getBody());
|
||||
|
||||
// Check in 3 attendees
|
||||
checkInAttendee(assemblyId, member1Id);
|
||||
checkInAttendee(assemblyId, member2Id);
|
||||
checkInAttendee(assemblyId, member3Id);
|
||||
|
||||
// Start assembly
|
||||
restClient().post()
|
||||
.uri("/api/v1/assemblies/" + assemblyId + "/start")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
return assemblyId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the "id" field value from a JSON response body.
|
||||
* Simple regex extraction to avoid Jackson dependency in test.
|
||||
*/
|
||||
private String extractId(String jsonBody) {
|
||||
if (jsonBody == null) return null;
|
||||
var pattern = java.util.regex.Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
var matcher = pattern.matcher(jsonBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the second agenda item's ID from the assembly detail response.
|
||||
*/
|
||||
private String extractSecondAgendaItemId(String jsonBody) {
|
||||
if (jsonBody == null) return null;
|
||||
var pattern = java.util.regex.Pattern.compile("\"agendaItems\"\\s*:\\s*\\[.*?\\{[^}]*\"id\"\\s*:\\s*\"([^\"]+)\"[^}]*\\}\\s*,\\s*\\{[^}]*\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
var matcher = pattern.matcher(jsonBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(2);
|
||||
}
|
||||
// Fallback: try to get any agenda item ID
|
||||
return extractFirstAgendaItemId(jsonBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the first agenda item's ID from the assembly detail response.
|
||||
*/
|
||||
private String extractFirstAgendaItemId(String jsonBody) {
|
||||
if (jsonBody == null) return null;
|
||||
// Look for agendaItems array and extract first ID
|
||||
var pattern = java.util.regex.Pattern.compile("\"agendaItems\"\\s*:\\s*\\[\\s*\\{[^}]*\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
var matcher = pattern.matcher(jsonBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test verifying the full bank import lifecycle end-to-end.
|
||||
* Tests: upload MT940 → parse → auto-match → confirm → complete,
|
||||
* duplicate file detection, and session abandonment.
|
||||
*/
|
||||
class BankImportLifecycleIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private UUID tenantId;
|
||||
private String adminToken;
|
||||
private UUID memberId;
|
||||
|
||||
private static final String ADMIN_EMAIL = "bank-admin@test.de";
|
||||
private static final String ADMIN_PASSWORD = "AdminPass123!";
|
||||
|
||||
/**
|
||||
* Minimal MT940 statement for testing. Contains one transaction
|
||||
* that can be auto-matched by name/IBAN.
|
||||
*/
|
||||
private static final String SAMPLE_MT940 = """
|
||||
:20:STARTUM
|
||||
:25:10010010/1234567890
|
||||
:28C:0
|
||||
:60F:C260101EUR1000,00
|
||||
:61:2601010101CR50,00N051NONREF
|
||||
:86:116?00GUTSCHRIFT?20Mitgliedsbeitrag?21Januar 2026?32MEIER ALICE?30TESTDE00?31DE89370400440532013000
|
||||
:62F:C260101EUR1050,00
|
||||
-
|
||||
""";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = createTestClub("Bank Import Test Club");
|
||||
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
adminToken = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
|
||||
// Create a member with IBAN for auto-matching
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member member = createMemberDirectly(tenantId, "Alice", "Meier",
|
||||
"alice-bank@test.de", LocalDate.of(1990, 5, 20));
|
||||
member.setIban("DE89370400440532013000");
|
||||
member.setIbanConsentDate(Instant.now());
|
||||
memberRepository.save(member);
|
||||
memberId = member.getId();
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Full flow: Upload MT940 → parse → confirm matches → complete")
|
||||
void testFullFlow_UploadMt940_MatchConfirmComplete() {
|
||||
// Step 1: Upload MT940 file
|
||||
String sessionId = uploadMt940(SAMPLE_MT940, "statement_jan.mt940");
|
||||
assertThat(sessionId).isNotNull();
|
||||
|
||||
// Step 2: Get session detail — should be OPEN
|
||||
ResponseEntity<String> sessionResponse = restClient().get()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId)
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(sessionResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(sessionResponse.getBody()).contains(sessionId);
|
||||
|
||||
// Step 3: List transactions
|
||||
ResponseEntity<String> txnResponse = restClient().get()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/transactions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(txnResponse.getStatusCode().value()).isEqualTo(200);
|
||||
|
||||
// Step 4: Confirm all matched transactions
|
||||
ResponseEntity<String> confirmAllResponse = restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/confirm-all")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(confirmAllResponse.getStatusCode().value()).isEqualTo(200);
|
||||
|
||||
// Step 5: Complete the session (GoBD seal)
|
||||
ResponseEntity<String> completeResponse = restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/complete")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(completeResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(completeResponse.getBody()).contains("COMPLETED");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Duplicate file (same SHA-256 hash) rejected on second upload")
|
||||
void testDuplicateUpload_SameFile_Rejected() {
|
||||
// First upload — should succeed
|
||||
String sessionId = uploadMt940(SAMPLE_MT940, "duplicate_test.mt940");
|
||||
assertThat(sessionId).isNotNull();
|
||||
|
||||
// Second upload of same content — should be rejected
|
||||
ResponseEntity<String> duplicateResponse = uploadMt940Raw(SAMPLE_MT940, "duplicate_test_copy.mt940");
|
||||
|
||||
assertThat(duplicateResponse.getStatusCode().value()).isIn(409, 400, 422);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unmatched transactions remain in PENDING status")
|
||||
void testUnmatchedTransactions_RemainPending() {
|
||||
// MT940 with a transaction that won't match any member's IBAN
|
||||
String unmatchedMt940 = """
|
||||
:20:STARTUM
|
||||
:25:10010010/1234567890
|
||||
:28C:0
|
||||
:60F:C260101EUR1000,00
|
||||
:61:2601010101CR75,00N051NONREF
|
||||
:86:116?00GUTSCHRIFT?20Unbekannte Zahlung?21Ref XYZ?32UNBEKANNT PERSON?30NOBANK00?31DE00000000000000000000
|
||||
:62F:C260101EUR1075,00
|
||||
-
|
||||
""";
|
||||
|
||||
String sessionId = uploadMt940(unmatchedMt940, "unmatched_test.mt940");
|
||||
assertThat(sessionId).isNotNull();
|
||||
|
||||
// Get transactions filtered by PENDING/UNMATCHED status
|
||||
ResponseEntity<String> pendingResponse = restClient().get()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/transactions?status=PENDING")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(pendingResponse.getStatusCode().value()).isEqualTo(200);
|
||||
// Should contain at least one transaction (the unmatched one)
|
||||
assertThat(pendingResponse.getBody()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Completed session is immutable — cannot be modified")
|
||||
void testImmutability_CompleteSessionCannotBeModified() {
|
||||
// Upload and complete a session
|
||||
String sessionId = uploadMt940(SAMPLE_MT940 + " ", "immutable_test.mt940");
|
||||
assertThat(sessionId).isNotNull();
|
||||
|
||||
// Complete the session
|
||||
restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/complete")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Try to confirm-all on completed session — should fail (GoBD immutability)
|
||||
ResponseEntity<String> confirmAfterComplete = restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions/" + sessionId + "/confirm-all")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(confirmAfterComplete.getStatusCode().value()).isIn(400, 409, 422);
|
||||
}
|
||||
|
||||
// === Helper methods ===
|
||||
|
||||
/**
|
||||
* Uploads an MT940 file and returns the session ID from the response.
|
||||
*/
|
||||
private String uploadMt940(String content, String filename) {
|
||||
ResponseEntity<String> response = uploadMt940Raw(content, filename);
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(201);
|
||||
return extractId(response.getBody());
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an MT940 file and returns the raw ResponseEntity for assertion.
|
||||
* Uses multipart/form-data upload matching the controller's @RequestParam("file").
|
||||
*/
|
||||
private ResponseEntity<String> uploadMt940Raw(String content, String filename) {
|
||||
byte[] fileBytes = content.getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
// Use RestClient with multipart — manual boundary construction
|
||||
String boundary = "----TestBoundary" + UUID.randomUUID().toString().replace("-", "");
|
||||
String body = "--" + boundary + "\r\n"
|
||||
+ "Content-Disposition: form-data; name=\"file\"; filename=\"" + filename + "\"\r\n"
|
||||
+ "Content-Type: application/octet-stream\r\n\r\n"
|
||||
+ content + "\r\n"
|
||||
+ "--" + boundary + "--\r\n";
|
||||
|
||||
return restClient().post()
|
||||
.uri("/api/v1/finance/import/sessions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.header(HttpHeaders.CONTENT_TYPE, "multipart/form-data; boundary=" + boundary)
|
||||
.body(body.getBytes(StandardCharsets.UTF_8))
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the "id" field value from a JSON response body.
|
||||
*/
|
||||
private String extractId(String jsonBody) {
|
||||
if (jsonBody == null) return null;
|
||||
var pattern = java.util.regex.Pattern.compile("\"id\"\\s*:\\s*\"([^\"]+)\"");
|
||||
var matcher = pattern.matcher(jsonBody);
|
||||
if (matcher.find()) {
|
||||
return matcher.group(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+309
@@ -0,0 +1,309 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import de.cannamanage.api.dto.distribution.CreateDistributionRequest;
|
||||
import de.cannamanage.api.dto.distribution.DistributionResponse;
|
||||
import de.cannamanage.domain.entity.Batch;
|
||||
import de.cannamanage.domain.entity.Member;
|
||||
import de.cannamanage.domain.entity.Strain;
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import de.cannamanage.domain.enums.BatchStatus;
|
||||
import de.cannamanage.domain.enums.MemberStatus;
|
||||
import de.cannamanage.service.repository.BatchRepository;
|
||||
import de.cannamanage.service.repository.StrainRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test verifying the full distribution lifecycle end-to-end.
|
||||
* Tests CanG §19 compliance checks (daily/monthly quotas, U21 THC limits, inactive member rejection).
|
||||
*/
|
||||
class DistributionLifecycleIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private StrainRepository strainRepository;
|
||||
|
||||
@Autowired
|
||||
private BatchRepository batchRepository;
|
||||
|
||||
private UUID tenantId;
|
||||
private String adminToken;
|
||||
private UUID memberId;
|
||||
private UUID batchId;
|
||||
|
||||
private static final String ADMIN_EMAIL = "dist-admin@test.de";
|
||||
private static final String ADMIN_PASSWORD = "AdminPass123!";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = createTestClub("Distribution Test Club");
|
||||
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
adminToken = getAccessToken(ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
|
||||
// Create an active member (adult, born 1990)
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member member = createMemberDirectly(tenantId, "Max", "Muster",
|
||||
"max@test.de", LocalDate.of(1990, 1, 15));
|
||||
memberId = member.getId();
|
||||
|
||||
// Create a strain + batch with stock
|
||||
Strain strain = new Strain();
|
||||
strain.setTenantId(tenantId);
|
||||
strain.setName("Test Strain");
|
||||
strain.setThcPercentage(new BigDecimal("15.0"));
|
||||
strain.setCbdPercentage(new BigDecimal("2.0"));
|
||||
strain = strainRepository.save(strain);
|
||||
|
||||
Batch batch = new Batch();
|
||||
batch.setTenantId(tenantId);
|
||||
batch.setStrainId(strain.getId());
|
||||
batch.setQuantityGrams(new BigDecimal("500.0"));
|
||||
batch.setHarvestDate(LocalDate.now().minusDays(7));
|
||||
batch.setBatchCode("BATCH-TEST-001");
|
||||
batch.setStatus(BatchStatus.AVAILABLE);
|
||||
batch = batchRepository.save(batch);
|
||||
batchId = batch.getId();
|
||||
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Create distribution for member — succeeds and records distribution")
|
||||
void testCreateDistribution_ValidRequest_Succeeds() {
|
||||
CreateDistributionRequest request = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("5.0"), "Test distribution");
|
||||
|
||||
ResponseEntity<DistributionResponse> response = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(201);
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
assertThat(response.getBody().memberId()).isEqualTo(memberId);
|
||||
assertThat(response.getBody().batchId()).isEqualTo(batchId);
|
||||
assertThat(response.getBody().quantityGrams()).isEqualByComparingTo(new BigDecimal("5.0"));
|
||||
assertThat(response.getBody().distributedAt()).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Distribution respects daily quota (25g) — boundary test at limit")
|
||||
void testCreateDistribution_DailyQuotaExceeded_Rejected() {
|
||||
// First: distribute 24g (just under limit)
|
||||
CreateDistributionRequest request1 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("24.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response1 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request1)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response1.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Second: distribute 1g more (should work — exactly at 25g)
|
||||
CreateDistributionRequest request2 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("1.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response2 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request2)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response2.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Third: 0.01g more — exceeds daily limit of 25g
|
||||
CreateDistributionRequest request3 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("0.01"), null);
|
||||
|
||||
ResponseEntity<String> response3 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request3)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response3.getStatusCode().value()).isIn(422, 400);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Distribution respects monthly quota (50g) — boundary test at limit")
|
||||
void testCreateDistribution_MonthlyQuotaExceeded_Rejected() {
|
||||
// Distribute 25g (daily max) — first day
|
||||
CreateDistributionRequest request1 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("25.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response1 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request1)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response1.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Now try to distribute 25.01g more — would exceed monthly 50g for adults
|
||||
// (in reality this is the same day so daily limit triggers first at 25g,
|
||||
// but the monthly check also applies)
|
||||
CreateDistributionRequest request2 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("25.01"), null);
|
||||
|
||||
ResponseEntity<String> response2 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request2)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Should be rejected (either daily or monthly limit)
|
||||
assertThat(response2.getStatusCode().value()).isIn(422, 400);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("U21 member gets lower THC restriction — high-THC strain rejected")
|
||||
void testCreateDistribution_Under21HighThc_Rejected() {
|
||||
// Create an under-21 member (born 5 years ago = 5 years old, but set under21=true)
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member youngMember = new Member();
|
||||
youngMember.setTenantId(tenantId);
|
||||
youngMember.setClubId(tenantId);
|
||||
youngMember.setFirstName("Jung");
|
||||
youngMember.setLastName("Mitglied");
|
||||
youngMember.setEmail("jung@test.de");
|
||||
youngMember.setDateOfBirth(LocalDate.now().minusYears(19));
|
||||
youngMember.setMembershipDate(LocalDate.now());
|
||||
youngMember.setMembershipNumber("M-U21-001");
|
||||
youngMember.setUnder21(true);
|
||||
youngMember.setStatus(MemberStatus.ACTIVE);
|
||||
youngMember = memberRepository.save(youngMember);
|
||||
|
||||
// Create a strain with THC > 10% (the U21 limit)
|
||||
Strain highThcStrain = new Strain();
|
||||
highThcStrain.setTenantId(tenantId);
|
||||
highThcStrain.setName("High THC Strain");
|
||||
highThcStrain.setThcPercentage(new BigDecimal("15.0"));
|
||||
highThcStrain.setCbdPercentage(new BigDecimal("1.0"));
|
||||
highThcStrain = strainRepository.save(highThcStrain);
|
||||
|
||||
Batch highThcBatch = new Batch();
|
||||
highThcBatch.setTenantId(tenantId);
|
||||
highThcBatch.setStrainId(highThcStrain.getId());
|
||||
highThcBatch.setQuantityGrams(new BigDecimal("100.0"));
|
||||
highThcBatch.setHarvestDate(LocalDate.now().minusDays(3));
|
||||
highThcBatch.setBatchCode("BATCH-HIGH-THC-001");
|
||||
highThcBatch.setStatus(BatchStatus.AVAILABLE);
|
||||
highThcBatch = batchRepository.save(highThcBatch);
|
||||
TenantContext.clear();
|
||||
|
||||
// Try to distribute high-THC strain to U21 member
|
||||
CreateDistributionRequest request = new CreateDistributionRequest(
|
||||
youngMember.getId(), highThcBatch.getId(), new BigDecimal("3.0"), null);
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isIn(422, 400);
|
||||
assertThat(response.getBody()).containsIgnoringCase("THC");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Distribution to inactive member is rejected")
|
||||
void testCreateDistribution_InactiveMember_Rejected() {
|
||||
// Create an inactive member
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member inactiveMember = new Member();
|
||||
inactiveMember.setTenantId(tenantId);
|
||||
inactiveMember.setClubId(tenantId);
|
||||
inactiveMember.setFirstName("Inaktiv");
|
||||
inactiveMember.setLastName("Mitglied");
|
||||
inactiveMember.setEmail("inaktiv@test.de");
|
||||
inactiveMember.setDateOfBirth(LocalDate.of(1985, 6, 1));
|
||||
inactiveMember.setMembershipDate(LocalDate.now());
|
||||
inactiveMember.setMembershipNumber("M-INACTIVE-001");
|
||||
inactiveMember.setUnder21(false);
|
||||
inactiveMember.setStatus(MemberStatus.SUSPENDED);
|
||||
inactiveMember = memberRepository.save(inactiveMember);
|
||||
TenantContext.clear();
|
||||
|
||||
CreateDistributionRequest request = new CreateDistributionRequest(
|
||||
inactiveMember.getId(), batchId, new BigDecimal("5.0"), null);
|
||||
|
||||
ResponseEntity<String> response = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isIn(422, 400);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Batch distribution — multiple distributions in sequence succeed within limits")
|
||||
void testCreateDistribution_BatchMultipleMembers_Succeeds() {
|
||||
// Create a second member
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
Member member2 = createMemberDirectly(tenantId, "Anna", "Beispiel",
|
||||
"anna@test.de", LocalDate.of(1992, 3, 20));
|
||||
TenantContext.clear();
|
||||
|
||||
// Distribute to first member
|
||||
CreateDistributionRequest request1 = new CreateDistributionRequest(
|
||||
memberId, batchId, new BigDecimal("10.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response1 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request1)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response1.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Distribute to second member
|
||||
CreateDistributionRequest request2 = new CreateDistributionRequest(
|
||||
member2.getId(), batchId, new BigDecimal("15.0"), null);
|
||||
|
||||
ResponseEntity<DistributionResponse> response2 = restClient().post()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(request2)
|
||||
.retrieve()
|
||||
.toEntity(DistributionResponse.class);
|
||||
assertThat(response2.getStatusCode().value()).isEqualTo(201);
|
||||
|
||||
// Verify both distributions are listed
|
||||
ResponseEntity<String> listResponse = restClient().get()
|
||||
.uri("/api/v1/distributions")
|
||||
.header("Authorization", "Bearer " + adminToken)
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
assertThat(listResponse.getStatusCode().value()).isEqualTo(200);
|
||||
assertThat(listResponse.getBody()).contains(memberId.toString());
|
||||
assertThat(listResponse.getBody()).contains(member2.getId().toString());
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import org.flywaydb.core.Flyway;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatNoException;
|
||||
|
||||
/**
|
||||
* Integration test verifying Flyway migrations apply cleanly to a fresh PostgreSQL database.
|
||||
* Validates schema integrity, idempotency, and expected table existence.
|
||||
*/
|
||||
class MigrationIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
@Autowired
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Test
|
||||
@DisplayName("All Flyway migrations (V1–V34) apply cleanly on fresh database")
|
||||
void testFlywayMigration_AllMigrationsApply_NoErrors() {
|
||||
// The application context starts with Flyway auto-migration enabled,
|
||||
// so if we reach this point, all migrations applied successfully.
|
||||
// Verify via flyway_schema_history table.
|
||||
Integer migrationCount = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM flyway_schema_history WHERE success = true",
|
||||
Integer.class);
|
||||
|
||||
assertThat(migrationCount).isGreaterThanOrEqualTo(34);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Running Flyway migrate again is idempotent (no new migrations applied)")
|
||||
void testFlywayMigration_Idempotent_SecondRunNoOp() {
|
||||
// Grab a Flyway instance pointing at the same datasource
|
||||
Flyway flyway = Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.locations("classpath:db/migration")
|
||||
.load();
|
||||
|
||||
// Running migrate again should be a no-op (0 new migrations)
|
||||
assertThatNoException().isThrownBy(flyway::migrate);
|
||||
|
||||
// Verify no pending migrations
|
||||
var info = flyway.info();
|
||||
assertThat(info.pending()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Schema contains all expected core tables after migration")
|
||||
void testFlywayMigration_ExpectedTablesExist() {
|
||||
// Spot-check critical tables from various migrations
|
||||
List<String> expectedTables = List.of(
|
||||
"users",
|
||||
"members",
|
||||
"distributions",
|
||||
"clubs",
|
||||
"audit_events",
|
||||
"bank_import_sessions",
|
||||
"assemblies",
|
||||
"forum_topics",
|
||||
"batches",
|
||||
"strains",
|
||||
"monthly_quotas",
|
||||
"bank_transactions",
|
||||
"assembly_votes",
|
||||
"documents"
|
||||
);
|
||||
|
||||
for (String table : expectedTables) {
|
||||
Integer count = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM information_schema.tables " +
|
||||
"WHERE table_schema = 'public' AND table_name = ?",
|
||||
Integer.class, table);
|
||||
assertThat(count)
|
||||
.as("Table '%s' should exist in the schema", table)
|
||||
.isEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package de.cannamanage.api.integration;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Integration test verifying Spring Security filter chain behavior end-to-end.
|
||||
* Tests public endpoints, JWT-protected endpoints, and CORS configuration.
|
||||
*/
|
||||
class SecurityConfigIntegrationTest extends AbstractIntegrationTest {
|
||||
|
||||
private UUID tenantId;
|
||||
private static final String ADMIN_EMAIL = "sec-admin@test.de";
|
||||
private static final String ADMIN_PASSWORD = "SecurePass123!";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenantId = createTestClub("Security Config Test Club");
|
||||
createAdminUser(tenantId, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unauthenticated request to public endpoint (actuator/health) returns 200")
|
||||
void testUnauthenticated_PublicEndpoint_Allowed() {
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/actuator/health")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Unauthenticated request to protected endpoint returns 401/403")
|
||||
void testUnauthenticated_ProtectedEndpoint_Returns401() {
|
||||
ResponseEntity<String> response = restClient().get()
|
||||
.uri("/api/v1/members")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
assertThat(response.getStatusCode().value()).isIn(401, 403);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Authenticated request to protected endpoint returns 200")
|
||||
void testAuthenticated_ProtectedEndpoint_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("CORS headers present on OPTIONS preflight request")
|
||||
void testCorsHeaders_PresentOnOptions() {
|
||||
ResponseEntity<String> response = restClient().options()
|
||||
.uri("/api/v1/members")
|
||||
.header(HttpHeaders.ORIGIN, "http://localhost:3000")
|
||||
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_HEADERS, "Authorization")
|
||||
.retrieve()
|
||||
.toEntity(String.class);
|
||||
|
||||
// Should not be blocked — allowed origin
|
||||
assertThat(response.getStatusCode().value()).isIn(200, 204);
|
||||
assertThat(response.getHeaders().getAccessControlAllowOrigin())
|
||||
.isEqualTo("http://localhost:3000");
|
||||
assertThat(response.getHeaders().getAccessControlAllowMethods())
|
||||
.isNotEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.io.Decoders;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link JwtService} covering token generation, parsing,
|
||||
* claim extraction, and security attack vectors.
|
||||
*/
|
||||
class JwtServiceTest {
|
||||
|
||||
private JwtService jwtService;
|
||||
|
||||
// A valid base64-encoded 256-bit secret for testing
|
||||
private static final String TEST_SECRET = Base64.getEncoder().encodeToString(
|
||||
"ThisIsA32ByteSecretKeyForTests!!".getBytes());
|
||||
|
||||
private UUID userId;
|
||||
private UUID tenantId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
jwtService = new JwtService();
|
||||
setField(jwtService, "secretKey", TEST_SECRET);
|
||||
setField(jwtService, "accessTokenExpiry", 3600L);
|
||||
setField(jwtService, "refreshTokenExpiry", 2592000L);
|
||||
|
||||
userId = UUID.randomUUID();
|
||||
tenantId = UUID.randomUUID();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateAccessToken_validClaims_containsExpectedFields() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "test@example.com");
|
||||
|
||||
assertThat(token).isNotNull().isNotBlank();
|
||||
assertThat(jwtService.extractSubject(token)).isEqualTo(userId.toString());
|
||||
assertThat(jwtService.extractTenantId(token)).isEqualTo(tenantId);
|
||||
assertThat(jwtService.extractRole(token)).isEqualTo("ADMIN");
|
||||
assertThat(jwtService.extractEmail(token)).isEqualTo("test@example.com");
|
||||
assertThat(jwtService.extractJti(token)).isNotNull().isNotBlank();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractUserId_validToken_returnsCorrectUuid() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "MEMBER", "user@club.de");
|
||||
|
||||
UUID extracted = jwtService.extractUserId(token);
|
||||
assertThat(extracted).isEqualTo(userId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractRole_staffToken_returnsStaff() {
|
||||
String token = jwtService.generateStaffAccessToken(userId, tenantId, "staff@club.de",
|
||||
List.of("MANAGE_MEMBERS", "VIEW_FINANCES"));
|
||||
|
||||
assertThat(jwtService.extractRole(token)).isEqualTo("STAFF");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractPermissions_staffToken_returnsPermissionsList() {
|
||||
List<String> permissions = List.of("MANAGE_MEMBERS", "VIEW_FINANCES", "MANAGE_GROW");
|
||||
String token = jwtService.generateStaffAccessToken(userId, tenantId, "staff@club.de", permissions);
|
||||
|
||||
List<String> extracted = jwtService.extractPermissions(token);
|
||||
assertThat(extracted).containsExactlyInAnyOrderElementsOf(permissions);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractPermissions_nonStaffToken_returnsEmptyList() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "admin@club.de");
|
||||
|
||||
List<String> extracted = jwtService.extractPermissions(token);
|
||||
assertThat(extracted).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractTenantId_validToken_returnsCorrectTenantUuid() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "MEMBER", "m@club.de");
|
||||
|
||||
UUID extracted = jwtService.extractTenantId(token);
|
||||
assertThat(extracted).isEqualTo(tenantId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_freshToken_returnsTrue() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "a@b.com");
|
||||
|
||||
assertThat(jwtService.isTokenValid(token)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_expiredToken_returnsFalse() throws Exception {
|
||||
// Create a service with 0 second expiry
|
||||
JwtService shortLived = new JwtService();
|
||||
setField(shortLived, "secretKey", TEST_SECRET);
|
||||
setField(shortLived, "accessTokenExpiry", 0L);
|
||||
setField(shortLived, "refreshTokenExpiry", 0L);
|
||||
|
||||
String token = shortLived.generateAccessToken(userId, tenantId, "ADMIN", "a@b.com");
|
||||
|
||||
// Token with 0-second expiry is immediately expired
|
||||
Thread.sleep(50);
|
||||
assertThat(jwtService.isTokenValid(token)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_invalidSignature_returnsFalse() {
|
||||
// Generate token with a different key
|
||||
String differentSecret = Base64.getEncoder().encodeToString(
|
||||
"ACompletelyDifferentKey1234567!!".getBytes());
|
||||
SecretKey wrongKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(differentSecret));
|
||||
|
||||
String forgedToken = Jwts.builder()
|
||||
.subject(userId.toString())
|
||||
.claim("tenant_id", tenantId.toString())
|
||||
.claim("role", "ADMIN")
|
||||
.issuedAt(Date.from(Instant.now()))
|
||||
.expiration(Date.from(Instant.now().plusSeconds(3600)))
|
||||
.signWith(wrongKey)
|
||||
.compact();
|
||||
|
||||
assertThat(jwtService.isTokenValid(forgedToken)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_malformedToken_returnsFalse() {
|
||||
assertThat(jwtService.isTokenValid("not.a.valid.jwt.token")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_nullToken_returnsFalse() {
|
||||
assertThat(jwtService.isTokenValid(null)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_emptyToken_returnsFalse() {
|
||||
assertThat(jwtService.isTokenValid("")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsTokenValid_tamperedPayload_returnsFalse() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "MEMBER", "m@club.de");
|
||||
// Tamper with the payload (second segment) by modifying a character
|
||||
String[] parts = token.split("\\.");
|
||||
// Flip a character in the payload
|
||||
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
|
||||
payloadBytes[5] = (byte) (payloadBytes[5] ^ 0xFF);
|
||||
parts[1] = Base64.getUrlEncoder().withoutPadding().encodeToString(payloadBytes);
|
||||
String tampered = String.join(".", parts);
|
||||
|
||||
assertThat(jwtService.isTokenValid(tampered)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGenerateRefreshToken_containsRefreshType() {
|
||||
String token = jwtService.generateRefreshToken(userId, tenantId);
|
||||
|
||||
assertThat(token).isNotNull();
|
||||
assertThat(jwtService.extractSubject(token)).isEqualTo(userId.toString());
|
||||
assertThat(jwtService.extractTenantId(token)).isEqualTo(tenantId);
|
||||
assertThat(jwtService.extractJti(token)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateSecret_tooShort_throwsIllegalState() throws Exception {
|
||||
JwtService invalid = new JwtService();
|
||||
setField(invalid, "secretKey", "short");
|
||||
setField(invalid, "accessTokenExpiry", 3600L);
|
||||
setField(invalid, "refreshTokenExpiry", 2592000L);
|
||||
|
||||
assertThatThrownBy(invalid::validateSecret)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("JWT secret is not configured");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateSecret_defaultPlaceholder_throwsIllegalState() throws Exception {
|
||||
JwtService invalid = new JwtService();
|
||||
setField(invalid, "secretKey", JwtService.UNCONFIGURED_SECRET_MARKER);
|
||||
setField(invalid, "accessTokenExpiry", 3600L);
|
||||
setField(invalid, "refreshTokenExpiry", 2592000L);
|
||||
|
||||
assertThatThrownBy(invalid::validateSecret)
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("JWT secret is not configured");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testExtractExpirationInstant_returnsNonNullFutureInstant() {
|
||||
String token = jwtService.generateAccessToken(userId, tenantId, "ADMIN", "a@b.com");
|
||||
|
||||
Instant expiration = jwtService.extractExpirationInstant(token);
|
||||
assertThat(expiration).isAfter(Instant.now());
|
||||
}
|
||||
|
||||
// --- Utility ---
|
||||
|
||||
private static void setField(Object target, String fieldName, Object value) throws Exception {
|
||||
Field field = target.getClass().getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link LoginRateLimiter} covering rate limiting logic,
|
||||
* IP isolation, boundary conditions, and counter reset.
|
||||
*/
|
||||
class LoginRateLimiterTest {
|
||||
|
||||
private LoginRateLimiter rateLimiter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
rateLimiter = new LoginRateLimiter();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_firstAttempt_allowed() {
|
||||
boolean result = rateLimiter.tryAcquire("192.168.1.1");
|
||||
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_withinLimit_allAllowed() {
|
||||
String ip = "10.0.0.1";
|
||||
|
||||
// Attempts 1 through 4 (under the limit of 5) should all be allowed
|
||||
for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW - 1; i++) {
|
||||
assertThat(rateLimiter.tryAcquire(ip)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_exactlyAtLimit_stillAllowed() {
|
||||
String ip = "10.0.0.2";
|
||||
|
||||
// Use up exactly MAX_ATTEMPTS_PER_WINDOW attempts
|
||||
for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) {
|
||||
assertThat(rateLimiter.tryAcquire(ip)).isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_oneOverLimit_blocked() {
|
||||
String ip = "10.0.0.3";
|
||||
|
||||
// Exhaust the quota
|
||||
for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) {
|
||||
rateLimiter.tryAcquire(ip);
|
||||
}
|
||||
|
||||
// Next attempt should be blocked
|
||||
assertThat(rateLimiter.tryAcquire(ip)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_differentIps_trackedIndependently() {
|
||||
String ip1 = "192.168.1.100";
|
||||
String ip2 = "192.168.1.200";
|
||||
|
||||
// Exhaust quota for ip1
|
||||
for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) {
|
||||
rateLimiter.tryAcquire(ip1);
|
||||
}
|
||||
assertThat(rateLimiter.tryAcquire(ip1)).isFalse();
|
||||
|
||||
// ip2 should still be allowed
|
||||
assertThat(rateLimiter.tryAcquire(ip2)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testResetCounters_afterReset_attemptsAllowedAgain() {
|
||||
String ip = "10.0.0.4";
|
||||
|
||||
// Exhaust the quota
|
||||
for (int i = 0; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) {
|
||||
rateLimiter.tryAcquire(ip);
|
||||
}
|
||||
assertThat(rateLimiter.tryAcquire(ip)).isFalse();
|
||||
|
||||
// Reset counters (simulating the scheduled task)
|
||||
rateLimiter.resetCounters();
|
||||
|
||||
// Should be allowed again
|
||||
assertThat(rateLimiter.tryAcquire(ip)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_ipv6Address_handledCorrectly() {
|
||||
String ipv6 = "2001:0db8:85a3:0000:0000:8a2e:0370:7334";
|
||||
|
||||
assertThat(rateLimiter.tryAcquire(ipv6)).isTrue();
|
||||
|
||||
// Exhaust quota for IPv6
|
||||
for (int i = 1; i < LoginRateLimiter.MAX_ATTEMPTS_PER_WINDOW; i++) {
|
||||
rateLimiter.tryAcquire(ipv6);
|
||||
}
|
||||
// Should still pass at limit
|
||||
assertThat(rateLimiter.tryAcquire(ipv6)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTryAcquire_nullOrBlankIp_treatedAsUnknown() {
|
||||
// null and blank IPs should not throw — they get mapped to "unknown"
|
||||
assertThat(rateLimiter.tryAcquire(null)).isTrue();
|
||||
assertThat(rateLimiter.tryAcquire("")).isTrue();
|
||||
assertThat(rateLimiter.tryAcquire(" ")).isTrue();
|
||||
|
||||
// All null/blank share the "unknown" bucket — 3 attempts above, 2 more allowed
|
||||
assertThat(rateLimiter.tryAcquire(null)).isTrue();
|
||||
assertThat(rateLimiter.tryAcquire("")).isTrue();
|
||||
// 6th attempt (over limit of 5) should be blocked
|
||||
assertThat(rateLimiter.tryAcquire(null)).isFalse();
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
package de.cannamanage.api.security;
|
||||
|
||||
import de.cannamanage.domain.entity.TenantContext;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import org.hibernate.Filter;
|
||||
import org.hibernate.Session;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* Unit tests for {@link TenantFilterAspect} verifying that the Hibernate
|
||||
* tenant filter is correctly activated/skipped based on TenantContext state.
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class TenantFilterAspectTest {
|
||||
|
||||
@Mock
|
||||
private EntityManager entityManager;
|
||||
|
||||
@Mock
|
||||
private Session session;
|
||||
|
||||
@Mock
|
||||
private Filter filter;
|
||||
|
||||
private TenantFilterAspect aspect;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
// Use doReturn to handle the generic unwrap() method properly
|
||||
doReturn(session).when(entityManager).unwrap(Session.class);
|
||||
when(session.enableFilter("tenantFilter")).thenReturn(filter);
|
||||
|
||||
aspect = new TenantFilterAspect(entityManager);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
TenantContext.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_withTenantSet_enablesFilter() {
|
||||
UUID tenantId = UUID.randomUUID();
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
|
||||
aspect.activateTenantFilter();
|
||||
|
||||
verify(session).enableFilter("tenantFilter");
|
||||
verify(filter).setParameter("tenantId", tenantId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_differentTenants_getDifferentFilterValues() {
|
||||
UUID tenant1 = UUID.randomUUID();
|
||||
UUID tenant2 = UUID.randomUUID();
|
||||
|
||||
// First tenant
|
||||
TenantContext.setCurrentTenant(tenant1);
|
||||
aspect.activateTenantFilter();
|
||||
verify(filter).setParameter("tenantId", tenant1);
|
||||
|
||||
// Second tenant
|
||||
TenantContext.setCurrentTenant(tenant2);
|
||||
aspect.activateTenantFilter();
|
||||
verify(filter).setParameter("tenantId", tenant2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_noTenantInContext_filterNotActivated() {
|
||||
// TenantContext is empty (no tenant set)
|
||||
aspect.activateTenantFilter();
|
||||
|
||||
verify(entityManager, never()).unwrap(Session.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_tenantCleared_filterNotActivated() {
|
||||
// Set and then clear tenant
|
||||
TenantContext.setCurrentTenant(UUID.randomUUID());
|
||||
TenantContext.clear();
|
||||
|
||||
aspect.activateTenantFilter();
|
||||
|
||||
verify(entityManager, never()).unwrap(Session.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_multipleCallsSameTenant_enablesFilterEachTime() {
|
||||
UUID tenantId = UUID.randomUUID();
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
|
||||
// Aspect is called per-repository-method; it should enable filter every time
|
||||
aspect.activateTenantFilter();
|
||||
aspect.activateTenantFilter();
|
||||
aspect.activateTenantFilter();
|
||||
|
||||
verify(session, times(3)).enableFilter("tenantFilter");
|
||||
verify(filter, times(3)).setParameter("tenantId", tenantId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_concurrentRequests_isolatedByThread() throws Exception {
|
||||
UUID tenant1 = UUID.randomUUID();
|
||||
UUID tenant2 = UUID.randomUUID();
|
||||
|
||||
// Simulate concurrent requests on different threads
|
||||
Thread thread1 = new Thread(() -> {
|
||||
TenantContext.setCurrentTenant(tenant1);
|
||||
aspect.activateTenantFilter();
|
||||
TenantContext.clear();
|
||||
});
|
||||
|
||||
Thread thread2 = new Thread(() -> {
|
||||
TenantContext.setCurrentTenant(tenant2);
|
||||
aspect.activateTenantFilter();
|
||||
TenantContext.clear();
|
||||
});
|
||||
|
||||
thread1.start();
|
||||
thread2.start();
|
||||
thread1.join();
|
||||
thread2.join();
|
||||
|
||||
// Both tenants should have been set (order not guaranteed)
|
||||
verify(filter).setParameter("tenantId", tenant1);
|
||||
verify(filter).setParameter("tenantId", tenant2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testTenantContext_clear_preventsLeakage() {
|
||||
UUID tenantId = UUID.randomUUID();
|
||||
TenantContext.setCurrentTenant(tenantId);
|
||||
TenantContext.clear();
|
||||
|
||||
// After clear, no tenant should be active
|
||||
aspect.activateTenantFilter();
|
||||
|
||||
verify(entityManager, never()).unwrap(Session.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivateTenantFilter_adminCannotAccessOtherClub_filterUsesContextTenant() {
|
||||
// Even for admin role, the filter is activated with whatever tenant is in context.
|
||||
// Cross-tenant access is prevented by TenantContext being set per-request.
|
||||
UUID adminTenant = UUID.randomUUID();
|
||||
TenantContext.setCurrentTenant(adminTenant);
|
||||
|
||||
aspect.activateTenantFilter();
|
||||
|
||||
// Filter is always set to the context tenant — no bypass possible
|
||||
verify(filter).setParameter("tenantId", adminTenant);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user