feat(license): enforce max_agents at AgentRegistryService.register
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> registerAgent(String agentId, String name, String application) {
|
||||
String json = """
|
||||
{
|
||||
|
||||
@@ -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<String> registerAgent(String agentId, String name) {
|
||||
String json = """
|
||||
{
|
||||
|
||||
@@ -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<String> registerAgent(String agentId, String name, String application) {
|
||||
String json = """
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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<AgentInfo> all = agentRegistryService.findAll();
|
||||
for (AgentInfo a : all) {
|
||||
agentRegistryService.deregister(a.instanceId());
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<String> 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<String> 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<String> reRegister = register("cap-it-rereg-1");
|
||||
assertThat(reRegister.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
// And a fresh registration is still rejected.
|
||||
ResponseEntity<String> third = register("cap-it-rereg-3");
|
||||
assertThat(third.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
@@ -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 = """
|
||||
{
|
||||
|
||||
@@ -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<String> registerAgent(String agentId) {
|
||||
String json = """
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -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<String, AgentInfo> agents = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, ConcurrentLinkedQueue<AgentCommand>> 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),
|
||||
|
||||
Reference in New Issue
Block a user