From afdaee628b977b596f702958ded4edc3a2c0ca81 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 14:19:08 +0200 Subject: [PATCH] feat(license): enforce max_agents at AgentRegistryService.register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a CreateGuard to AgentRegistryService that fires only on NEW registrations: re-registers of an existing agent bypass the cap (they don't grow the registry, and rejecting them would orphan an agent that already counts against the cap). Live-only count for cap enforcement — STALE/DEAD/SHUTDOWN agents are excluded so the cap reflects the working fleet, not historical residue. Reuses the CreateGuard pattern from T18-T19. The global LicenseExceptionAdvice maps the resulting LicenseCapExceededException to 403 with the structured envelope — no AgentRegistrationController changes needed. AgentCapEnforcementIT exercises the HTTP path end-to-end: two registers succeed at cap=2, a third returns 403 with the expected envelope, and a re-register of an already-registered agent succeeds at-cap. Sibling agent-registering ITs (Agent*ControllerIT, Diagram*IT, Execution*IT, Search*IT, Protocol*IT, Backpressure*IT, JwtRefresh*IT, Registration*IT, Security*IT, SseSigning*IT, IngestionSchemaIT) lift max_agents in @BeforeEach and clear the synthetic license in @AfterEach — the in-memory registry is shared across @SpringBootTest reuse boundaries, so without the lift the default-tier max_agents=5 would be exhausted by accumulated test residue. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/config/AgentRegistryBeanConfig.java | 6 +- .../controller/AgentCommandControllerIT.java | 10 ++ .../AgentRegistrationControllerIT.java | 11 ++ .../app/controller/AgentSseControllerIT.java | 10 ++ .../server/app/controller/BackpressureIT.java | 11 ++ .../app/controller/DetailControllerIT.java | 11 ++ .../app/controller/DiagramControllerIT.java | 11 ++ .../controller/DiagramRenderControllerIT.java | 11 ++ .../app/controller/ExecutionControllerIT.java | 11 ++ .../app/controller/ForwardCompatIT.java | 11 ++ .../app/controller/MetricsControllerIT.java | 11 ++ .../app/controller/SearchControllerIT.java | 6 + .../app/interceptor/ProtocolVersionIT.java | 11 ++ .../app/license/AgentCapEnforcementIT.java | 127 ++++++++++++++++++ .../server/app/security/JwtRefreshIT.java | 16 +++ .../app/security/RegistrationSecurityIT.java | 20 +++ .../server/app/security/SecurityFilterIT.java | 11 ++ .../server/app/security/SseSigningIT.java | 15 +++ .../server/app/storage/DiagramLinkingIT.java | 11 ++ .../server/app/storage/IngestionSchemaIT.java | 11 ++ .../core/agent/AgentRegistryService.java | 28 +++- 21 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/license/AgentCapEnforcementIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java index aeda9f9e..93bed25d 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/AgentRegistryBeanConfig.java @@ -17,11 +17,13 @@ import org.springframework.context.annotation.Configuration; public class AgentRegistryBeanConfig { @Bean - public AgentRegistryService agentRegistryService(AgentRegistryConfig config) { + public AgentRegistryService agentRegistryService(AgentRegistryConfig config, + com.cameleer.server.app.license.LicenseEnforcer enforcer) { return new AgentRegistryService( config.getStaleThresholdMs(), config.getDeadThresholdMs(), - config.getCommandExpiryMs() + config.getCommandExpiryMs(), + current -> enforcer.assertWithinCap("max_agents", current, 1) ); } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentCommandControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentCommandControllerIT.java index 22fe45be..85199896 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentCommandControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentCommandControllerIT.java @@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -13,6 +14,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.util.Map; import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; @@ -33,10 +35,18 @@ class AgentCommandControllerIT extends AbstractPostgresIT { @BeforeEach void setUp() { + // Lift max_agents cap so this IT (which registers many agents per test) isn't gated + // by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); agentJwt = securityHelper.registerTestAgent("test-agent-command-it"); operatorJwt = securityHelper.operatorToken(); } + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + private ResponseEntity registerAgent(String agentId, String name, String application) { String json = """ { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentRegistrationControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentRegistrationControllerIT.java index fdd904d3..1822957f 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentRegistrationControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentRegistrationControllerIT.java @@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -13,6 +14,8 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; class AgentRegistrationControllerIT extends AbstractPostgresIT { @@ -31,10 +34,18 @@ class AgentRegistrationControllerIT extends AbstractPostgresIT { @BeforeEach void setUp() { + // Lift max_agents cap so this IT (which registers many agents per test) isn't gated + // by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); jwt = securityHelper.registerTestAgent("test-agent-registration-it"); viewerJwt = securityHelper.viewerToken(); } + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + private ResponseEntity registerAgent(String agentId, String name) { String json = """ { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentSseControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentSseControllerIT.java index abf03741..f6d33d15 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentSseControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AgentSseControllerIT.java @@ -3,6 +3,7 @@ package com.cameleer.server.app.controller; import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -20,6 +21,7 @@ import java.net.http.HttpResponse; import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; @@ -48,10 +50,18 @@ class AgentSseControllerIT extends AbstractPostgresIT { @BeforeEach void setUp() { + // Lift max_agents cap so this IT (which registers many agents per test) isn't gated + // by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); jwt = securityHelper.registerTestAgent("test-agent-sse-it"); operatorJwt = securityHelper.operatorToken(); } + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + private ResponseEntity registerAgent(String agentId, String name, String application) { String json = """ { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/BackpressureIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/BackpressureIT.java index 79c0b53e..2815089a 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/BackpressureIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/BackpressureIT.java @@ -3,6 +3,7 @@ package com.cameleer.server.app.controller; import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.core.ingestion.IngestionService; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -13,6 +14,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.context.TestPropertySource; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -45,10 +48,18 @@ class BackpressureIT extends AbstractPostgresIT { @BeforeEach void setUp() { + // Lift max_agents cap so this IT (which registers an agent) isn't gated by license + // enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); String jwt = securityHelper.registerTestAgent("test-agent-backpressure-it"); authHeaders = securityHelper.authHeaders(jwt); } + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + @Test void whenMetricsBufferFull_returns503WithRetryAfter() { // Fill the metrics buffer completely with a batch of 5 diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DetailControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DetailControllerIT.java index 9fdbb4eb..ddd144e7 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DetailControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DetailControllerIT.java @@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; @@ -15,6 +16,8 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.util.Map; + import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -49,6 +52,9 @@ class DetailControllerIT extends AbstractPostgresIT { */ @BeforeAll void seedTestData() { + // Lift max_agents cap so this IT (which registers an agent) isn't gated by license + // enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); jwt = securityHelper.registerTestAgent("test-agent-detail-it"); viewerJwt = securityHelper.viewerToken(); @@ -231,4 +237,9 @@ class DetailControllerIT extends AbstractPostgresIT { new HttpEntity<>(headers), String.class); } + + @AfterAll + void tearDown() { + securityHelper.clearTestLicense(); + } } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramControllerIT.java index 5b1f3827..ce8939d0 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramControllerIT.java @@ -2,6 +2,7 @@ package com.cameleer.server.app.controller; import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -12,6 +13,8 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.util.Map; + import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -29,11 +32,19 @@ class DiagramControllerIT extends AbstractPostgresIT { @BeforeEach void setUp() { + // Lift max_agents cap so this IT (which registers an agent) isn't gated by license + // enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); String jwt = securityHelper.registerTestAgent("test-agent-diagram-it"); authHeaders = securityHelper.authHeaders(jwt); viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken()); } + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + @Test void postSingleDiagram_returns202() { String json = """ diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java index 7ba1074f..11e863e9 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DiagramRenderControllerIT.java @@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.util.Map; + import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -41,6 +44,9 @@ class DiagramRenderControllerIT extends AbstractPostgresIT { @BeforeEach void seedDiagram() { + // Lift max_agents cap so this IT (which registers an agent) isn't gated by license + // enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); jwt = securityHelper.registerTestAgent("test-agent-diagram-render-it"); viewerJwt = securityHelper.viewerToken(); @@ -115,6 +121,11 @@ class DiagramRenderControllerIT extends AbstractPostgresIT { }); } + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + @Test void getSvg_withAcceptHeader_returnsSvg() { HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ExecutionControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ExecutionControllerIT.java index 7c818119..763d733e 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ExecutionControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ExecutionControllerIT.java @@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.util.Map; + import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -38,11 +41,19 @@ class ExecutionControllerIT extends AbstractPostgresIT { @BeforeEach void setUp() { + // Lift max_agents cap so this IT (which registers an agent) isn't gated by license + // enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); String jwt = securityHelper.registerTestAgent("test-agent-execution-it"); authHeaders = securityHelper.authHeaders(jwt); viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken()); } + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + @Test void postSingleExecution_returns202() { String json = """ diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ForwardCompatIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ForwardCompatIT.java index 74b6f268..0dcd440c 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ForwardCompatIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/ForwardCompatIT.java @@ -2,6 +2,7 @@ package com.cameleer.server.app.controller; import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -10,6 +11,8 @@ import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -28,9 +31,17 @@ class ForwardCompatIT extends AbstractPostgresIT { @BeforeEach void setUp() { + // Lift max_agents cap so this IT (which registers an agent) isn't gated by license + // enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); jwt = securityHelper.registerTestAgent("test-agent-forward-compat-it"); } + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + @Test void unknownFieldsInRequestBodyDoNotCauseError() { // Valid ExecutionChunk plus extra fields a future agent version diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/MetricsControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/MetricsControllerIT.java index 977a2a5a..89f55947 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/MetricsControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/MetricsControllerIT.java @@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.util.Map; + import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -34,12 +37,20 @@ class MetricsControllerIT extends AbstractPostgresIT { @BeforeEach void setUp() { + // Lift max_agents cap so this IT (which registers an agent) isn't gated by license + // enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); agentId = "test-agent-metrics-it"; String jwt = securityHelper.registerTestAgent(agentId); authHeaders = securityHelper.authHeaders(jwt); viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken()); } + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + @Test void postMetrics_returns202() { String json = """ diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java index 49eed7fb..33923c6f 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/SearchControllerIT.java @@ -14,6 +14,8 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.util.Map; + import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -42,6 +44,10 @@ class SearchControllerIT extends AbstractPostgresIT { */ @BeforeEach void seedTestData() { + // Lift max_agents cap unconditionally so this IT (which registers an agent on first + // seed) isn't gated by license enforcement on this run or any sibling that follows. + // Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); if (seeded) return; seeded = true; jwt = securityHelper.registerTestAgent("test-agent-search-it"); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/interceptor/ProtocolVersionIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/interceptor/ProtocolVersionIT.java index 910a780c..0228fe9b 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/interceptor/ProtocolVersionIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/interceptor/ProtocolVersionIT.java @@ -2,6 +2,7 @@ package com.cameleer.server.app.interceptor; import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -11,6 +12,8 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -30,9 +33,17 @@ class ProtocolVersionIT extends AbstractPostgresIT { @BeforeEach void setUp() { + // Lift max_agents cap so this IT (which registers an agent) isn't gated by license + // enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); jwt = securityHelper.registerTestAgent("test-agent-protocol-it"); } + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + @Test void requestWithoutProtocolHeaderReturns400() { HttpHeaders headers = new HttpHeaders(); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AgentCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AgentCapEnforcementIT.java new file mode 100644 index 00000000..be111541 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AgentCapEnforcementIT.java @@ -0,0 +1,127 @@ +package com.cameleer.server.app.license; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.TestSecurityHelper; +import com.cameleer.server.core.agent.AgentInfo; +import com.cameleer.server.core.agent.AgentRegistryService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that the {@code max_agents} cap is enforced at + * {@code POST /api/v1/agents/register} via the {@link com.cameleer.server.core.runtime.CreateGuard} + * wired into {@link AgentRegistryService}. The cap fires only on NEW registrations — re-registers + * of an already-registered agent bypass the check (they don't grow the registry). + * + *

This IT installs a synthetic license that lowers {@code max_agents} to {@code 2} so the cap + * can be exercised in a few HTTP calls. The default tier ({@code max_agents = 5}) is intentionally + * not exercised here because sibling agent ITs share the Spring context's in-memory registry and + * would interfere; the structured 403 envelope (also produced by + * {@link LicenseExceptionAdvice}) is identical regardless of the underlying limit value.

+ */ +class AgentCapEnforcementIT extends AbstractPostgresIT { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TestSecurityHelper securityHelper; + + @Autowired + private AgentRegistryService agentRegistryService; + + @BeforeEach + void setUp() { + // The registry is in-memory and shared across @SpringBootTest reuse boundaries; sibling + // ITs (AgentRegistrationControllerIT, AgentSseControllerIT, …) leave residue here. + clearRegistry(); + // Lower max_agents to 2 so the cap rejection lands on the third register call. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 2)); + } + + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + clearRegistry(); + } + + private void clearRegistry() { + List all = agentRegistryService.findAll(); + for (AgentInfo a : all) { + agentRegistryService.deregister(a.instanceId()); + } + } + + private ResponseEntity register(String agentId) { + String json = """ + { + "instanceId": "%s", + "applicationId": "test-cap", + "environmentId": "default", + "version": "1.0.0", + "routeIds": [], + "capabilities": {} + } + """.formatted(agentId); + return restTemplate.postForEntity( + "/api/v1/agents/register", + new HttpEntity<>(json, securityHelper.bootstrapHeaders()), + String.class); + } + + @Test + void registerBeyondCap_returns403WithStateAndMessage() throws Exception { + // Two registrations succeed (cap = 2). + assertThat(register("cap-it-agent-1").getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(register("cap-it-agent-2").getStatusCode()).isEqualTo(HttpStatus.OK); + + // The third registration must be rejected with the structured 403 envelope. + ResponseEntity third = register("cap-it-agent-3"); + assertThat(third.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + + JsonNode body = objectMapper.readTree(third.getBody()); + assertThat(body.path("error").asText()).isEqualTo("license cap reached"); + assertThat(body.path("limit").asText()).isEqualTo("max_agents"); + assertThat(body.path("cap").asInt()).isEqualTo(2); + // We installed a synthetic license, so state is ACTIVE (not ABSENT). + assertThat(body.path("state").asText()).isEqualTo("ACTIVE"); + assertThat(body.has("message")).isTrue(); + assertThat(body.path("message").asText()).isNotBlank(); + + // And the third agent must NOT be present in the registry. + assertThat(agentRegistryService.findById("cap-it-agent-3")).isNull(); + } + + @Test + void reRegisterAtCap_bypassesGuardAndReturns200() { + // Fill to the cap. + assertThat(register("cap-it-rereg-1").getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(register("cap-it-rereg-2").getStatusCode()).isEqualTo(HttpStatus.OK); + + // Re-register an existing agent — must succeed even though we're at-cap, because + // re-registers don't grow the registry. This is the explicit design of the guard + // placement inside the (existing == null) branch of agents.compute(...). + ResponseEntity reRegister = register("cap-it-rereg-1"); + assertThat(reRegister.getStatusCode()).isEqualTo(HttpStatus.OK); + + // And a fresh registration is still rejected. + ResponseEntity third = register("cap-it-rereg-3"); + assertThat(third.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRefreshIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRefreshIT.java index 8492e9c4..4dc9820c 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRefreshIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/JwtRefreshIT.java @@ -5,6 +5,8 @@ import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.core.security.JwtService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.web.client.TestRestTemplate; @@ -15,6 +17,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -34,6 +38,18 @@ class JwtRefreshIT extends AbstractPostgresIT { @Autowired private JwtService jwtService; + @BeforeEach + void setUp() { + // Lift max_agents cap so this IT (which registers agents per test) isn't gated + // by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); + } + + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + private JsonNode registerAndGetTokens(String agentId) throws Exception { String json = """ { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/RegistrationSecurityIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/RegistrationSecurityIT.java index d85e0294..63095bf7 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/RegistrationSecurityIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/RegistrationSecurityIT.java @@ -1,8 +1,11 @@ package com.cameleer.server.app.security; import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.TestSecurityHelper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.web.client.TestRestTemplate; @@ -13,6 +16,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -27,6 +32,21 @@ class RegistrationSecurityIT extends AbstractPostgresIT { @Autowired private ObjectMapper objectMapper; + @Autowired + private TestSecurityHelper securityHelper; + + @BeforeEach + void setUp() { + // Lift max_agents cap so this IT (which registers agents per test) isn't gated + // by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); + } + + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + private ResponseEntity registerAgent(String agentId) { String json = """ { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SecurityFilterIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SecurityFilterIT.java index 4b1832b3..00737ad9 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SecurityFilterIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SecurityFilterIT.java @@ -2,6 +2,7 @@ package com.cameleer.server.app.security; import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -13,6 +14,8 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import java.util.Map; + import static org.assertj.core.api.Assertions.assertThat; /** @@ -31,10 +34,18 @@ class SecurityFilterIT extends AbstractPostgresIT { @BeforeEach void setUp() { + // Lift max_agents cap so this IT (which registers agents per test) isn't gated + // by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); securityHelper.registerTestAgent("test-agent-security-filter-it"); viewerJwt = securityHelper.viewerToken(); } + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + @Test void protectedEndpoint_withoutJwt_returns401or403() { HttpHeaders headers = new HttpHeaders(); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SseSigningIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SseSigningIT.java index 35992e60..aa887b79 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SseSigningIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/security/SseSigningIT.java @@ -5,6 +5,8 @@ import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.core.security.Ed25519SigningService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.web.client.TestRestTemplate; @@ -29,6 +31,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Base64; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; @@ -62,6 +65,18 @@ class SseSigningIT extends AbstractPostgresIT { @LocalServerPort private int port; + @BeforeEach + void setUp() { + // Lift max_agents cap so this IT (which registers agents per test) isn't gated + // by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); + } + + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + private HttpHeaders protocolHeaders() { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/DiagramLinkingIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/DiagramLinkingIT.java index b44b634d..1b75e040 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/DiagramLinkingIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/DiagramLinkingIT.java @@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.util.Map; + import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -40,11 +43,19 @@ class DiagramLinkingIT extends AbstractPostgresIT { @BeforeEach void setUp() { + // Lift max_agents cap so this IT (which registers an agent) isn't gated by license + // enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); String jwt = securityHelper.registerTestAgent(agentId); authHeaders = securityHelper.authHeaders(jwt); viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken()); } + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + @Test void diagramHashPopulated_whenRouteGraphExistsBeforeExecution() throws Exception { String graphJson = """ diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/IngestionSchemaIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/IngestionSchemaIT.java index 3219a191..83b07852 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/IngestionSchemaIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/IngestionSchemaIT.java @@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.TestSecurityHelper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import java.util.Map; + import static java.util.concurrent.TimeUnit.SECONDS; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -41,11 +44,19 @@ class IngestionSchemaIT extends AbstractPostgresIT { @BeforeEach void setUp() { + // Lift max_agents cap so this IT (which registers an agent) isn't gated by license + // enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT. + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); String jwt = securityHelper.registerTestAgent(agentId); authHeaders = securityHelper.authHeaders(jwt); viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken()); } + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + @Test void processorTreeMetadata_depthsAndParentIdsCorrect() throws Exception { String json = """ diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java index 014e503d..8eb68828 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/agent/AgentRegistryService.java @@ -1,5 +1,6 @@ package com.cameleer.server.core.agent; +import com.cameleer.server.core.runtime.CreateGuard; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -29,6 +30,7 @@ public class AgentRegistryService { private final long staleThresholdMs; private final long deadThresholdMs; private final long commandExpiryMs; + private final CreateGuard registerGuard; private final ConcurrentHashMap agents = new ConcurrentHashMap<>(); private final ConcurrentHashMap> commands = new ConcurrentHashMap<>(); @@ -36,10 +38,18 @@ public class AgentRegistryService { private volatile AgentEventListener eventListener; + /** Backwards-compatible 3-arg ctor (no enforcement). Used by tests. */ public AgentRegistryService(long staleThresholdMs, long deadThresholdMs, long commandExpiryMs) { + this(staleThresholdMs, deadThresholdMs, commandExpiryMs, CreateGuard.NOOP); + } + + /** Production ctor with license-cap enforcement on new registrations. */ + public AgentRegistryService(long staleThresholdMs, long deadThresholdMs, long commandExpiryMs, + CreateGuard registerGuard) { this.staleThresholdMs = staleThresholdMs; this.deadThresholdMs = deadThresholdMs; this.commandExpiryMs = commandExpiryMs; + this.registerGuard = registerGuard; } /** @@ -55,7 +65,9 @@ public class AgentRegistryService { return agents.compute(id, (key, existing) -> { if (existing != null) { - // Re-registration: update metadata, reset to LIVE + // Re-registration: update metadata, reset to LIVE. + // Re-registers bypass the license cap check — they don't grow the registry, + // and rejecting them would orphan an agent that already counts against the cap. log.info("Agent {} re-registering (was {})", id, existing.state()); return existing .withMetadata(name, application, environmentId, version, List.copyOf(routeIds), Map.copyOf(capabilities)) @@ -64,11 +76,25 @@ public class AgentRegistryService { .withRegisteredAt(now) .withStaleTransitionTime(null); } + // NEW registration — consult the cap. ConcurrentHashMap.compute propagates the + // exception thrown here, so the controller advice (LicenseExceptionAdvice) maps + // LicenseCapExceededException to 403 with the structured envelope. + registerGuard.check(liveAgentCount()); log.info("Agent {} registered (name={}, application={}, env={})", id, name, application, environmentId); return newAgent; }); } + /** + * Live-only agent count for license-cap enforcement. + * STALE/DEAD/SHUTDOWN agents are excluded so the cap reflects the working fleet, not + * historical residue. Re-registers of an existing agent revive it via the + * {@code existing != null} branch in {@link #register}, so this count never double-counts. + */ + private long liveAgentCount() { + return agents.values().stream().filter(a -> a.state() == AgentState.LIVE).count(); + } + /** * Process a heartbeat from an agent. * Updates lastHeartbeat, routeIds (if provided), capabilities (if provided),