chore: rename cameleer3 to cameleer
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 18s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped

Rename Java packages from com.cameleer3 to com.cameleer, module
directories from cameleer3-* to cameleer-*, and all references
throughout workflows, Dockerfiles, docs, migrations, and pom.xml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-15 15:28:42 +02:00
parent 1077293343
commit cb3ebfea7c
569 changed files with 4356 additions and 3245 deletions

View File

@@ -0,0 +1,48 @@
package com.cameleer.server.app;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.containers.PostgreSQLContainer;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
public abstract class AbstractPostgresIT {
static final PostgreSQLContainer<?> postgres;
static final ClickHouseContainer clickhouse;
static {
postgres = new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("cameleer")
.withUsername("cameleer")
.withPassword("test");
postgres.start();
clickhouse = new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
clickhouse.start();
}
@Autowired
protected JdbcTemplate jdbcTemplate;
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.flyway.enabled", () -> "true");
registry.add("spring.flyway.url", postgres::getJdbcUrl);
registry.add("spring.flyway.user", postgres::getUsername);
registry.add("spring.flyway.password", postgres::getPassword);
registry.add("cameleer.server.clickhouse.url", clickhouse::getJdbcUrl);
registry.add("cameleer.server.clickhouse.username", clickhouse::getUsername);
registry.add("cameleer.server.clickhouse.password", clickhouse::getPassword);
}
}

View File

@@ -0,0 +1,31 @@
package com.cameleer.server.app;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
/**
* Loads and executes the consolidated ClickHouse init.sql schema for integration tests.
*/
public final class ClickHouseTestHelper {
private ClickHouseTestHelper() {}
public static void executeInitSql(JdbcTemplate jdbc) throws IOException {
String sql = new ClassPathResource("clickhouse/init.sql")
.getContentAsString(StandardCharsets.UTF_8);
for (String statement : sql.split(";")) {
String trimmed = statement.trim();
String withoutComments = trimmed.lines()
.filter(line -> !line.stripLeading().startsWith("--"))
.map(String::trim)
.filter(line -> !line.isEmpty())
.reduce("", (a, b) -> a + b);
if (!withoutComments.isEmpty()) {
jdbc.execute(trimmed);
}
}
}
}

View File

@@ -0,0 +1,104 @@
package com.cameleer.server.app;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.security.JwtService;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* Test utility for creating JWT-authenticated requests in integration tests.
* <p>
* Registers a test agent and issues a JWT access token that can be used
* to authenticate against protected endpoints.
*/
@Component
public class TestSecurityHelper {
private final JwtService jwtService;
private final AgentRegistryService agentRegistryService;
public TestSecurityHelper(JwtService jwtService, AgentRegistryService agentRegistryService) {
this.jwtService = jwtService;
this.agentRegistryService = agentRegistryService;
}
/**
* Registers a test agent and returns a valid JWT access token with AGENT role.
*/
public String registerTestAgent(String instanceId) {
agentRegistryService.register(instanceId, instanceId, "test-group", "default", "1.0", List.of(), Map.of());
return jwtService.createAccessToken(instanceId, "test-group", List.of("AGENT"));
}
/**
* Returns a valid JWT access token with the given roles (no agent registration).
*/
public String createToken(String subject, String application, List<String> roles) {
return jwtService.createAccessToken(subject, application, roles);
}
/**
* Returns a valid JWT access token with OPERATOR role.
*/
public String operatorToken() {
// Subject must start with "user:" for JwtAuthenticationFilter to treat it as a UI user token
return jwtService.createAccessToken("user:test-operator", "user", List.of("OPERATOR"));
}
/**
* Returns a valid JWT access token with ADMIN role.
*/
public String adminToken() {
return jwtService.createAccessToken("user:test-admin", "user", List.of("ADMIN"));
}
/**
* Returns a valid JWT access token with VIEWER role.
*/
public String viewerToken() {
return jwtService.createAccessToken("user:test-viewer", "user", List.of("VIEWER"));
}
/**
* Returns HttpHeaders with JWT Bearer authorization, protocol version, and JSON content type.
*/
public HttpHeaders authHeaders(String jwt) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + jwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
/**
* Returns HttpHeaders with JWT Bearer authorization and protocol version (no content type).
*/
public HttpHeaders authHeadersNoBody(String jwt) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + jwt);
headers.set("X-Cameleer-Protocol-Version", "1");
return headers;
}
/**
* Returns HttpHeaders with ADMIN JWT Bearer authorization, protocol version, and JSON content type.
*/
public HttpHeaders adminHeaders() {
return authHeaders(adminToken());
}
/**
* Returns HttpHeaders with bootstrap token authorization, protocol version, and JSON content type.
*/
public HttpHeaders bootstrapHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer test-bootstrap-token");
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
}

View File

@@ -0,0 +1,49 @@
package com.cameleer.server.app.admin;
import com.cameleer.server.core.admin.*;
import jakarta.servlet.http.HttpServletRequest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AuditServiceTest {
private AuditRepository mockRepository;
private AuditService auditService;
@BeforeEach
void setUp() {
mockRepository = mock(AuditRepository.class);
auditService = new AuditService(mockRepository);
}
@Test
void log_withExplicitUsername_insertsRecordWithCorrectFields() {
var request = mock(HttpServletRequest.class);
when(request.getRemoteAddr()).thenReturn("192.168.1.1");
when(request.getHeader("User-Agent")).thenReturn("Mozilla/5.0");
auditService.log("admin", "kill_query", AuditCategory.INFRA, "PID 42",
Map.of("query", "SELECT 1"), AuditResult.SUCCESS, request);
var captor = ArgumentCaptor.forClass(AuditRecord.class);
verify(mockRepository).insert(captor.capture());
var record = captor.getValue();
assertEquals("admin", record.username());
assertEquals("kill_query", record.action());
assertEquals(AuditCategory.INFRA, record.category());
assertEquals("PID 42", record.target());
assertEquals("192.168.1.1", record.ipAddress());
assertEquals("Mozilla/5.0", record.userAgent());
}
@Test
void log_withNullRequest_handlesGracefully() {
auditService.log("admin", "test", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, null);
verify(mockRepository).insert(any(AuditRecord.class));
}
}

View File

@@ -0,0 +1,127 @@
package com.cameleer.server.app.agent;
import com.cameleer.server.app.security.Ed25519SigningServiceImpl;
import com.cameleer.server.core.security.Ed25519SigningService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for SsePayloadSigner.
* Uses real Ed25519SigningServiceImpl (no Spring context needed).
*/
class SsePayloadSignerTest {
private Ed25519SigningService signingService;
private ObjectMapper objectMapper;
private SsePayloadSigner signer;
@BeforeEach
void setUp() {
signingService = Ed25519SigningServiceImpl.ephemeral();
objectMapper = new ObjectMapper();
signer = new SsePayloadSigner(signingService, objectMapper);
}
@Test
void signPayload_addsSignatureFieldToJson() throws Exception {
String payload = "{\"key\":\"value\",\"count\":42}";
String signed = signer.signPayload(payload);
JsonNode node = objectMapper.readTree(signed);
assertThat(node.has("signature")).isTrue();
assertThat(node.get("key").asText()).isEqualTo("value");
assertThat(node.get("count").asInt()).isEqualTo(42);
}
@Test
void signPayload_signatureIsBase64Encoded() throws Exception {
String payload = "{\"key\":\"value\"}";
String signed = signer.signPayload(payload);
JsonNode node = objectMapper.readTree(signed);
String signature = node.get("signature").asText();
// Should not throw -- valid Base64
byte[] decoded = Base64.getDecoder().decode(signature);
assertThat(decoded).isNotEmpty();
}
@Test
void signPayload_signatureVerifiesAgainstPublicKey() throws Exception {
String payload = "{\"key\":\"value\",\"nested\":{\"a\":1}}";
String signed = signer.signPayload(payload);
JsonNode node = objectMapper.readTree(signed);
String signatureBase64 = node.get("signature").asText();
// Verify signature against original payload (before signature was added)
byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
PublicKey publicKey = loadPublicKey(signingService.getPublicKeyBase64());
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(payload.getBytes(StandardCharsets.UTF_8));
assertThat(verifier.verify(signatureBytes)).isTrue();
}
@Test
void signPayload_signatureIsOverOriginalPayloadWithoutSignatureField() throws Exception {
String payload = "{\"data\":\"test\"}";
String signed = signer.signPayload(payload);
// Remove signature field and verify the original payload was what was signed
JsonNode node = objectMapper.readTree(signed);
String signatureBase64 = node.get("signature").asText();
byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
PublicKey publicKey = loadPublicKey(signingService.getPublicKeyBase64());
// Verify against original payload string (not reconstructed from parsed JSON)
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(payload.getBytes(StandardCharsets.UTF_8));
assertThat(verifier.verify(signatureBytes))
.as("Signature should be computed over the original JSON string")
.isTrue();
}
@Test
void signPayload_nullPayloadReturnsNull() {
String result = signer.signPayload(null);
assertThat(result).isNull();
}
@Test
void signPayload_emptyPayloadReturnsEmpty() {
String result = signer.signPayload("");
assertThat(result).isEmpty();
}
@Test
void signPayload_blankPayloadReturnsAsIs() {
String result = signer.signPayload(" ");
assertThat(result).isEqualTo(" ");
}
private PublicKey loadPublicKey(String base64PublicKey) throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(base64PublicKey);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
return keyFactory.generatePublic(keySpec);
}
}

View File

@@ -0,0 +1,176 @@
package com.cameleer.server.app.controller;
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.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.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class AgentCommandControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String agentJwt;
private String operatorJwt;
@BeforeEach
void setUp() {
agentJwt = securityHelper.registerTestAgent("test-agent-command-it");
operatorJwt = securityHelper.operatorToken();
}
private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """
{
"instanceId": "%s",
"applicationId": "%s",
"version": "1.0.0",
"routeIds": ["route-1"],
"capabilities": {}
}
""".formatted(agentId, application);
return restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, securityHelper.bootstrapHeaders()),
String.class);
}
@Test
void sendCommandToAgent_returns202WithCommandId() throws Exception {
String agentId = "cmd-it-single-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Command Agent", "test-group");
String commandJson = """
{"type": "config-update", "payload": {"key": "value"}}
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands",
new HttpEntity<>(commandJson, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.has("commandId")).isTrue();
assertThat(body.get("commandId").asText()).isNotBlank();
assertThat(body.get("status").asText()).isIn("PENDING", "DELIVERED");
}
@Test
void sendGroupCommand_returns202WithTargetCount() throws Exception {
String group = "cmd-it-group-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent("agent-g1-" + group, "Group Agent 1", group);
registerAgent("agent-g2-" + group, "Group Agent 2", group);
String commandJson = """
{"type": "deep-trace", "payload": {"correlationId": "group-trace-1"}}
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/groups/" + group + "/commands",
new HttpEntity<>(commandJson, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("targetCount").asInt()).isEqualTo(2);
assertThat(body.get("commandIds").isArray()).isTrue();
assertThat(body.get("commandIds").size()).isEqualTo(2);
}
@Test
void broadcastCommand_returns202WithLiveAgentCount() throws Exception {
String agentId = "cmd-it-broadcast-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Broadcast Agent", "broadcast-group");
String commandJson = """
{"type": "replay", "payload": {"exchangeId": "ex-broadcast"}}
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/commands",
new HttpEntity<>(commandJson, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("targetCount").asInt()).isGreaterThanOrEqualTo(1);
assertThat(body.get("commandIds").isArray()).isTrue();
}
@Test
void acknowledgeCommand_returns200() throws Exception {
String agentId = "cmd-it-ack-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Ack Agent", "test-group");
String commandJson = """
{"type": "config-update", "payload": {"key": "ack-test"}}
""";
ResponseEntity<String> cmdResponse = restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands",
new HttpEntity<>(commandJson, securityHelper.authHeaders(operatorJwt)),
String.class);
JsonNode cmdBody = objectMapper.readTree(cmdResponse.getBody());
String commandId = cmdBody.get("commandId").asText();
ResponseEntity<Void> ackResponse = restTemplate.exchange(
"/api/v1/agents/" + agentId + "/commands/" + commandId + "/ack",
HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeadersNoBody(agentJwt)),
Void.class);
assertThat(ackResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void acknowledgeUnknownCommand_returns404() {
String agentId = "cmd-it-ack-unknown-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Ack Unknown Agent", "test-group");
ResponseEntity<Void> response = restTemplate.exchange(
"/api/v1/agents/" + agentId + "/commands/nonexistent-cmd-id/ack",
HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeadersNoBody(agentJwt)),
Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void sendCommandToUnregisteredAgent_returns404() {
String commandJson = """
{"type": "config-update", "payload": {"key": "value"}}
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/nonexistent-agent-xyz/commands",
new HttpEntity<>(commandJson, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}

View File

@@ -0,0 +1,156 @@
package com.cameleer.server.app.controller;
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.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.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
class AgentRegistrationControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String jwt;
private String viewerJwt;
@BeforeEach
void setUp() {
jwt = securityHelper.registerTestAgent("test-agent-registration-it");
viewerJwt = securityHelper.viewerToken();
}
private ResponseEntity<String> registerAgent(String agentId, String name) {
String json = """
{
"instanceId": "%s",
"applicationId": "test-group",
"version": "1.0.0",
"routeIds": ["route-1", "route-2"],
"capabilities": {"tracing": true}
}
""".formatted(agentId);
return restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, securityHelper.bootstrapHeaders()),
String.class);
}
@Test
void registerNewAgent_returns200WithAgentIdAndSseEndpoint() throws Exception {
ResponseEntity<String> response = registerAgent("agent-it-1", "IT Agent 1");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("instanceId").asText()).isEqualTo("agent-it-1");
assertThat(body.get("sseEndpoint").asText()).isEqualTo("/api/v1/agents/agent-it-1/events");
assertThat(body.get("heartbeatIntervalMs").asLong()).isGreaterThan(0);
assertThat(body.has("serverPublicKey")).isTrue();
assertThat(body.get("serverPublicKey").asText()).isNotEmpty();
assertThat(body.has("accessToken")).isTrue();
assertThat(body.get("accessToken").asText()).isNotEmpty();
assertThat(body.has("refreshToken")).isTrue();
assertThat(body.get("refreshToken").asText()).isNotEmpty();
}
@Test
void reRegisterSameAgent_returns200WithLiveState() throws Exception {
registerAgent("agent-it-reregister", "First Registration");
ResponseEntity<String> response = registerAgent("agent-it-reregister", "Second Registration");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("instanceId").asText()).isEqualTo("agent-it-reregister");
}
@Test
void heartbeatKnownAgent_returns200() {
registerAgent("agent-it-hb", "HB Agent");
ResponseEntity<Void> response = restTemplate.exchange(
"/api/v1/agents/agent-it-hb/heartbeat",
HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void heartbeatUnknownAgent_returns404() {
ResponseEntity<Void> response = restTemplate.exchange(
"/api/v1/agents/unknown-agent-xyz/heartbeat",
HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void listAllAgents_returnsBothAgents() throws Exception {
registerAgent("agent-it-list-1", "List Agent 1");
registerAgent("agent-it-list-2", "List Agent 2");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
assertThat(body.size()).isGreaterThanOrEqualTo(2);
}
@Test
void listAgentsByStatus_filtersCorrectly() throws Exception {
registerAgent("agent-it-filter", "Filter Agent");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents?status=LIVE",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
for (JsonNode agent : body) {
assertThat(agent.get("status").asText()).isEqualTo("LIVE");
}
}
@Test
void listAgentsWithInvalidStatus_returns400() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents?status=INVALID",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}

View File

@@ -0,0 +1,266 @@
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.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.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.ResponseEntity;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
class AgentSseControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@LocalServerPort
private int port;
private String jwt;
private String operatorJwt;
@BeforeEach
void setUp() {
jwt = securityHelper.registerTestAgent("test-agent-sse-it");
operatorJwt = securityHelper.operatorToken();
}
private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """
{
"instanceId": "%s",
"applicationId": "%s",
"version": "1.0.0",
"routeIds": ["route-1"],
"capabilities": {}
}
""".formatted(agentId, application);
return restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, securityHelper.bootstrapHeaders()),
String.class);
}
private ResponseEntity<String> sendCommand(String agentId, String type, String payloadJson) {
String json = """
{"type": "%s", "payload": %s}
""".formatted(type, payloadJson);
return restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands",
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
String.class);
}
/**
* Opens an SSE stream via java.net.http.HttpClient with JWT query param auth.
*/
private SseStream openSseStream(String agentId) {
return openSseStream(agentId, null);
}
private SseStream openSseStream(String agentId, String lastEventId) {
List<String> lines = new ArrayList<>();
CountDownLatch connected = new CountDownLatch(1);
AtomicInteger statusCode = new AtomicInteger(0);
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
// Use JWT query parameter for SSE authentication
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/api/v1/agents/" + agentId + "/events?token=" + jwt))
.header("Accept", "text/event-stream")
.GET();
if (lastEventId != null) {
requestBuilder.header("Last-Event-ID", lastEventId);
}
HttpRequest request = requestBuilder.build();
CompletableFuture<Void> future = client.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())
.thenAccept(response -> {
statusCode.set(response.statusCode());
connected.countDown();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body()))) {
String line;
while ((line = reader.readLine()) != null) {
synchronized (lines) {
lines.add(line);
}
}
} catch (Exception e) {
// Stream closed -- expected
}
});
return new SseStream(lines, future, connected, statusCode);
}
private record SseStream(List<String> lines, CompletableFuture<Void> future,
CountDownLatch connected, AtomicInteger statusCode) {
List<String> snapshot() {
synchronized (lines) {
return new ArrayList<>(lines);
}
}
boolean awaitConnection(long timeoutMs) throws InterruptedException {
return connected.await(timeoutMs, TimeUnit.MILLISECONDS);
}
}
@Test
void sseConnect_registeredAgent_returnsEventStream() throws Exception {
String agentId = "sse-it-connect-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "SSE Connect Agent", "test-group");
SseStream stream = openSseStream(agentId);
assertThat(stream.awaitConnection(5000)).isTrue();
assertThat(stream.statusCode().get()).isEqualTo(200);
}
@Test
void sseConnect_unknownAgent_returns404() throws Exception {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/api/v1/agents/unknown-sse-agent/events?token=" + jwt))
.header("Accept", "text/event-stream")
.GET()
.build();
CompletableFuture<Integer> statusFuture = client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::statusCode);
int status = statusFuture.get(5, TimeUnit.SECONDS);
assertThat(status).isEqualTo(404);
}
@Test
void configUpdateDelivery_receivedViaSseStream() throws Exception {
String agentId = "sse-it-config-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Config Update Agent", "test-group");
SseStream stream = openSseStream(agentId);
stream.awaitConnection(5000);
await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
.ignoreExceptions()
.until(() -> {
sendCommand(agentId, "config-update", "{\"key\":\"value\"}");
List<String> lines = stream.snapshot();
return lines.stream().anyMatch(l -> l.contains("event:config-update"));
});
List<String> lines = stream.snapshot();
assertThat(lines).anyMatch(l -> l.contains("event:config-update"));
assertThat(lines).anyMatch(l -> l.startsWith("id:"));
assertThat(lines).anyMatch(l -> l.contains("\"key\":\"value\""));
}
@Test
void deepTraceDelivery_receivedViaSseStream() throws Exception {
String agentId = "sse-it-trace-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Deep Trace Agent", "test-group");
SseStream stream = openSseStream(agentId);
stream.awaitConnection(5000);
await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
.ignoreExceptions()
.until(() -> {
sendCommand(agentId, "deep-trace", "{\"correlationId\":\"test-123\"}");
List<String> lines = stream.snapshot();
return lines.stream().anyMatch(l -> l.contains("event:deep-trace"));
});
List<String> lines = stream.snapshot();
assertThat(lines).anyMatch(l -> l.contains("event:deep-trace"));
assertThat(lines).anyMatch(l -> l.contains("test-123"));
}
@Test
void replayDelivery_receivedViaSseStream() throws Exception {
String agentId = "sse-it-replay-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Replay Agent", "test-group");
SseStream stream = openSseStream(agentId);
stream.awaitConnection(5000);
await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
.ignoreExceptions()
.until(() -> {
sendCommand(agentId, "replay", "{\"exchangeId\":\"ex-456\"}");
List<String> lines = stream.snapshot();
return lines.stream().anyMatch(l -> l.contains("event:replay"));
});
List<String> lines = stream.snapshot();
assertThat(lines).anyMatch(l -> l.contains("event:replay"));
assertThat(lines).anyMatch(l -> l.contains("ex-456"));
}
@Test
void pingKeepalive_receivedViaSseStream() throws Exception {
String agentId = "sse-it-ping-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Ping Agent", "test-group");
SseStream stream = openSseStream(agentId);
stream.awaitConnection(5000);
await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
.ignoreExceptions()
.until(() -> {
List<String> lines = stream.snapshot();
return lines.stream().anyMatch(l -> l.contains(":ping"));
});
List<String> lines = stream.snapshot();
assertThat(lines).anyMatch(l -> l.contains(":ping"));
}
@Test
void lastEventIdHeader_connectionSucceeds() throws Exception {
String agentId = "sse-it-lastid-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Last-Event-ID Agent", "test-group");
SseStream stream = openSseStream(agentId, "some-previous-event-id");
assertThat(stream.awaitConnection(5000)).isTrue();
assertThat(stream.statusCode().get()).isEqualTo(200);
}
}

View File

@@ -0,0 +1,155 @@
package com.cameleer.server.app.controller;
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.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.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.core.io.ByteArrayResource;
import static org.assertj.core.api.Assertions.assertThat;
class AppControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String operatorJwt;
private String viewerJwt;
private String defaultEnvId;
@BeforeEach
void setUp() throws Exception {
operatorJwt = securityHelper.operatorToken();
viewerJwt = securityHelper.viewerToken();
// Clean up test apps
jdbcTemplate.update("DELETE FROM apps");
// Get default environment ID
ResponseEntity<String> envResponse = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(securityHelper.adminToken())),
String.class);
JsonNode envs = objectMapper.readTree(envResponse.getBody());
for (JsonNode env : envs) {
if ("default".equals(env.path("slug").asText())) {
defaultEnvId = env.path("id").asText();
break;
}
}
assertThat(defaultEnvId).isNotNull();
}
@Test
void createApp_asOperator_returns201() throws Exception {
String json = String.format("""
{"environmentId": "%s", "slug": "my-app", "displayName": "My App"}
""", defaultEnvId);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("slug").asText()).isEqualTo("my-app");
assertThat(body.path("displayName").asText()).isEqualTo("My App");
assertThat(body.path("environmentId").asText()).isEqualTo(defaultEnvId);
}
@Test
void listApps_asOperator_returnsCreatedApp() throws Exception {
// Create an app first
String json = String.format("""
{"environmentId": "%s", "slug": "list-test", "displayName": "List Test"}
""", defaultEnvId);
restTemplate.exchange(
"/api/v1/apps", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
String.class);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps?environmentId=" + defaultEnvId, HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
assertThat(body.size()).isGreaterThanOrEqualTo(1);
}
@Test
void createApp_asViewer_returns403() {
String json = String.format("""
{"environmentId": "%s", "slug": "viewer-app", "displayName": "Viewer App"}
""", defaultEnvId);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(viewerJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void uploadJar_asOperator_returns201() throws Exception {
// Create app
String json = String.format("""
{"environmentId": "%s", "slug": "jar-test", "displayName": "JAR Test"}
""", defaultEnvId);
ResponseEntity<String> createResponse = restTemplate.exchange(
"/api/v1/apps", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
String.class);
String appId = objectMapper.readTree(createResponse.getBody()).path("id").asText();
// Upload JAR (fake content)
byte[] jarContent = "fake-jar-content".getBytes();
ByteArrayResource resource = new ByteArrayResource(jarContent) {
@Override
public String getFilename() {
return "test-app.jar";
}
};
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + operatorJwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps/" + appId + "/versions", HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
JsonNode version = objectMapper.readTree(response.getBody());
assertThat(version.path("version").asInt()).isEqualTo(1);
assertThat(version.path("jarChecksum").asText()).isNotEmpty();
assertThat(version.path("jarFilename").asText()).isEqualTo("test-app.jar");
}
}

View File

@@ -0,0 +1,112 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class AuditLogControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@Autowired
private AuditService auditService;
private String adminJwt;
private String viewerJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
viewerJwt = securityHelper.viewerToken();
}
@Test
void getAuditLog_asAdmin_returns200() throws Exception {
// Insert a test audit entry
auditService.log("test-admin", "test_action", AuditCategory.CONFIG,
"test-target", Map.of("key", "value"), AuditResult.SUCCESS, null);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/audit", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.has("items")).isTrue();
assertThat(body.has("totalCount")).isTrue();
assertThat(body.get("totalCount").asLong()).isGreaterThanOrEqualTo(1);
}
@Test
void getAuditLog_asViewer_returns403() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/audit", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void getAuditLog_withCategoryFilter_returnsFilteredResults() throws Exception {
auditService.log("filter-test", "infra_action", AuditCategory.INFRA,
"infra-target", null, AuditResult.SUCCESS, null);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/audit?category=INFRA", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("items").isArray()).isTrue();
}
@Test
void getAuditLog_withPagination_respectsPageSize() throws Exception {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/audit?page=0&size=5", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("pageSize").asInt()).isEqualTo(5);
}
@Test
void getAuditLog_maxPageSizeEnforced() throws Exception {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/audit?size=500", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("pageSize").asInt()).isEqualTo(100);
}
}

View File

@@ -0,0 +1,95 @@
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.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.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests backpressure behavior when the metrics write buffer is full.
* <p>
* Execution and diagram ingestion are now synchronous (no buffers).
* Only the metrics pipeline still uses a write buffer with backpressure.
*/
@TestPropertySource(properties = {
"ingestion.buffer-capacity=5",
"ingestion.batch-size=5",
"ingestion.flush-interval-ms=60000" // 60s -- effectively no flush during test
})
class BackpressureIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private IngestionService ingestionService;
@Autowired
private TestSecurityHelper securityHelper;
private HttpHeaders authHeaders;
@BeforeEach
void setUp() {
String jwt = securityHelper.registerTestAgent("test-agent-backpressure-it");
authHeaders = securityHelper.authHeaders(jwt);
}
@Test
void whenMetricsBufferFull_returns503WithRetryAfter() {
// Fill the metrics buffer completely with a batch of 5
String batchJson = """
[
{"instanceId":"bp-agent","collectedAt":"2026-03-11T10:00:00Z","metricName":"test.metric","metricValue":1.0,"tags":{}},
{"instanceId":"bp-agent","collectedAt":"2026-03-11T10:00:01Z","metricName":"test.metric","metricValue":2.0,"tags":{}},
{"instanceId":"bp-agent","collectedAt":"2026-03-11T10:00:02Z","metricName":"test.metric","metricValue":3.0,"tags":{}},
{"instanceId":"bp-agent","collectedAt":"2026-03-11T10:00:03Z","metricName":"test.metric","metricValue":4.0,"tags":{}},
{"instanceId":"bp-agent","collectedAt":"2026-03-11T10:00:04Z","metricName":"test.metric","metricValue":5.0,"tags":{}}
]
""";
ResponseEntity<String> batchResponse = restTemplate.postForEntity(
"/api/v1/data/metrics",
new HttpEntity<>(batchJson, authHeaders),
String.class);
assertThat(batchResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
// Now buffer should be full -- next POST should get 503
String overflowJson = """
[{"instanceId":"bp-agent","collectedAt":"2026-03-11T10:00:05Z","metricName":"test.metric","metricValue":6.0,"tags":{}}]
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/metrics",
new HttpEntity<>(overflowJson, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
assertThat(response.getHeaders().getFirst("Retry-After")).isNotNull();
}
@Test
void executionIngestion_isSynchronous_returnsAccepted() {
String json = """
{"routeId":"bp-sync","exchangeId":"bp-sync-e","status":"COMPLETED","startTime":"2026-03-11T10:00:00Z","durationMs":100,"processors":[]}
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>(json, authHeaders),
String.class);
// Synchronous ingestion always returns 202 (no buffer to overflow)
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
}
}

View File

@@ -0,0 +1,64 @@
package com.cameleer.server.app.controller;
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.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.*;
import static org.assertj.core.api.Assertions.assertThat;
class ClaimMappingAdminControllerIT extends AbstractPostgresIT {
@Autowired private TestRestTemplate restTemplate;
@Autowired private ObjectMapper objectMapper;
@Autowired private TestSecurityHelper securityHelper;
private HttpHeaders adminHeaders;
@BeforeEach
void setUp() {
adminHeaders = securityHelper.adminHeaders();
}
@Test
void createAndListRules() throws Exception {
String body = """
{"claim":"groups","matchType":"contains","matchValue":"admins","action":"assignRole","target":"ADMIN","priority":0}
""";
var createResponse = restTemplate.exchange("/api/v1/admin/claim-mappings",
HttpMethod.POST, new HttpEntity<>(body, adminHeaders), String.class);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
var listResponse = restTemplate.exchange("/api/v1/admin/claim-mappings",
HttpMethod.GET, new HttpEntity<>(adminHeaders), String.class);
assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode rules = objectMapper.readTree(listResponse.getBody());
assertThat(rules.isArray()).isTrue();
assertThat(rules.size()).isGreaterThanOrEqualTo(1);
}
@Test
void deleteRule() throws Exception {
String body = """
{"claim":"dept","matchType":"equals","matchValue":"eng","action":"assignRole","target":"VIEWER","priority":0}
""";
var createResponse = restTemplate.exchange("/api/v1/admin/claim-mappings",
HttpMethod.POST, new HttpEntity<>(body, adminHeaders), String.class);
JsonNode created = objectMapper.readTree(createResponse.getBody());
String id = created.get("id").asText();
var deleteResponse = restTemplate.exchange("/api/v1/admin/claim-mappings/" + id,
HttpMethod.DELETE, new HttpEntity<>(adminHeaders), Void.class);
assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
var getResponse = restTemplate.exchange("/api/v1/admin/claim-mappings/" + id,
HttpMethod.GET, new HttpEntity<>(adminHeaders), String.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}

View File

@@ -0,0 +1,109 @@
package com.cameleer.server.app.controller;
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.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.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
class DatabaseAdminControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String adminJwt;
private String viewerJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
viewerJwt = securityHelper.viewerToken();
}
@Test
void getStatus_asAdmin_returns200WithConnected() throws Exception {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/database/status", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("connected").asBoolean()).isTrue();
assertThat(body.get("version").asText()).contains("PostgreSQL");
assertThat(body.has("schema")).isTrue();
}
@Test
void getStatus_asViewer_returns403() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/database/status", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void getPool_asAdmin_returns200WithPoolStats() throws Exception {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/database/pool", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.has("activeConnections")).isTrue();
assertThat(body.has("idleConnections")).isTrue();
assertThat(body.get("maxPoolSize").asInt()).isGreaterThan(0);
}
@Test
void getTables_asAdmin_returns200WithTableList() throws Exception {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/database/tables", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
}
@Test
void getQueries_asAdmin_returns200() throws Exception {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/database/queries", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
}
@Test
void killQuery_unknownPid_returns404() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/database/queries/999999/kill", HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}

View File

@@ -0,0 +1,145 @@
package com.cameleer.server.app.controller;
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.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.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.core.io.ByteArrayResource;
import static org.assertj.core.api.Assertions.assertThat;
class DeploymentControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String operatorJwt;
private String adminJwt;
private String defaultEnvId;
private String appId;
private String versionId;
@BeforeEach
void setUp() throws Exception {
operatorJwt = securityHelper.operatorToken();
adminJwt = securityHelper.adminToken();
// Clean up
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
// Get default environment ID
ResponseEntity<String> envResponse = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
JsonNode envs = objectMapper.readTree(envResponse.getBody());
for (JsonNode env : envs) {
if ("default".equals(env.path("slug").asText())) {
defaultEnvId = env.path("id").asText();
break;
}
}
// Create app
String appJson = String.format("""
{"environmentId": "%s", "slug": "deploy-test", "displayName": "Deploy Test"}
""", defaultEnvId);
ResponseEntity<String> appResponse = restTemplate.exchange(
"/api/v1/apps", HttpMethod.POST,
new HttpEntity<>(appJson, securityHelper.authHeaders(operatorJwt)),
String.class);
appId = objectMapper.readTree(appResponse.getBody()).path("id").asText();
// Upload a JAR version
byte[] jarContent = "fake-jar-for-deploy".getBytes();
ByteArrayResource resource = new ByteArrayResource(jarContent) {
@Override
public String getFilename() {
return "deploy-test.jar";
}
};
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + operatorJwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
ResponseEntity<String> versionResponse = restTemplate.exchange(
"/api/v1/apps/" + appId + "/versions", HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);
versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText();
}
@Test
void deploy_asOperator_returns202() throws Exception {
// Deploy creates a record; the async executor will fail (no Docker) but the record should exist
String json = String.format("""
{"appVersionId": "%s", "environmentId": "%s"}
""", versionId, defaultEnvId);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps/" + appId + "/deployments", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("status").asText()).isEqualTo("STARTING");
assertThat(body.path("containerName").asText()).isEqualTo("default-deploy-test");
assertThat(body.has("id")).isTrue();
}
@Test
void listDeployments_asOperator_returnsDeployments() throws Exception {
// Create a deployment first
String json = String.format("""
{"appVersionId": "%s", "environmentId": "%s"}
""", versionId, defaultEnvId);
restTemplate.exchange(
"/api/v1/apps/" + appId + "/deployments", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
String.class);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps/" + appId + "/deployments", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
assertThat(body.size()).isGreaterThanOrEqualTo(1);
}
@Test
void getDeployment_notFound_returns404() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps/" + appId + "/deployments/00000000-0000-0000-0000-000000000000",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
}

View File

@@ -0,0 +1,225 @@
package com.cameleer.server.app.controller;
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.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/**
* Integration tests for the detail and processor snapshot endpoints.
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class DetailControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private final ObjectMapper objectMapper = new ObjectMapper();
private String jwt;
private String viewerJwt;
private String seededExecutionId;
/**
* Seed a route execution with a 3-level processor tree:
* root -> [child1, child2], child2 -> [grandchild]
*/
@BeforeAll
void seedTestData() {
jwt = securityHelper.registerTestAgent("test-agent-detail-it");
viewerJwt = securityHelper.viewerToken();
String json = """
{
"routeId": "detail-test-route",
"exchangeId": "detail-ex-1",
"correlationId": "detail-corr-1",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00Z",
"endTime": "2026-03-10T10:00:01Z",
"durationMs": 1000,
"errorMessage": "",
"errorStackTrace": "",
"processors": [
{
"processorId": "root-proc",
"processorType": "split",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00Z",
"endTime": "2026-03-10T10:00:01Z",
"durationMs": 1000,
"inputBody": "root-input-body",
"outputBody": "root-output-body",
"inputHeaders": {"Content-Type": "application/json"},
"outputHeaders": {"X-Result": "ok"},
"children": [
{
"processorId": "child1-proc",
"processorType": "log",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00.100Z",
"endTime": "2026-03-10T10:00:00.200Z",
"durationMs": 100,
"inputBody": "child1-input",
"outputBody": "child1-output",
"inputHeaders": {},
"outputHeaders": {}
},
{
"processorId": "child2-proc",
"processorType": "bean",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00.200Z",
"endTime": "2026-03-10T10:00:00.800Z",
"durationMs": 600,
"inputBody": "child2-input",
"outputBody": "child2-output",
"inputHeaders": {},
"outputHeaders": {},
"children": [
{
"processorId": "grandchild-proc",
"processorType": "to",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00.300Z",
"endTime": "2026-03-10T10:00:00.700Z",
"durationMs": 400,
"inputBody": "gc-input",
"outputBody": "gc-output",
"inputHeaders": {"X-GC": "true"},
"outputHeaders": {}
}
]
}
]
}
]
}
""";
ingest(json);
// Wait for flush and get the execution_id
await().atMost(10, SECONDS).untilAsserted(() -> {
Integer count = jdbcTemplate.queryForObject(
"SELECT count(*) FROM executions WHERE route_id = 'detail-test-route'",
Integer.class);
assertThat(count).isGreaterThanOrEqualTo(1);
});
seededExecutionId = jdbcTemplate.queryForObject(
"SELECT execution_id FROM executions WHERE route_id = 'detail-test-route' LIMIT 1",
String.class);
}
@Test
void getDetail_returnsNestedProcessorTree() throws Exception {
ResponseEntity<String> response = detailGet("/" + seededExecutionId);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("executionId").asText()).isEqualTo(seededExecutionId);
assertThat(body.get("routeId").asText()).isEqualTo("detail-test-route");
assertThat(body.get("status").asText()).isEqualTo("COMPLETED");
assertThat(body.get("durationMs").asLong()).isEqualTo(1000);
// Check nested tree: 1 root
JsonNode processors = body.get("processors");
assertThat(processors).hasSize(1);
// Root has 2 children
JsonNode root = processors.get(0);
assertThat(root.get("processorId").asText()).isEqualTo("root-proc");
assertThat(root.get("processorType").asText()).isEqualTo("split");
assertThat(root.get("children")).hasSize(2);
// Child1 has no children
JsonNode child1 = root.get("children").get(0);
assertThat(child1.get("processorId").asText()).isEqualTo("child1-proc");
assertThat(child1.get("children")).isEmpty();
// Child2 has 1 grandchild
JsonNode child2 = root.get("children").get(1);
assertThat(child2.get("processorId").asText()).isEqualTo("child2-proc");
assertThat(child2.get("children")).hasSize(1);
JsonNode grandchild = child2.get("children").get(0);
assertThat(grandchild.get("processorId").asText()).isEqualTo("grandchild-proc");
assertThat(grandchild.get("children")).isEmpty();
}
@Test
void getDetail_includesDiagramContentHash() throws Exception {
ResponseEntity<String> response = detailGet("/" + seededExecutionId);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.has("diagramContentHash")).isTrue();
}
@Test
void getDetail_nonexistentId_returns404() {
ResponseEntity<String> response = detailGet("/nonexistent-execution-id");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void getProcessorSnapshot_returnsExchangeData() throws Exception {
ResponseEntity<String> response = detailGet(
"/" + seededExecutionId + "/processors/0/snapshot");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("inputBody").asText()).isEqualTo("root-input-body");
assertThat(body.get("outputBody").asText()).isEqualTo("root-output-body");
assertThat(body.get("inputHeaders").asText()).contains("Content-Type");
assertThat(body.get("outputHeaders").asText()).contains("X-Result");
}
@Test
void getProcessorSnapshot_outOfBoundsIndex_returns404() {
ResponseEntity<String> response = detailGet(
"/" + seededExecutionId + "/processors/999/snapshot");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void getProcessorSnapshot_nonexistentExecution_returns404() {
ResponseEntity<String> response = detailGet(
"/nonexistent-id/processors/0/snapshot");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
// --- Helper methods ---
private void ingest(String json) {
restTemplate.postForEntity("/api/v1/data/executions",
new HttpEntity<>(json, securityHelper.authHeaders(jwt)), String.class);
}
private ResponseEntity<String> detailGet(String path) {
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
return restTemplate.exchange(
"/api/v1/executions" + path,
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
}
}

View File

@@ -0,0 +1,103 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
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.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
class DiagramControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private HttpHeaders authHeaders;
@BeforeEach
void setUp() {
String jwt = securityHelper.registerTestAgent("test-agent-diagram-it");
authHeaders = securityHelper.authHeaders(jwt);
}
@Test
void postSingleDiagram_returns202() {
String json = """
{
"routeId": "diagram-route-1",
"description": "Test route",
"version": 1,
"nodes": [],
"edges": []
}
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/diagrams",
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
}
@Test
void postDiagram_dataAppearsAfterFlush() {
String json = """
{
"routeId": "diagram-flush-route",
"description": "Flush test",
"version": 1,
"nodes": [],
"edges": []
}
""";
restTemplate.postForEntity(
"/api/v1/data/diagrams",
new HttpEntity<>(json, authHeaders),
String.class);
await().atMost(10, SECONDS).untilAsserted(() -> {
Integer count = jdbcTemplate.queryForObject(
"SELECT count(*) FROM route_diagrams WHERE route_id = 'diagram-flush-route'",
Integer.class);
assertThat(count).isGreaterThanOrEqualTo(1);
});
}
@Test
void postArrayOfDiagrams_returns202() {
String json = """
[{
"routeId": "diagram-arr-1",
"version": 1,
"nodes": [],
"edges": []
},
{
"routeId": "diagram-arr-2",
"version": 1,
"nodes": [],
"edges": []
}]
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/diagrams",
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
}
}

View File

@@ -0,0 +1,138 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
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.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/**
* Integration tests for {@link DiagramRenderController}.
* Seeds a diagram via the ingestion endpoint, then tests rendering.
*/
class DiagramRenderControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private String jwt;
private String viewerJwt;
private String contentHash;
/**
* Seed a diagram and compute its content hash for render tests.
*/
@BeforeEach
void seedDiagram() {
jwt = securityHelper.registerTestAgent("test-agent-diagram-render-it");
viewerJwt = securityHelper.viewerToken();
String json = """
{
"routeId": "render-test-route",
"description": "Render test",
"version": 1,
"nodes": [
{"id": "n1", "type": "ENDPOINT", "label": "timer:tick"},
{"id": "n2", "type": "BEAN", "label": "myBean"},
{"id": "n3", "type": "TO", "label": "log:out"}
],
"edges": [
{"source": "n1", "target": "n2", "edgeType": "FLOW"},
{"source": "n2", "target": "n3", "edgeType": "FLOW"}
]
}
""";
restTemplate.postForEntity(
"/api/v1/data/diagrams",
new HttpEntity<>(json, securityHelper.authHeaders(jwt)),
String.class);
// Wait for flush to storage and retrieve the content hash
await().atMost(10, SECONDS).untilAsserted(() -> {
String hash = jdbcTemplate.queryForObject(
"SELECT content_hash FROM route_diagrams WHERE route_id = 'render-test-route' LIMIT 1",
String.class);
assertThat(hash).isNotNull();
contentHash = hash;
});
}
@Test
void getSvg_withAcceptHeader_returnsSvg() {
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
headers.set("Accept", "image/svg+xml");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class,
contentHash);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getHeaders().getContentType().toString()).contains("svg");
assertThat(response.getBody()).contains("<svg");
}
@Test
void getJson_withAcceptHeader_returnsJson() {
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
headers.set("Accept", "application/json");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class,
contentHash);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).contains("nodes");
assertThat(response.getBody()).contains("edges");
}
@Test
void getNonExistentHash_returns404() {
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
headers.set("Accept", "image/svg+xml");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class,
"nonexistent-hash-12345");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void getWithNoAcceptHeader_defaultsToSvg() {
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class,
contentHash);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).contains("<svg");
}
}

View File

@@ -0,0 +1,183 @@
package com.cameleer.server.app.controller;
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.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.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
class EnvironmentAdminControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String adminJwt;
private String viewerJwt;
private String operatorJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
viewerJwt = securityHelper.viewerToken();
operatorJwt = securityHelper.operatorToken();
// Clean up test environments (keep default)
jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
}
@Test
void listEnvironments_asAdmin_returnsDefaultEnvironment() throws Exception {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
assertThat(body.size()).isGreaterThanOrEqualTo(1);
// Default environment should exist
boolean hasDefault = false;
for (JsonNode env : body) {
if ("default".equals(env.path("slug").asText())) {
hasDefault = true;
break;
}
}
assertThat(hasDefault).isTrue();
}
@Test
void listEnvironments_asViewer_returns403() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void createEnvironment_asAdmin_returns201() throws Exception {
String json = """
{"slug": "staging", "displayName": "Staging", "production": false}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("slug").asText()).isEqualTo("staging");
assertThat(body.path("displayName").asText()).isEqualTo("Staging");
assertThat(body.path("production").asBoolean()).isFalse();
assertThat(body.path("enabled").asBoolean()).isTrue();
assertThat(body.has("id")).isTrue();
}
@Test
void updateEnvironment_asAdmin_returns200() throws Exception {
// Create an environment first
String createJson = """
{"slug": "update-test", "displayName": "Before", "production": false}
""";
ResponseEntity<String> createResponse = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(createJson, securityHelper.authHeaders(adminJwt)),
String.class);
JsonNode created = objectMapper.readTree(createResponse.getBody());
String envId = created.path("id").asText();
// Update it
String updateJson = """
{"displayName": "After", "production": true, "enabled": false}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/" + envId, HttpMethod.PUT,
new HttpEntity<>(updateJson, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("displayName").asText()).isEqualTo("After");
assertThat(body.path("production").asBoolean()).isTrue();
assertThat(body.path("enabled").asBoolean()).isFalse();
}
@Test
void createEnvironment_duplicateSlug_returns400() {
String json = """
{"slug": "dup-test", "displayName": "Dup Test"}
""";
// First create should succeed
restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
// Second create with same slug should fail
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void deleteEnvironment_defaultEnv_returns400() throws Exception {
// Find the default environment ID
ResponseEntity<String> listResponse = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
JsonNode envs = objectMapper.readTree(listResponse.getBody());
String defaultId = null;
for (JsonNode env : envs) {
if ("default".equals(env.path("slug").asText())) {
defaultId = env.path("id").asText();
break;
}
}
assertThat(defaultId).isNotNull();
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/" + defaultId, HttpMethod.DELETE,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void createEnvironment_asOperator_returns403() {
String json = """
{"slug": "op-test", "displayName": "Op Test"}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
}

View File

@@ -0,0 +1,141 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
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.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
class ExecutionControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private HttpHeaders authHeaders;
@BeforeEach
void setUp() {
String jwt = securityHelper.registerTestAgent("test-agent-execution-it");
authHeaders = securityHelper.authHeaders(jwt);
}
@Test
void postSingleExecution_returns202() {
String json = """
{
"routeId": "route-1",
"exchangeId": "exchange-1",
"correlationId": "corr-1",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:01Z",
"durationMs": 1000,
"errorMessage": "",
"errorStackTrace": "",
"processors": []
}
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
}
@Test
void postArrayOfExecutions_returns202() {
String json = """
[{
"routeId": "route-2",
"exchangeId": "exchange-2",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:01Z",
"durationMs": 1000,
"processors": []
},
{
"routeId": "route-3",
"exchangeId": "exchange-3",
"status": "FAILED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:02Z",
"durationMs": 2000,
"errorMessage": "Something went wrong",
"processors": []
}]
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
}
@Test
void postExecution_dataAppearsAfterFlush() {
String json = """
{
"routeId": "flush-test-route",
"exchangeId": "flush-exchange-1",
"correlationId": "flush-corr-1",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:01Z",
"durationMs": 1000,
"processors": []
}
""";
restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>(json, authHeaders),
String.class);
await().atMost(10, SECONDS).untilAsserted(() -> {
Integer count = jdbcTemplate.queryForObject(
"SELECT count(*) FROM executions WHERE route_id = 'flush-test-route'",
Integer.class);
assertThat(count).isGreaterThanOrEqualTo(1);
});
}
@Test
void postExecution_unknownFieldsAccepted() {
String json = """
{
"routeId": "route-unk",
"exchangeId": "exchange-unk",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"durationMs": 500,
"unknownField": "should-be-ignored",
"anotherUnknown": 42,
"processors": []
}
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
}
}

View File

@@ -0,0 +1,53 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
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.HttpHeaders;
import org.springframework.http.HttpMethod;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration test for forward compatibility (API-05).
* Verifies that unknown JSON fields in request bodies do not cause deserialization errors.
*/
class ForwardCompatIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private String jwt;
@BeforeEach
void setUp() {
jwt = securityHelper.registerTestAgent("test-agent-forward-compat-it");
}
@Test
void unknownFieldsInRequestBodyDoNotCauseError() {
String jsonWithUnknownFields = """
{
"futureField": "value",
"anotherUnknown": 42
}
""";
HttpHeaders headers = securityHelper.authHeaders(jwt);
var entity = new HttpEntity<>(jsonWithUnknownFields, headers);
var response = restTemplate.exchange(
"/api/v1/data/executions", HttpMethod.POST, entity, String.class);
assertThat(response.getStatusCode().value())
.as("Unknown JSON fields must not cause 400 or 422 deserialization error")
.isNotIn(400, 422);
}
}

View File

@@ -0,0 +1,31 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for the health endpoint.
*/
class HealthControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Test
void healthEndpointReturns200WithStatus() {
var response = restTemplate.getForEntity("/api/v1/health", String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).contains("status");
}
@Test
void healthEndpointDoesNotRequireProtocolVersionHeader() {
// Health should be accessible without X-Cameleer-Protocol-Version header
var response = restTemplate.getForEntity("/api/v1/health", String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
}
}

View File

@@ -0,0 +1,78 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
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.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
class MetricsControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private HttpHeaders authHeaders;
@BeforeEach
void setUp() {
String jwt = securityHelper.registerTestAgent("test-agent-metrics-it");
authHeaders = securityHelper.authHeaders(jwt);
}
@Test
void postMetrics_returns202() {
String json = """
[{
"instanceId": "agent-1",
"collectedAt": "2026-03-11T10:00:00Z",
"metricName": "cpu.usage",
"metricValue": 75.5,
"tags": {"host": "server-1"}
}]
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/metrics",
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
}
@Test
void postMetrics_dataAppearsAfterFlush() {
String json = """
[{
"instanceId": "agent-flush-test",
"collectedAt": "2026-03-11T10:00:00Z",
"metricName": "memory.used",
"metricValue": 1024.0,
"tags": {}
}]
""";
restTemplate.postForEntity(
"/api/v1/data/metrics",
new HttpEntity<>(json, authHeaders),
String.class);
await().atMost(10, SECONDS).untilAsserted(() -> {
Integer count = jdbcTemplate.queryForObject(
"SELECT count(*) FROM agent_metrics WHERE instance_id = 'agent-flush-test'",
Integer.class);
assertThat(count).isGreaterThanOrEqualTo(1);
});
}
}

View File

@@ -0,0 +1,32 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for OpenAPI documentation endpoints.
*/
class OpenApiIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Test
void apiDocsReturnsOpenApiSpec() {
var response = restTemplate.getForEntity("/api/v1/api-docs", String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
assertThat(response.getBody()).contains("openapi");
assertThat(response.getBody()).contains("paths");
}
@Test
void swaggerUiIsAccessible() {
var response = restTemplate.getForEntity("/api/v1/swagger-ui/index.html", String.class);
// Swagger UI may return 200 directly or 302 redirect
assertThat(response.getStatusCode().value()).isIn(200, 302);
}
}

View File

@@ -0,0 +1,391 @@
package com.cameleer.server.app.controller;
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.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.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/**
* Integration tests for the search controller endpoints.
* Tests all filter types independently and in combination.
*/
class SearchControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private final ObjectMapper objectMapper = new ObjectMapper();
private static String jwt;
private static String viewerJwt;
private static boolean seeded;
/**
* Seed test data: Insert executions with varying statuses, times, durations,
* correlationIds, error messages, and exchange snapshot data.
*/
@BeforeEach
void seedTestData() {
if (seeded) return;
seeded = true;
jwt = securityHelper.registerTestAgent("test-agent-search-it");
viewerJwt = securityHelper.viewerToken();
// Execution 1: COMPLETED, short duration, no errors
ingest("""
{
"routeId": "search-route-1",
"exchangeId": "ex-search-1",
"correlationId": "corr-alpha",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00Z",
"endTime": "2026-03-10T10:00:00.050Z",
"durationMs": 50,
"errorMessage": "",
"errorStackTrace": "",
"processors": [
{
"processorId": "proc-1",
"processorType": "log",
"status": "COMPLETED",
"startTime": "2026-03-10T10:00:00Z",
"endTime": "2026-03-10T10:00:00.050Z",
"durationMs": 50,
"inputBody": "customer-123 order data",
"outputBody": "processed customer-123",
"inputHeaders": {"Content-Type": "application/json"},
"outputHeaders": {"X-Trace": "abc"}
}
]
}
""");
// Execution 2: FAILED with NullPointerException, medium duration
ingest("""
{
"routeId": "search-route-2",
"exchangeId": "ex-search-2",
"correlationId": "corr-beta",
"status": "FAILED",
"startTime": "2026-03-10T12:00:00Z",
"endTime": "2026-03-10T12:00:00.200Z",
"durationMs": 200,
"errorMessage": "NullPointerException in OrderService",
"errorStackTrace": "java.lang.NullPointerException\\n at com.example.OrderService.process(OrderService.java:42)",
"processors": []
}
""");
// Execution 3: RUNNING, long duration, different time window
ingest("""
{
"routeId": "search-route-3",
"exchangeId": "ex-search-3",
"correlationId": "corr-gamma",
"status": "RUNNING",
"startTime": "2026-03-11T08:00:00Z",
"endTime": "2026-03-11T08:00:01Z",
"durationMs": 1000,
"errorMessage": "",
"errorStackTrace": "",
"processors": []
}
""");
// Execution 4: FAILED with MyException in stack trace
ingest("""
{
"routeId": "search-route-4",
"exchangeId": "ex-search-4",
"correlationId": "corr-delta",
"status": "FAILED",
"startTime": "2026-03-10T14:00:00Z",
"endTime": "2026-03-10T14:00:00.300Z",
"durationMs": 300,
"errorMessage": "Processing failed",
"errorStackTrace": "com.example.MyException: something broke\\n at com.example.Handler.handle(Handler.java:10)",
"processors": [
{
"processorId": "proc-4",
"processorType": "bean",
"status": "FAILED",
"startTime": "2026-03-10T14:00:00Z",
"endTime": "2026-03-10T14:00:00.300Z",
"durationMs": 300,
"inputBody": "",
"outputBody": "",
"inputHeaders": {"Content-Type": "text/plain"},
"outputHeaders": {}
}
]
}
""");
// Insert 6 more COMPLETED executions for pagination testing (total = 10)
for (int i = 5; i <= 10; i++) {
ingest(String.format("""
{
"routeId": "search-route-%d",
"exchangeId": "ex-search-%d",
"correlationId": "corr-page-%d",
"status": "COMPLETED",
"startTime": "2026-03-10T15:00:%02d.000Z",
"endTime": "2026-03-10T15:00:%02d.100Z",
"durationMs": 100,
"errorMessage": "",
"errorStackTrace": "",
"processors": []
}
""", i, i, i, i, i));
}
// Verify all data is in PostgreSQL (synchronous writes)
Integer count = jdbcTemplate.queryForObject(
"SELECT count(*) FROM executions WHERE route_id LIKE 'search-route-%'",
Integer.class);
assertThat(count).isEqualTo(10);
// Wait for async search indexing (debounce + index time)
// Check for last seeded execution specifically to avoid false positives from other test classes
await().atMost(30, SECONDS).untilAsserted(() -> {
ResponseEntity<String> r = searchGet("?correlationId=corr-page-10");
JsonNode body = objectMapper.readTree(r.getBody());
assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1);
});
}
@Test
void searchByStatus_returnsOnlyMatchingExecutions() throws Exception {
ResponseEntity<String> response = searchGet("?status=FAILED");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(2);
assertThat(body.get("offset").asInt()).isEqualTo(0);
assertThat(body.get("limit").asInt()).isEqualTo(50);
assertThat(body.get("data")).isNotNull();
body.get("data").forEach(item ->
assertThat(item.get("status").asText()).isEqualTo("FAILED"));
}
@Test
void searchByTimeRange_returnsOnlyExecutionsInRange() throws Exception {
ResponseEntity<String> response = searchGet(
"?timeFrom=2026-03-10T09:00:00Z&timeTo=2026-03-10T13:00:00Z&correlationId=corr-alpha");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-alpha");
ResponseEntity<String> response2 = searchGet(
"?timeFrom=2026-03-10T09:00:00Z&timeTo=2026-03-10T13:00:00Z&correlationId=corr-gamma");
JsonNode body2 = objectMapper.readTree(response2.getBody());
assertThat(body2.get("total").asLong()).isZero();
}
@Test
void searchByDuration_returnsOnlyMatchingExecutions() throws Exception {
ResponseEntity<String> response = searchGet("?correlationId=corr-beta");
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
ResponseEntity<String> response2 = searchPost("""
{
"durationMin": 100,
"durationMax": 500,
"correlationId": "corr-alpha"
}
""");
JsonNode body2 = objectMapper.readTree(response2.getBody());
assertThat(body2.get("total").asLong()).isZero();
ResponseEntity<String> response3 = searchPost("""
{
"durationMin": 100,
"durationMax": 500,
"correlationId": "corr-delta"
}
""");
JsonNode body3 = objectMapper.readTree(response3.getBody());
assertThat(body3.get("total").asLong()).isEqualTo(1);
}
@Test
void searchByCorrelationId_returnsOnlyMatchingExecution() throws Exception {
ResponseEntity<String> response = searchGet("?correlationId=corr-alpha");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-alpha");
}
@Test
void fullTextSearchGlobal_findsMatchInErrorMessage() throws Exception {
ResponseEntity<String> response = searchPost("""
{ "text": "NullPointerException" }
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("routeId").asText()).isEqualTo("search-route-2");
}
@Test
void fullTextSearchGlobal_returnsEmptyForNonexistent() throws Exception {
ResponseEntity<String> response = searchPost("""
{ "text": "nonexistent-term-xyz-12345" }
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isZero();
assertThat(body.get("data")).isEmpty();
}
@Test
void fullTextSearchInBody_findsMatchInExchangeBody() throws Exception {
ResponseEntity<String> response = searchPost("""
{ "textInBody": "customer-123" }
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("routeId").asText()).isEqualTo("search-route-1");
}
@Test
void fullTextSearchInHeaders_findsMatchInExchangeHeaders() throws Exception {
ResponseEntity<String> response = searchPost("""
{ "textInHeaders": "Content-Type" }
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(1);
}
@Test
void fullTextSearchInErrors_findsMatchInStackTrace() throws Exception {
ResponseEntity<String> response = searchPost("""
{ "textInErrors": "MyException" }
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("routeId").asText()).isEqualTo("search-route-4");
}
@Test
void combinedFilters_statusAndText() throws Exception {
ResponseEntity<String> response = searchPost("""
{
"status": "FAILED",
"text": "NullPointer"
}
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("routeId").asText()).isEqualTo("search-route-2");
}
@Test
void postAdvancedSearch_allFiltersWork() throws Exception {
ResponseEntity<String> response = searchPost("""
{
"status": "COMPLETED",
"timeFrom": "2026-03-10T09:00:00Z",
"timeTo": "2026-03-10T11:00:00Z",
"durationMin": 0,
"durationMax": 100,
"correlationId": "corr-alpha"
}
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-alpha");
}
@Test
void pagination_worksCorrectly() throws Exception {
ResponseEntity<String> countResponse = searchGet("?status=COMPLETED&limit=1");
JsonNode countBody = objectMapper.readTree(countResponse.getBody());
long totalCompleted = countBody.get("total").asLong();
assertThat(totalCompleted).isGreaterThanOrEqualTo(7);
ResponseEntity<String> response = searchPost("""
{
"status": "COMPLETED",
"offset": 2,
"limit": 3
}
""");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(totalCompleted);
assertThat(body.get("data").size()).isEqualTo(3);
assertThat(body.get("offset").asInt()).isEqualTo(2);
assertThat(body.get("limit").asInt()).isEqualTo(3);
}
@Test
void emptyResults_returnsCorrectEnvelope() throws Exception {
ResponseEntity<String> response = searchGet("?status=NONEXISTENT_STATUS");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("data")).isEmpty();
assertThat(body.get("total").asLong()).isZero();
assertThat(body.get("offset").asInt()).isEqualTo(0);
assertThat(body.get("limit").asInt()).isEqualTo(50);
}
// --- Helper methods ---
private void ingest(String json) {
restTemplate.postForEntity("/api/v1/data/executions",
new HttpEntity<>(json, securityHelper.authHeaders(jwt)), String.class);
}
private ResponseEntity<String> searchGet(String queryString) {
HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
return restTemplate.exchange(
"/api/v1/search/executions" + queryString,
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
}
private ResponseEntity<String> searchPost(String jsonBody) {
return restTemplate.exchange(
"/api/v1/search/executions",
HttpMethod.POST,
new HttpEntity<>(jsonBody, securityHelper.authHeaders(viewerJwt)),
String.class);
}
}

View File

@@ -0,0 +1,120 @@
package com.cameleer.server.app.controller;
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.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.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
class SensitiveKeysAdminControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String adminJwt;
private String viewerJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
viewerJwt = securityHelper.viewerToken();
jdbcTemplate.update("DELETE FROM server_config WHERE config_key = 'sensitive_keys'");
}
@Test
void get_notConfigured_returns204() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/sensitive-keys", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
void get_asViewer_returns403() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/sensitive-keys", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void put_savesAndReturnsKeys() throws Exception {
String json = """
{ "keys": ["Authorization", "Cookie", "*password*"] }
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/sensitive-keys", HttpMethod.PUT,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("keys").size()).isEqualTo(3);
assertThat(body.path("keys").get(0).asText()).isEqualTo("Authorization");
assertThat(body.path("pushResult").isNull()).isTrue();
}
@Test
void put_thenGet_returnsStoredKeys() throws Exception {
String json = """
{ "keys": ["Authorization", "*secret*"] }
""";
restTemplate.exchange(
"/api/v1/admin/sensitive-keys", HttpMethod.PUT,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
ResponseEntity<String> getResponse = restTemplate.exchange(
"/api/v1/admin/sensitive-keys", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(getResponse.getBody());
assertThat(body.path("keys").size()).isEqualTo(2);
}
@Test
void put_withPushToAgents_returnsEmptyPushResult() throws Exception {
String json = """
{ "keys": ["Authorization"] }
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/sensitive-keys?pushToAgents=true", HttpMethod.PUT,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("pushResult").path("total").asInt()).isEqualTo(0);
}
@Test
void put_asViewer_returns403() {
String json = """
{ "keys": ["Authorization"] }
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/sensitive-keys", HttpMethod.PUT,
new HttpEntity<>(json, securityHelper.authHeaders(viewerJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
}

View File

@@ -0,0 +1,105 @@
package com.cameleer.server.app.controller;
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.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.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
class ThresholdAdminControllerIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String adminJwt;
private String viewerJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
viewerJwt = securityHelper.viewerToken();
jdbcTemplate.update("DELETE FROM server_config WHERE config_key = 'thresholds'");
}
@Test
void getThresholds_asAdmin_returnsDefaults() throws Exception {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/thresholds", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.has("database")).isTrue();
assertThat(body.path("database").path("connectionPoolWarning").asInt()).isEqualTo(80);
}
@Test
void getThresholds_asViewer_returns403() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/thresholds", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void updateThresholds_asAdmin_returns200() throws Exception {
String json = """
{
"database": {
"connectionPoolWarning": 70,
"connectionPoolCritical": 90,
"queryDurationWarning": 2.0,
"queryDurationCritical": 15.0
}
}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/thresholds", HttpMethod.PUT,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("database").path("connectionPoolWarning").asInt()).isEqualTo(70);
}
@Test
void updateThresholds_invalidWarningGreaterThanCritical_returns400() {
String json = """
{
"database": {
"connectionPoolWarning": 95,
"connectionPoolCritical": 80,
"queryDurationWarning": 2.0,
"queryDurationCritical": 15.0
}
}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/thresholds", HttpMethod.PUT,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}

View File

@@ -0,0 +1,275 @@
package com.cameleer.server.app.diagram;
import com.cameleer.common.graph.NodeType;
import com.cameleer.common.graph.RouteEdge;
import com.cameleer.common.graph.RouteGraph;
import com.cameleer.common.graph.RouteNode;
import com.cameleer.server.core.diagram.DiagramLayout;
import com.cameleer.server.core.diagram.PositionedNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link ElkDiagramRenderer}.
* No Spring context needed -- pure unit test.
*/
class ElkDiagramRendererTest {
private ElkDiagramRenderer renderer;
@BeforeEach
void setUp() {
renderer = new ElkDiagramRenderer();
}
/**
* Build a simple 3-node route: from(endpoint) -> process(bean) -> to(endpoint)
*/
private RouteGraph buildSimpleGraph() {
RouteGraph graph = new RouteGraph("test-route");
graph.setExtractedAt(Instant.now());
graph.setVersion(1);
RouteNode from = new RouteNode("node-1", NodeType.ENDPOINT, "timer:tick");
RouteNode process = new RouteNode("node-2", NodeType.BEAN, "myProcessor");
RouteNode to = new RouteNode("node-3", NodeType.TO, "log:output");
from.setChildren(List.of(process, to));
graph.setRoot(from);
graph.setEdges(List.of(
new RouteEdge("node-1", "node-2", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-2", "node-3", RouteEdge.EdgeType.FLOW)
));
return graph;
}
/**
* Build a compound graph: from -> choice -> (when, otherwise) -> to
*/
private RouteGraph buildCompoundGraph() {
RouteGraph graph = new RouteGraph("compound-route");
graph.setExtractedAt(Instant.now());
graph.setVersion(1);
RouteNode from = new RouteNode("node-1", NodeType.ENDPOINT, "direct:start");
RouteNode choice = new RouteNode("node-2", NodeType.EIP_CHOICE, "choice");
RouteNode when = new RouteNode("node-3", NodeType.EIP_WHEN, "when(simple)");
RouteNode otherwise = new RouteNode("node-4", NodeType.EIP_OTHERWISE, "otherwise");
RouteNode to = new RouteNode("node-5", NodeType.TO, "log:result");
// Build tree: from → [choice, to]; choice → [when, otherwise]
choice.setChildren(List.of(when, otherwise));
from.setChildren(List.of(choice, to));
graph.setRoot(from);
graph.setEdges(List.of(
new RouteEdge("node-1", "node-2", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-2", "node-3", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-2", "node-4", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-3", "node-5", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-4", "node-5", RouteEdge.EdgeType.FLOW)
));
return graph;
}
@Test
void renderSvg_simpleGraph_producesValidSvg() {
String svg = renderer.renderSvg(buildSimpleGraph());
assertNotNull(svg);
assertTrue(svg.contains("<svg"), "SVG should contain <svg element");
assertTrue(svg.contains("</svg>"), "SVG should be properly closed");
}
@Test
void renderSvg_simpleGraph_containsNodeShapes() {
String svg = renderer.renderSvg(buildSimpleGraph());
// Should contain rect elements for nodes
assertTrue(svg.contains("<rect") || svg.contains("<path"),
"SVG should contain rect or path elements for nodes");
}
@Test
void renderSvg_simpleGraph_containsNodeLabels() {
String svg = renderer.renderSvg(buildSimpleGraph());
assertTrue(svg.contains("timer:tick"), "SVG should contain endpoint label");
assertTrue(svg.contains("myProcessor"), "SVG should contain processor label");
assertTrue(svg.contains("log:output"), "SVG should contain to label");
}
@Test
void renderSvg_endpointNodes_haveBlueColor() {
String svg = renderer.renderSvg(buildSimpleGraph());
// Endpoint nodes should have blue fill (#3B82F6)
assertTrue(svg.contains("#3B82F6") || svg.contains("#3b82f6") ||
svg.contains("rgb(59,130,246)") || svg.contains("rgb(59, 130, 246)"),
"Endpoint nodes should have blue fill color (#3B82F6)");
}
@Test
void renderSvg_containsEdgeLines() {
String svg = renderer.renderSvg(buildSimpleGraph());
// Edges should be drawn as lines or paths
assertTrue(svg.contains("<line") || svg.contains("<polyline") || svg.contains("<path"),
"SVG should contain line/path elements for edges");
}
@Test
void layoutJson_simpleGraph_returnsCorrectNodeCount() {
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
assertNotNull(layout);
assertEquals(3, layout.nodes().size(), "Should have 3 positioned nodes");
}
@Test
void layoutJson_simpleGraph_nodesHavePositiveCoordinates() {
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
for (PositionedNode node : layout.nodes()) {
assertTrue(node.x() >= 0, "Node x should be >= 0: " + node.id());
assertTrue(node.y() >= 0, "Node y should be >= 0: " + node.id());
assertTrue(node.width() > 0, "Node width should be > 0: " + node.id());
assertTrue(node.height() > 0, "Node height should be > 0: " + node.id());
}
}
@Test
void layoutJson_simpleGraph_hasPositiveDimensions() {
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
assertTrue(layout.width() > 0, "Layout width should be positive");
assertTrue(layout.height() > 0, "Layout height should be positive");
}
@Test
void layoutJson_simpleGraph_hasEdges() {
DiagramLayout layout = renderer.layoutJson(buildSimpleGraph());
assertEquals(2, layout.edges().size(), "Should have 2 edges");
}
@Test
void layoutJson_compoundGraph_choiceNodeHasChildren() {
DiagramLayout layout = renderer.layoutJson(buildCompoundGraph());
PositionedNode choiceNode = layout.nodes().stream()
.filter(n -> "node-2".equals(n.id()))
.findFirst()
.orElseThrow(() -> new AssertionError("Choice node not found"));
assertNotNull(choiceNode.children(), "Choice node should have children");
assertFalse(choiceNode.children().isEmpty(), "Choice node should have non-empty children");
assertEquals(2, choiceNode.children().size(), "Choice node should have 2 children (when, otherwise)");
}
/**
* Build a DO_TRY graph: from -> doTry(try: [process, log], doFinally: [cleanup], doCatch: [errorLog]) -> to
*/
private RouteGraph buildDoTryGraph() {
RouteGraph graph = new RouteGraph("try-catch-route");
graph.setExtractedAt(Instant.now());
graph.setVersion(1);
RouteNode from = new RouteNode("node-1", NodeType.ENDPOINT, "timer:tick");
RouteNode doTry = new RouteNode("node-2", NodeType.DO_TRY, "doTry");
RouteNode process = new RouteNode("node-3", NodeType.PROCESSOR, "process");
RouteNode log1 = new RouteNode("node-4", NodeType.LOG, "log:tryBody");
RouteNode doFinally = new RouteNode("node-5", NodeType.DO_FINALLY, "doFinally");
RouteNode cleanup = new RouteNode("node-6", NodeType.LOG, "log:cleanup");
RouteNode doCatch = new RouteNode("node-7", NodeType.DO_CATCH, "doCatch");
RouteNode errorLog = new RouteNode("node-8", NodeType.LOG, "log:error");
RouteNode to = new RouteNode("node-9", NodeType.TO, "log:done");
doFinally.setChildren(List.of(cleanup));
doCatch.setChildren(List.of(errorLog));
doTry.setChildren(List.of(process, log1, doFinally, doCatch));
from.setChildren(List.of(doTry, to));
graph.setRoot(from);
graph.setEdges(List.of(
new RouteEdge("node-1", "node-2", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-2", "node-3", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-3", "node-4", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-2", "node-5", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-5", "node-6", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-2", "node-7", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-7", "node-8", RouteEdge.EdgeType.FLOW),
new RouteEdge("node-2", "node-9", RouteEdge.EdgeType.FLOW)
));
return graph;
}
@Test
void layoutJson_doTryGraph_sectionsInCorrectOrder() {
DiagramLayout layout = renderer.layoutJson(buildDoTryGraph());
assertNotNull(layout);
// Find the DO_TRY compound node
PositionedNode doTryNode = layout.nodes().stream()
.filter(n -> "node-2".equals(n.id()))
.findFirst()
.orElseThrow(() -> new AssertionError("DO_TRY node not found"));
assertNotNull(doTryNode.children(), "DO_TRY should have children");
assertFalse(doTryNode.children().isEmpty(), "DO_TRY should have non-empty children");
// Find sections by ID pattern
PositionedNode tryBody = doTryNode.children().stream()
.filter(n -> n.id() != null && n.id().contains("._try_body"))
.findFirst().orElse(null);
PositionedNode finallySection = doTryNode.children().stream()
.filter(n -> "node-5".equals(n.id()))
.findFirst().orElse(null);
PositionedNode catchSection = doTryNode.children().stream()
.filter(n -> "node-7".equals(n.id()))
.findFirst().orElse(null);
assertNotNull(tryBody, "Try body wrapper should exist");
assertNotNull(finallySection, "doFinally section should exist");
assertNotNull(catchSection, "doCatch section should exist");
// Verify vertical order: tryBody.y < doFinally.y < doCatch.y
assertTrue(tryBody.y() < finallySection.y(),
"Try body (y=" + tryBody.y() + ") should be above doFinally (y=" + finallySection.y() + ")");
assertTrue(finallySection.y() < catchSection.y(),
"doFinally (y=" + finallySection.y() + ") should be above doCatch (y=" + catchSection.y() + ")");
}
@Test
void layoutJson_doTryGraph_sectionsHaveSameWidth() {
DiagramLayout layout = renderer.layoutJson(buildDoTryGraph());
PositionedNode doTryNode = layout.nodes().stream()
.filter(n -> "node-2".equals(n.id()))
.findFirst()
.orElseThrow(() -> new AssertionError("DO_TRY node not found"));
List<PositionedNode> sections = doTryNode.children();
double firstWidth = sections.get(0).width();
for (PositionedNode section : sections) {
assertEquals(firstWidth, section.width(), 0.1,
"All sections should have the same width, but " + section.id() + " differs");
}
}
@Test
void renderSvg_compoundGraph_producesValidSvg() {
String svg = renderer.renderSvg(buildCompoundGraph());
assertNotNull(svg);
assertTrue(svg.contains("<svg"), "Compound SVG should contain <svg element");
assertTrue(svg.contains("choice"), "SVG should contain choice label");
}
}

View File

@@ -0,0 +1,86 @@
package com.cameleer.server.app.interceptor;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
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.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for the protocol version interceptor.
* With security enabled, requests to protected endpoints need JWT auth
* to reach the interceptor layer.
*/
class ProtocolVersionIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private String jwt;
@BeforeEach
void setUp() {
jwt = securityHelper.registerTestAgent("test-agent-protocol-it");
}
@Test
void requestWithoutProtocolHeaderReturns400() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + jwt);
var entity = new HttpEntity<>("{}", headers);
var response = restTemplate.exchange(
"/api/v1/data/executions", HttpMethod.POST, entity, String.class);
assertThat(response.getStatusCode().value()).isEqualTo(400);
assertThat(response.getBody()).contains("Missing or unsupported X-Cameleer-Protocol-Version header");
}
@Test
void requestWithWrongProtocolVersionReturns400() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + jwt);
headers.set("X-Cameleer-Protocol-Version", "2");
var entity = new HttpEntity<>("{}", headers);
var response = restTemplate.exchange(
"/api/v1/data/executions", HttpMethod.POST, entity, String.class);
assertThat(response.getStatusCode().value()).isEqualTo(400);
}
@Test
void requestWithCorrectProtocolVersionPassesInterceptor() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + jwt);
headers.set("X-Cameleer-Protocol-Version", "1");
var entity = new HttpEntity<>("{}", headers);
var response = restTemplate.exchange(
"/api/v1/data/executions", HttpMethod.POST, entity, String.class);
assertThat(response.getStatusCode().value()).isNotEqualTo(400);
}
@Test
void healthEndpointExcludedFromInterceptor() {
var response = restTemplate.getForEntity("/api/v1/health", String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
}
@Test
void apiDocsExcludedFromInterceptor() {
var response = restTemplate.getForEntity("/api/v1/api-docs", String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
}
}

View File

@@ -0,0 +1,50 @@
package com.cameleer.server.app.runtime;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class PrometheusLabelBuilderTest {
@Test
void springBootLabels() {
Map<String, String> labels = PrometheusLabelBuilder.build("spring-boot");
assertEquals("true", labels.get("prometheus.scrape"));
assertEquals("/actuator/prometheus", labels.get("prometheus.path"));
assertEquals("8081", labels.get("prometheus.port"));
}
@Test
void quarkusLabels() {
Map<String, String> labels = PrometheusLabelBuilder.build("quarkus");
assertEquals("true", labels.get("prometheus.scrape"));
assertEquals("/q/metrics", labels.get("prometheus.path"));
assertEquals("9000", labels.get("prometheus.port"));
}
@Test
void nativeLabels() {
Map<String, String> labels = PrometheusLabelBuilder.build("native");
assertEquals("true", labels.get("prometheus.scrape"));
assertEquals("/q/metrics", labels.get("prometheus.path"));
assertEquals("9000", labels.get("prometheus.port"));
}
@Test
void plainJavaLabels() {
Map<String, String> labels = PrometheusLabelBuilder.build("plain-java");
assertEquals("true", labels.get("prometheus.scrape"));
assertEquals("/metrics", labels.get("prometheus.path"));
assertEquals("9464", labels.get("prometheus.port"));
}
@Test
void unknownDefaultsToSpringBoot() {
Map<String, String> labels = PrometheusLabelBuilder.build("unknown");
assertEquals("true", labels.get("prometheus.scrape"));
assertEquals("/actuator/prometheus", labels.get("prometheus.path"));
assertEquals("8081", labels.get("prometheus.port"));
}
}

View File

@@ -0,0 +1,326 @@
package com.cameleer.server.app.search;
import com.cameleer.common.model.LogEntry;
import com.cameleer.server.core.search.LogSearchRequest;
import com.cameleer.server.core.search.LogSearchResponse;
import com.cameleer.server.core.storage.LogEntryResult;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.cameleer.server.app.ClickHouseTestHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class ClickHouseLogStoreIT {
@Container
static final ClickHouseContainer clickhouse =
new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
private JdbcTemplate jdbc;
private ClickHouseLogStore store;
@BeforeEach
void setUp() throws Exception {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(clickhouse.getJdbcUrl());
ds.setUsername(clickhouse.getUsername());
ds.setPassword(clickhouse.getPassword());
jdbc = new JdbcTemplate(ds);
ClickHouseTestHelper.executeInitSql(jdbc);
jdbc.execute("TRUNCATE TABLE logs");
store = new ClickHouseLogStore("default", jdbc);
}
// ── Helpers ──────────────────────────────────────────────────────────
private LogEntry entry(Instant ts, String level, String logger, String message,
String thread, String stackTrace, Map<String, String> mdc) {
return new LogEntry(ts, level, logger, message, thread, stackTrace, mdc);
}
private LogSearchRequest req(String application) {
return new LogSearchRequest(null, null, application, null, null, null, null, null, null, null, null, 100, "desc");
}
// ── Tests ─────────────────────────────────────────────────────────────
@Test
void indexBatch_writesLogs() {
Instant now = Instant.parse("2026-03-31T12:00:00Z");
List<LogEntry> entries = List.of(
entry(now, "INFO", "com.example.Foo", "Hello world", "main", null, null),
entry(now.plusSeconds(1), "ERROR", "com.example.Bar", "Something failed", "worker-1", "stack...", null)
);
store.indexBatch("agent-1", "my-app", entries);
Long count = jdbc.queryForObject("SELECT count() FROM logs WHERE application = 'my-app'", Long.class);
assertThat(count).isEqualTo(2);
}
@Test
void search_byApplication_returnsLogs() {
Instant now = Instant.parse("2026-03-31T12:00:00Z");
store.indexBatch("agent-1", "app-a", List.of(
entry(now, "INFO", "logger", "msg-a", "t1", null, null)
));
store.indexBatch("agent-2", "app-b", List.of(
entry(now, "INFO", "logger", "msg-b", "t1", null, null)
));
LogSearchResponse result = store.search(req("app-a"));
assertThat(result.data()).hasSize(1);
assertThat(result.data().get(0).message()).isEqualTo("msg-a");
assertThat(result.data().get(0).application()).isEqualTo("app-a");
assertThat(result.data().get(0).instanceId()).isEqualTo("agent-1");
}
@Test
void search_byLevel_filtersCorrectly() {
Instant now = Instant.parse("2026-03-31T12:00:00Z");
store.indexBatch("agent-1", "my-app", List.of(
entry(now, "INFO", "logger", "info message", "t1", null, null),
entry(now.plusSeconds(1), "ERROR", "logger", "error message", "t1", null, null)
));
LogSearchResponse result = store.search(new LogSearchRequest(
null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc"));
assertThat(result.data()).hasSize(1);
assertThat(result.data().get(0).level()).isEqualTo("ERROR");
assertThat(result.data().get(0).message()).isEqualTo("error message");
}
@Test
void search_multiLevel_filtersCorrectly() {
Instant now = Instant.parse("2026-03-31T12:00:00Z");
store.indexBatch("agent-1", "my-app", List.of(
entry(now, "INFO", "logger", "info msg", "t1", null, null),
entry(now.plusSeconds(1), "WARN", "logger", "warn msg", "t1", null, null),
entry(now.plusSeconds(2), "ERROR", "logger", "error msg", "t1", null, null)
));
LogSearchResponse result = store.search(new LogSearchRequest(
null, List.of("WARN", "ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc"));
assertThat(result.data()).hasSize(2);
}
@Test
void search_byQuery_usesLikeSearch() {
Instant now = Instant.parse("2026-03-31T12:00:00Z");
store.indexBatch("agent-1", "my-app", List.of(
entry(now, "INFO", "logger", "Processing order #12345", "t1", null, null),
entry(now.plusSeconds(1), "INFO", "logger", "Health check OK", "t1", null, null)
));
LogSearchResponse result = store.search(new LogSearchRequest(
"order #12345", null, "my-app", null, null, null, null, null, null, null, null, 100, "desc"));
assertThat(result.data()).hasSize(1);
assertThat(result.data().get(0).message()).contains("order #12345");
}
@Test
void search_byExchangeId_matchesTopLevelAndMdc() {
Instant now = Instant.parse("2026-03-31T12:00:00Z");
Map<String, String> mdc = Map.of("camel.exchangeId", "exchange-abc");
store.indexBatch("agent-1", "my-app", List.of(
entry(now, "INFO", "logger", "msg with exchange", "t1", null, mdc),
entry(now.plusSeconds(1), "INFO", "logger", "msg without exchange", "t1", null, null)
));
LogSearchResponse result = store.search(new LogSearchRequest(
null, null, "my-app", null, "exchange-abc", null, null, null, null, null, null, 100, "desc"));
assertThat(result.data()).hasSize(1);
assertThat(result.data().get(0).message()).isEqualTo("msg with exchange");
assertThat(result.data().get(0).exchangeId()).isEqualTo("exchange-abc");
}
@Test
void search_byTimeRange_filtersCorrectly() {
Instant t1 = Instant.parse("2026-03-31T10:00:00Z");
Instant t2 = Instant.parse("2026-03-31T12:00:00Z");
Instant t3 = Instant.parse("2026-03-31T14:00:00Z");
store.indexBatch("agent-1", "my-app", List.of(
entry(t1, "INFO", "logger", "morning", "t1", null, null),
entry(t2, "INFO", "logger", "noon", "t1", null, null),
entry(t3, "INFO", "logger", "afternoon", "t1", null, null)
));
Instant from = Instant.parse("2026-03-31T11:00:00Z");
Instant to = Instant.parse("2026-03-31T13:00:00Z");
LogSearchResponse result = store.search(new LogSearchRequest(
null, null, "my-app", null, null, null, null, null, from, to, null, 100, "desc"));
assertThat(result.data()).hasSize(1);
assertThat(result.data().get(0).message()).isEqualTo("noon");
}
@Test
void search_crossApp_returnsAllApps() {
Instant now = Instant.parse("2026-03-31T12:00:00Z");
store.indexBatch("agent-1", "app-a", List.of(
entry(now, "INFO", "logger", "msg-a", "t1", null, null)
));
store.indexBatch("agent-2", "app-b", List.of(
entry(now, "INFO", "logger", "msg-b", "t1", null, null)
));
// No application filter — should return both
LogSearchResponse result = store.search(new LogSearchRequest(
null, null, null, null, null, null, null, null, null, null, null, 100, "desc"));
assertThat(result.data()).hasSize(2);
}
@Test
void search_byLogger_filtersCorrectly() {
Instant now = Instant.parse("2026-03-31T12:00:00Z");
store.indexBatch("agent-1", "my-app", List.of(
entry(now, "INFO", "com.example.OrderProcessor", "order msg", "t1", null, null),
entry(now.plusSeconds(1), "INFO", "com.example.PaymentService", "payment msg", "t1", null, null)
));
LogSearchResponse result = store.search(new LogSearchRequest(
null, null, "my-app", null, null, "OrderProcessor", null, null, null, null, null, 100, "desc"));
assertThat(result.data()).hasSize(1);
assertThat(result.data().get(0).loggerName()).contains("OrderProcessor");
}
@Test
void search_cursorPagination_works() {
Instant base = Instant.parse("2026-03-31T12:00:00Z");
store.indexBatch("agent-1", "my-app", List.of(
entry(base, "INFO", "logger", "msg-1", "t1", null, null),
entry(base.plusSeconds(1), "INFO", "logger", "msg-2", "t1", null, null),
entry(base.plusSeconds(2), "INFO", "logger", "msg-3", "t1", null, null),
entry(base.plusSeconds(3), "INFO", "logger", "msg-4", "t1", null, null),
entry(base.plusSeconds(4), "INFO", "logger", "msg-5", "t1", null, null)
));
// Page 1: limit 2
LogSearchResponse page1 = store.search(new LogSearchRequest(
null, null, "my-app", null, null, null, null, null, null, null, null, 2, "desc"));
assertThat(page1.data()).hasSize(2);
assertThat(page1.hasMore()).isTrue();
assertThat(page1.nextCursor()).isNotNull();
assertThat(page1.data().get(0).message()).isEqualTo("msg-5");
// Page 2: use cursor
LogSearchResponse page2 = store.search(new LogSearchRequest(
null, null, "my-app", null, null, null, null, null, null, null, page1.nextCursor(), 2, "desc"));
assertThat(page2.data()).hasSize(2);
assertThat(page2.hasMore()).isTrue();
assertThat(page2.data().get(0).message()).isEqualTo("msg-3");
// Page 3: last page
LogSearchResponse page3 = store.search(new LogSearchRequest(
null, null, "my-app", null, null, null, null, null, null, null, page2.nextCursor(), 2, "desc"));
assertThat(page3.data()).hasSize(1);
assertThat(page3.hasMore()).isFalse();
assertThat(page3.nextCursor()).isNull();
}
@Test
void search_levelCounts_correctAndUnaffectedByLevelFilter() {
Instant now = Instant.parse("2026-03-31T12:00:00Z");
store.indexBatch("agent-1", "my-app", List.of(
entry(now, "INFO", "logger", "info1", "t1", null, null),
entry(now.plusSeconds(1), "INFO", "logger", "info2", "t1", null, null),
entry(now.plusSeconds(2), "WARN", "logger", "warn1", "t1", null, null),
entry(now.plusSeconds(3), "ERROR", "logger", "err1", "t1", null, null)
));
// Filter for ERROR only, but counts should include all levels
LogSearchResponse result = store.search(new LogSearchRequest(
null, List.of("ERROR"), "my-app", null, null, null, null, null, null, null, null, 100, "desc"));
assertThat(result.data()).hasSize(1);
assertThat(result.levelCounts()).containsEntry("INFO", 2L);
assertThat(result.levelCounts()).containsEntry("WARN", 1L);
assertThat(result.levelCounts()).containsEntry("ERROR", 1L);
}
@Test
void search_sortAsc_returnsOldestFirst() {
Instant base = Instant.parse("2026-03-31T12:00:00Z");
store.indexBatch("agent-1", "my-app", List.of(
entry(base, "INFO", "logger", "msg-1", "t1", null, null),
entry(base.plusSeconds(1), "INFO", "logger", "msg-2", "t1", null, null),
entry(base.plusSeconds(2), "INFO", "logger", "msg-3", "t1", null, null)
));
LogSearchResponse result = store.search(new LogSearchRequest(
null, null, "my-app", null, null, null, null, null, null, null, null, 100, "asc"));
assertThat(result.data()).hasSize(3);
assertThat(result.data().get(0).message()).isEqualTo("msg-1");
assertThat(result.data().get(2).message()).isEqualTo("msg-3");
}
@Test
void search_returnsNewFields() {
Instant now = Instant.parse("2026-03-31T12:00:00Z");
Map<String, String> mdc = Map.of("camel.exchangeId", "ex-123", "custom.key", "custom-value");
store.indexBatch("agent-1", "my-app", List.of(
entry(now, "INFO", "logger", "msg", "t1", null, mdc)
));
LogSearchResponse result = store.search(req("my-app"));
assertThat(result.data()).hasSize(1);
LogEntryResult entry = result.data().get(0);
assertThat(entry.exchangeId()).isEqualTo("ex-123");
assertThat(entry.instanceId()).isEqualTo("agent-1");
assertThat(entry.application()).isEqualTo("my-app");
assertThat(entry.mdc()).containsEntry("custom.key", "custom-value");
}
@Test
void indexBatch_storesMdc() {
Instant now = Instant.parse("2026-03-31T12:00:00Z");
Map<String, String> mdc = Map.of(
"camel.exchangeId", "ex-123",
"custom.key", "custom-value"
);
store.indexBatch("agent-1", "my-app", List.of(
entry(now, "INFO", "logger", "msg", "t1", null, mdc)
));
String exchangeId = jdbc.queryForObject(
"SELECT exchange_id FROM logs WHERE application = 'my-app' LIMIT 1",
String.class);
assertThat(exchangeId).isEqualTo("ex-123");
String customVal = jdbc.queryForObject(
"SELECT mdc['custom.key'] FROM logs WHERE application = 'my-app' LIMIT 1",
String.class);
assertThat(customVal).isEqualTo("custom-value");
}
}

View File

@@ -0,0 +1,312 @@
package com.cameleer.server.app.search;
import com.cameleer.server.app.storage.ClickHouseExecutionStore;
import com.cameleer.server.core.ingestion.MergedExecution;
import com.cameleer.server.core.search.ExecutionSummary;
import com.cameleer.server.core.search.SearchRequest;
import com.cameleer.server.core.search.SearchResult;
import com.cameleer.common.model.ExecutionStatus;
import com.cameleer.common.model.FlatProcessorRecord;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.cameleer.server.app.ClickHouseTestHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class ClickHouseSearchIndexIT {
@Container
static final ClickHouseContainer clickhouse =
new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
private JdbcTemplate jdbc;
private ClickHouseSearchIndex searchIndex;
@BeforeEach
void setUp() throws Exception {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(clickhouse.getJdbcUrl());
ds.setUsername(clickhouse.getUsername());
ds.setPassword(clickhouse.getPassword());
jdbc = new JdbcTemplate(ds);
ClickHouseTestHelper.executeInitSql(jdbc);
jdbc.execute("TRUNCATE TABLE executions");
jdbc.execute("TRUNCATE TABLE processor_executions");
ClickHouseExecutionStore store = new ClickHouseExecutionStore("default", jdbc);
searchIndex = new ClickHouseSearchIndex("default", jdbc);
// Seed test data
Instant baseTime = Instant.parse("2026-03-31T10:00:00Z");
// exec-1: COMPLETED, route-timer, agent-a, my-app, corr-1, 500ms, input_body with order number, attributes
MergedExecution exec1 = new MergedExecution(
"default", 1L, "exec-1", "route-timer", "agent-a", "my-app", "default",
"COMPLETED", "corr-1", "exchange-1",
baseTime,
baseTime.plusMillis(500),
500L,
"", "", "", "", "", "",
"hash-abc", "FULL",
"{\"order\":\"12345\"}", "", "", "", "", "", "{\"env\":\"prod\"}",
"", "",
false, false,
null, null
);
// exec-2: FAILED, route-timer, agent-a, my-app, corr-2, 200ms, with error
MergedExecution exec2 = new MergedExecution(
"default", 1L, "exec-2", "route-timer", "agent-a", "my-app", "default",
"FAILED", "corr-2", "exchange-2",
baseTime.plusSeconds(1),
baseTime.plusSeconds(1).plusMillis(200),
200L,
"NullPointerException at line 42",
"java.lang.NPE\n at Foo.bar(Foo.java:42)",
"NullPointerException", "RUNTIME", "", "",
"", "FULL",
"", "", "", "", "", "", "",
"", "",
false, false,
null, null
);
// exec-3: COMPLETED, route-rest, agent-b, other-app, 100ms, no error
MergedExecution exec3 = new MergedExecution(
"default", 1L, "exec-3", "route-rest", "agent-b", "other-app", "default",
"COMPLETED", "", "exchange-3",
baseTime.plusSeconds(2),
baseTime.plusSeconds(2).plusMillis(100),
100L,
"", "", "", "", "", "",
"", "FULL",
"", "", "", "", "", "", "",
"", "",
false, false,
null, null
);
store.insertExecutionBatch(List.of(exec1, exec2, exec3));
// Processor for exec-1: seq=1, to, inputBody with "Hello World", inputHeaders with secret-token
FlatProcessorRecord proc1 = new FlatProcessorRecord(1, "proc-1", "to");
proc1.setStatus(ExecutionStatus.COMPLETED);
proc1.setStartTime(baseTime);
proc1.setDurationMs(50L);
proc1.setInputBody("Hello World request body");
proc1.setOutputBody("");
proc1.setInputHeaders(Map.of("Authorization", "Bearer secret-token"));
store.insertProcessorBatch("default", "exec-1", "route-timer", "my-app", baseTime, List.of(proc1));
}
@Test
void search_withNoFilters_returnsAllExecutions() {
SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(3);
assertThat(result.data()).hasSize(3);
}
@Test
void search_byStatus_filtersCorrectly() {
SearchRequest request = new SearchRequest(
"FAILED", null, null, null, null, null, null, null, null, null,
null, null, null, null, null, 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(1);
assertThat(result.data()).hasSize(1);
assertThat(result.data().get(0).executionId()).isEqualTo("exec-2");
}
@Test
void search_byTimeRange_filtersCorrectly() {
Instant baseTime = Instant.parse("2026-03-31T10:00:00Z");
// Time window covering exec-1 and exec-2 but not exec-3
SearchRequest request = new SearchRequest(
null, baseTime, baseTime.plusMillis(1500), null, null, null, null, null, null, null,
null, null, null, null, null, 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(2);
assertThat(result.data()).extracting(ExecutionSummary::executionId)
.containsExactlyInAnyOrder("exec-1", "exec-2");
}
@Test
void search_fullTextSearch_findsInErrorMessage() {
SearchRequest request = new SearchRequest(
null, null, null, null, null, null, "NullPointerException", null, null, null,
null, null, null, null, null, 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).executionId()).isEqualTo("exec-2");
}
@Test
void search_fullTextSearch_findsInInputBody() {
SearchRequest request = new SearchRequest(
null, null, null, null, null, null, "12345", null, null, null,
null, null, null, null, null, 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
}
@Test
void search_textInBody_searchesProcessorBodies() {
SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, "Hello World", null, null,
null, null, null, null, null, 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
}
@Test
void search_textInHeaders_searchesProcessorHeaders() {
SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, null, "secret-token", null,
null, null, null, null, null, 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
}
@Test
void search_textInErrors_searchesErrorFields() {
SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, null, null, "Foo.bar",
null, null, null, null, null, 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).executionId()).isEqualTo("exec-2");
}
@Test
void search_withHighlight_returnsSnippet() {
SearchRequest request = new SearchRequest(
null, null, null, null, null, null, "NullPointerException", null, null, null,
null, null, null, null, null, 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).highlight()).contains("NullPointerException");
}
@Test
void search_pagination_works() {
SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, null, null, null,
null, null, null, null, null, 0, 2, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(3);
assertThat(result.data()).hasSize(2);
assertThat(result.offset()).isEqualTo(0);
assertThat(result.limit()).isEqualTo(2);
}
@Test
void search_byApplication_filtersCorrectly() {
SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, null, null, null,
null, null, null, "other-app", null, 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).executionId()).isEqualTo("exec-3");
}
@Test
void search_byAgentIds_filtersCorrectly() {
SearchRequest request = new SearchRequest(
null, null, null, null, null, null, null, null, null, null,
null, null, null, null, List.of("agent-b"), 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).executionId()).isEqualTo("exec-3");
}
@Test
void count_returnsMatchingCount() {
SearchRequest request = new SearchRequest(
"COMPLETED", null, null, null, null, null, null, null, null, null,
null, null, null, null, null, 0, 50, null, null, null);
long count = searchIndex.count(request);
assertThat(count).isEqualTo(2);
}
@Test
void search_multipleStatusFilter_works() {
SearchRequest request = new SearchRequest(
"COMPLETED,FAILED", null, null, null, null, null, null, null, null, null,
null, null, null, null, null, 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(3);
}
@Test
void search_byCorrelationId_filtersCorrectly() {
SearchRequest request = new SearchRequest(
null, null, null, null, null, "corr-1", null, null, null, null,
null, null, null, null, null, 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
}
@Test
void search_byDurationRange_filtersCorrectly() {
SearchRequest request = new SearchRequest(
null, null, null, 300L, 600L, null, null, null, null, null,
null, null, null, null, null, 0, 50, null, null, null);
SearchResult<ExecutionSummary> result = searchIndex.search(request);
assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).executionId()).isEqualTo("exec-1");
}
}

View File

@@ -0,0 +1,112 @@
package com.cameleer.server.app.security;
import com.cameleer.server.app.AbstractPostgresIT;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests verifying bootstrap token validation on the registration endpoint.
*/
class BootstrapTokenIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
private static final String REGISTRATION_JSON = """
{
"instanceId": "bootstrap-test-agent",
"applicationId": "test-group",
"version": "1.0.0",
"routeIds": [],
"capabilities": {}
}
""";
@Test
void registerWithoutBootstrapToken_returns401() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(REGISTRATION_JSON, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void registerWithWrongBootstrapToken_returns401() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.set("Authorization", "Bearer wrong-token-value");
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(REGISTRATION_JSON, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void registerWithCorrectBootstrapToken_returns200WithTokens() throws Exception {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.set("Authorization", "Bearer test-bootstrap-token");
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(REGISTRATION_JSON, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("accessToken").asText()).isNotEmpty();
assertThat(body.get("refreshToken").asText()).isNotEmpty();
assertThat(body.get("serverPublicKey").asText()).isNotEmpty();
}
@Test
void registerWithPreviousBootstrapToken_returns200() {
// Dual-token rotation: previous token should also be accepted
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.set("Authorization", "Bearer old-bootstrap-token");
String json = """
{
"instanceId": "bootstrap-test-previous",
"applicationId": "test-group",
"version": "1.0.0",
"routeIds": [],
"capabilities": {}
}
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}

View File

@@ -0,0 +1,70 @@
package com.cameleer.server.app.security;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link BootstrapTokenValidator}.
* No Spring context needed — implementation constructed directly.
*/
class BootstrapTokenValidatorTest {
@Test
void validate_correctToken_returnsTrue() {
SecurityProperties props = new SecurityProperties();
props.setBootstrapToken("my-secret-token");
BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
assertTrue(validator.validate("my-secret-token"));
}
@Test
void validate_wrongToken_returnsFalse() {
SecurityProperties props = new SecurityProperties();
props.setBootstrapToken("my-secret-token");
BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
assertFalse(validator.validate("wrong-token"));
}
@Test
void validate_previousToken_returnsTrueWhenSet() {
SecurityProperties props = new SecurityProperties();
props.setBootstrapToken("new-token");
props.setBootstrapTokenPrevious("old-token");
BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
assertTrue(validator.validate("old-token"), "Previous token should be accepted during rotation");
}
@Test
void validate_nullToken_returnsFalse() {
SecurityProperties props = new SecurityProperties();
props.setBootstrapToken("my-secret-token");
BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
assertFalse(validator.validate(null));
}
@Test
void validate_blankToken_returnsFalse() {
SecurityProperties props = new SecurityProperties();
props.setBootstrapToken("my-secret-token");
BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
assertFalse(validator.validate(""));
assertFalse(validator.validate(" "));
}
@Test
void validate_previousTokenNotSet_onlyCurrentAccepted() {
SecurityProperties props = new SecurityProperties();
props.setBootstrapToken("current-token");
// bootstrapTokenPrevious is null by default
BootstrapTokenValidator validator = new BootstrapTokenValidator(props);
assertTrue(validator.validate("current-token"));
assertFalse(validator.validate("some-old-token"));
}
}

View File

@@ -0,0 +1,88 @@
package com.cameleer.server.app.security;
import com.cameleer.server.core.security.Ed25519SigningService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link Ed25519SigningServiceImpl}.
* No Spring context needed — implementation constructed directly.
*/
class Ed25519SigningServiceTest {
private Ed25519SigningService signingService;
@BeforeEach
void setUp() {
signingService = Ed25519SigningServiceImpl.ephemeral();
}
@Test
void getPublicKeyBase64_returnsNonNullBase64String() {
String publicKeyBase64 = signingService.getPublicKeyBase64();
assertNotNull(publicKeyBase64);
assertFalse(publicKeyBase64.isBlank());
// Verify it's valid Base64
assertDoesNotThrow(() -> Base64.getDecoder().decode(publicKeyBase64));
}
@Test
void sign_returnsBase64SignatureString() {
String signature = signingService.sign("test payload");
assertNotNull(signature);
assertFalse(signature.isBlank());
assertDoesNotThrow(() -> Base64.getDecoder().decode(signature));
}
@Test
void sign_signatureVerifiesAgainstPublicKey() throws Exception {
String payload = "important config data";
String signatureBase64 = signingService.sign(payload);
String publicKeyBase64 = signingService.getPublicKeyBase64();
// Reconstruct public key from Base64
byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyBase64);
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyBytes));
// Verify signature
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8));
assertTrue(verifier.verify(Base64.getDecoder().decode(signatureBase64)),
"Signature should verify against the public key");
}
@Test
void sign_differentPayloadsProduceDifferentSignatures() {
String sig1 = signingService.sign("payload one");
String sig2 = signingService.sign("payload two");
assertNotEquals(sig1, sig2, "Different payloads should produce different signatures");
}
@Test
void sign_tamperedPayloadFailsVerification() throws Exception {
String payload = "original payload";
String signatureBase64 = signingService.sign(payload);
String publicKeyBase64 = signingService.getPublicKeyBase64();
byte[] publicKeyBytes = Base64.getDecoder().decode(publicKeyBase64);
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
PublicKey publicKey = keyFactory.generatePublic(new X509EncodedKeySpec(publicKeyBytes));
// Verify against tampered payload
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update("tampered payload".getBytes(java.nio.charset.StandardCharsets.UTF_8));
assertFalse(verifier.verify(Base64.getDecoder().decode(signatureBase64)),
"Tampered payload should fail verification");
}
}

View File

@@ -0,0 +1,170 @@
package com.cameleer.server.app.security;
import com.cameleer.server.app.AbstractPostgresIT;
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.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.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for the JWT refresh flow.
*/
class JwtRefreshIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@Autowired
private JwtService jwtService;
private JsonNode registerAndGetTokens(String agentId) throws Exception {
String json = """
{
"instanceId": "%s",
"applicationId": "test-group",
"version": "1.0.0",
"routeIds": [],
"capabilities": {}
}
""".formatted(agentId);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.set("Authorization", "Bearer test-bootstrap-token");
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
return objectMapper.readTree(response.getBody());
}
@Test
void refreshWithValidToken_returnsNewAccessToken() throws Exception {
JsonNode tokens = registerAndGetTokens("refresh-valid");
String refreshToken = tokens.get("refreshToken").asText();
String refreshBody = "{\"refreshToken\":\"" + refreshToken + "\"}";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/refresh-valid/refresh",
new HttpEntity<>(refreshBody, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("accessToken").asText()).isNotEmpty();
assertThat(body.get("refreshToken").asText()).isNotEmpty();
assertThat(body.get("refreshToken").asText()).isNotEqualTo(refreshToken);
}
@Test
void refreshWithAccessToken_returns401() throws Exception {
JsonNode tokens = registerAndGetTokens("refresh-wrong-type");
String accessToken = tokens.get("accessToken").asText();
String refreshBody = "{\"refreshToken\":\"" + accessToken + "\"}";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/refresh-wrong-type/refresh",
new HttpEntity<>(refreshBody, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void refreshWithMismatchedAgentId_returns401() throws Exception {
JsonNode tokens = registerAndGetTokens("refresh-mismatch");
String refreshToken = tokens.get("refreshToken").asText();
String refreshBody = "{\"refreshToken\":\"" + refreshToken + "\"}";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
// Use a different agent ID in the path
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/wrong-agent-id/refresh",
new HttpEntity<>(refreshBody, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void refreshWithInvalidToken_returns401() {
String refreshBody = "{\"refreshToken\":\"invalid.jwt.token\"}";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/some-agent/refresh",
new HttpEntity<>(refreshBody, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void newAccessTokenFromRefresh_canAccessProtectedEndpoints() throws Exception {
JsonNode tokens = registerAndGetTokens("refresh-access-test");
String refreshToken = tokens.get("refreshToken").asText();
String refreshBody = "{\"refreshToken\":\"" + refreshToken + "\"}";
HttpHeaders refreshHeaders = new HttpHeaders();
refreshHeaders.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<String> refreshResponse = restTemplate.postForEntity(
"/api/v1/agents/refresh-access-test/refresh",
new HttpEntity<>(refreshBody, refreshHeaders),
String.class);
assertThat(refreshResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode refreshBody2 = objectMapper.readTree(refreshResponse.getBody());
String newAccessToken = refreshBody2.get("accessToken").asText();
// Use the new access token to hit a protected endpoint accessible by AGENT role
HttpHeaders authHeaders = new HttpHeaders();
authHeaders.set("Authorization", "Bearer " + newAccessToken);
authHeaders.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/search/executions",
HttpMethod.GET,
new HttpEntity<>(authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}

View File

@@ -0,0 +1,132 @@
package com.cameleer.server.app.security;
import com.cameleer.server.core.security.InvalidTokenException;
import com.cameleer.server.core.security.JwtService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Unit tests for {@link JwtServiceImpl}.
* No Spring context needed — implementations are constructed directly.
*/
class JwtServiceTest {
private JwtService jwtService;
@BeforeEach
void setUp() {
SecurityProperties props = new SecurityProperties();
props.setAccessTokenExpiryMs(3_600_000); // 1 hour
props.setRefreshTokenExpiryMs(604_800_000); // 7 days
jwtService = new JwtServiceImpl(props);
}
@Test
void createAccessToken_returnsSignedJwtWithCorrectClaims() {
String token = jwtService.createAccessToken("agent-1", "group-a");
assertNotNull(token);
assertFalse(token.isBlank());
// JWT format: header.payload.signature
assertEquals(3, token.split("\\.").length, "JWT should have 3 parts");
}
@Test
void createAccessToken_canBeValidated() {
String token = jwtService.createAccessToken("agent-1", "group-a");
String agentId = jwtService.validateAndExtractAgentId(token);
assertEquals("agent-1", agentId);
}
@Test
void createRefreshToken_returnsSignedJwt() {
String token = jwtService.createRefreshToken("agent-2", "group-b");
assertNotNull(token);
assertFalse(token.isBlank());
assertEquals(3, token.split("\\.").length, "JWT should have 3 parts");
}
@Test
void createRefreshToken_canBeValidatedWithRefreshMethod() {
String token = jwtService.createRefreshToken("agent-2", "group-b");
JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
assertEquals("agent-2", result.subject());
}
@Test
void validateAndExtractAgentId_rejectsRefreshToken() {
String refreshToken = jwtService.createRefreshToken("agent-3", "group-c");
assertThrows(InvalidTokenException.class, () ->
jwtService.validateAndExtractAgentId(refreshToken),
"Access validation should reject refresh tokens");
}
@Test
void validateRefreshToken_rejectsAccessToken() {
String accessToken = jwtService.createAccessToken("agent-4", "group-d");
assertThrows(InvalidTokenException.class, () ->
jwtService.validateRefreshToken(accessToken),
"Refresh validation should reject access tokens");
}
@Test
void accessToken_rolesRoundTrip() {
List<String> roles = List.of("ADMIN", "OPERATOR");
String token = jwtService.createAccessToken("user:admin", "user", roles);
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
assertEquals("user:admin", result.subject());
assertEquals("user", result.application());
assertEquals(roles, result.roles());
}
@Test
void refreshToken_rolesRoundTrip() {
List<String> roles = List.of("AGENT");
String token = jwtService.createRefreshToken("agent-1", "default", roles);
JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
assertEquals("agent-1", result.subject());
assertEquals("default", result.application());
assertEquals(roles, result.roles());
}
@Test
void legacyToken_emptyRoles() {
// Backward compat: tokens without explicit roles get empty list
String token = jwtService.createAccessToken("agent-1", "default");
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
assertEquals(List.of(), result.roles());
}
@Test
void configuredJwtSecret_producesStableTokens() {
SecurityProperties props = new SecurityProperties();
props.setAccessTokenExpiryMs(3_600_000);
props.setRefreshTokenExpiryMs(604_800_000);
props.setJwtSecret("my-test-secret-that-is-at-least-32-bytes");
JwtService svc1 = new JwtServiceImpl(props);
JwtService svc2 = new JwtServiceImpl(props);
String token = svc1.createAccessToken("agent-1", "default", List.of("AGENT"));
// Token created by svc1 should be validatable by svc2 (same secret)
JwtService.JwtValidationResult result = svc2.validateAccessToken(token);
assertEquals("agent-1", result.subject());
assertEquals(List.of("AGENT"), result.roles());
}
@Test
void validateAndExtractAgentId_rejectsExpiredToken() {
// Create a service with 0ms expiry to produce already-expired tokens
SecurityProperties shortProps = new SecurityProperties();
shortProps.setAccessTokenExpiryMs(0);
shortProps.setRefreshTokenExpiryMs(604_800_000);
JwtService shortLivedService = new JwtServiceImpl(shortProps);
String token = shortLivedService.createAccessToken("agent-5", "group-e");
assertThrows(InvalidTokenException.class, () ->
jwtService.validateAndExtractAgentId(token),
"Should reject expired tokens");
}
}

View File

@@ -0,0 +1,96 @@
package com.cameleer.server.app.security;
import com.cameleer.server.app.AbstractPostgresIT;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests verifying that registration returns security credentials
* and that those credentials can be used to access protected endpoints.
*/
class RegistrationSecurityIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
private ResponseEntity<String> registerAgent(String agentId) {
String json = """
{
"instanceId": "%s",
"applicationId": "test-group",
"version": "1.0.0",
"routeIds": [],
"capabilities": {}
}
""".formatted(agentId);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.set("Authorization", "Bearer test-bootstrap-token");
return restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, headers),
String.class);
}
@Test
void registrationResponse_containsNonNullServerPublicKey() throws Exception {
ResponseEntity<String> response = registerAgent("reg-sec-pubkey");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("serverPublicKey").asText())
.isNotNull()
.isNotEmpty();
// Base64-encoded Ed25519 public key should be a reasonable length
assertThat(body.get("serverPublicKey").asText().length()).isGreaterThan(20);
}
@Test
void registrationResponse_containsAccessAndRefreshTokens() throws Exception {
ResponseEntity<String> response = registerAgent("reg-sec-tokens");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("accessToken").asText()).isNotEmpty();
assertThat(body.get("refreshToken").asText()).isNotEmpty();
}
@Test
void accessTokenFromRegistration_canAccessProtectedEndpoints() throws Exception {
ResponseEntity<String> regResponse = registerAgent("reg-sec-access-test");
assertThat(regResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode regBody = objectMapper.readTree(regResponse.getBody());
String accessToken = regBody.get("accessToken").asText();
// Use the access token to hit a protected endpoint accessible by AGENT role
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/search/executions",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}

View File

@@ -0,0 +1,115 @@
package com.cameleer.server.app.security;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
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.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests verifying that the SecurityFilterChain correctly
* protects endpoints and allows public access where configured.
*/
class SecurityFilterIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private String viewerJwt;
@BeforeEach
void setUp() {
securityHelper.registerTestAgent("test-agent-security-filter-it");
viewerJwt = securityHelper.viewerToken();
}
@Test
void protectedEndpoint_withoutJwt_returns401or403() {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
assertThat(response.getStatusCode().value()).isIn(401, 403);
}
@Test
void protectedEndpoint_withValidJwt_returns200() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void healthEndpoint_withoutJwt_returns200() {
ResponseEntity<String> response = restTemplate.getForEntity(
"/api/v1/health", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void dataEndpoint_withoutJwt_returns401or403() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>("{}", headers),
String.class);
assertThat(response.getStatusCode().value()).isIn(401, 403);
}
@Test
void protectedEndpoint_withExpiredJwt_returns401or403() {
// An invalid/malformed token should be rejected
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer expired.invalid.token");
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
assertThat(response.getStatusCode().value()).isIn(401, 403);
}
@Test
void protectedEndpoint_withMalformedJwt_returns401or403() {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer not-a-jwt");
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
assertThat(response.getStatusCode().value()).isIn(401, 403);
}
}

View File

@@ -0,0 +1,270 @@
package com.cameleer.server.app.security;
import com.cameleer.server.app.AbstractPostgresIT;
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.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/**
* Integration test verifying that SSE command events carry valid Ed25519 signatures.
* <p>
* Flow: register agent (with bootstrap token) -> extract JWT + public key from response ->
* open SSE stream (with JWT query param) -> push config-update command (with JWT) ->
* receive SSE event -> verify signature field against server's Ed25519 public key.
*/
class SseSigningIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@Autowired
private Ed25519SigningService ed25519SigningService;
@LocalServerPort
private int port;
private HttpHeaders protocolHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
return headers;
}
private HttpHeaders authProtocolHeaders(String accessToken) {
HttpHeaders headers = protocolHeaders();
headers.set("Authorization", "Bearer " + accessToken);
return headers;
}
private HttpHeaders bootstrapHeaders() {
HttpHeaders headers = protocolHeaders();
headers.set("Authorization", "Bearer test-bootstrap-token");
return headers;
}
/**
* Registers an agent using the bootstrap token and returns the registration response.
* The response contains: instanceId, sseEndpoint, accessToken, refreshToken, serverPublicKey.
*/
private JsonNode registerAgentWithAuth(String agentId) throws Exception {
String json = """
{
"instanceId": "%s",
"applicationId": "test-group",
"version": "1.0.0",
"routeIds": ["route-1"],
"capabilities": {}
}
""".formatted(agentId);
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, bootstrapHeaders()),
String.class);
assertThat(response.getStatusCode().value()).isEqualTo(200);
return objectMapper.readTree(response.getBody());
}
private ResponseEntity<String> sendCommand(String agentId, String type, String payloadJson, String accessToken) {
String json = """
{"type": "%s", "payload": %s}
""".formatted(type, payloadJson);
return restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands",
new HttpEntity<>(json, authProtocolHeaders(accessToken)),
String.class);
}
private SseStream openSseStream(String agentId, String accessToken) {
List<String> lines = new ArrayList<>();
CountDownLatch connected = new CountDownLatch(1);
AtomicInteger statusCode = new AtomicInteger(0);
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/api/v1/agents/" + agentId + "/events?token=" + accessToken))
.header("Accept", "text/event-stream")
.GET()
.build();
CompletableFuture<Void> future = client.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())
.thenAccept(response -> {
statusCode.set(response.statusCode());
connected.countDown();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body()))) {
String line;
while ((line = reader.readLine()) != null) {
synchronized (lines) {
lines.add(line);
}
}
} catch (Exception e) {
// Stream closed -- expected
}
});
return new SseStream(lines, future, connected, statusCode);
}
private record SseStream(List<String> lines, CompletableFuture<Void> future,
CountDownLatch connected, AtomicInteger statusCode) {
List<String> snapshot() {
synchronized (lines) {
return new ArrayList<>(lines);
}
}
boolean awaitConnection(long timeoutMs) throws InterruptedException {
return connected.await(timeoutMs, TimeUnit.MILLISECONDS);
}
}
@Test
void configUpdateEvent_containsValidEd25519Signature() throws Exception {
String agentId = "sse-sign-it-" + UUID.randomUUID().toString().substring(0, 8);
JsonNode registration = registerAgentWithAuth(agentId);
String accessToken = registration.get("accessToken").asText();
String operatorToken = securityHelper.operatorToken();
String serverPublicKey = registration.get("serverPublicKey").asText();
SseStream stream = openSseStream(agentId, accessToken);
assertThat(stream.awaitConnection(5000)).isTrue();
assertThat(stream.statusCode().get()).isEqualTo(200);
String originalPayload = "{\"key\":\"value\",\"setting\":\"enabled\"}";
// Send config-update and wait for SSE event
await().atMost(10, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
.ignoreExceptions()
.until(() -> {
sendCommand(agentId, "config-update", originalPayload, operatorToken);
List<String> lines = stream.snapshot();
return lines.stream().anyMatch(l -> l.contains("event:config-update"));
});
// Extract the data line from SSE event
List<String> lines = stream.snapshot();
String dataLine = lines.stream()
.filter(l -> l.startsWith("data:"))
.findFirst()
.orElseThrow(() -> new AssertionError("No data line in SSE event"));
String eventData = dataLine.substring("data:".length());
JsonNode eventNode = objectMapper.readTree(eventData);
// Verify signature field exists
assertThat(eventNode.has("signature"))
.as("SSE event data should contain 'signature' field")
.isTrue();
String signatureBase64 = eventNode.get("signature").asText();
assertThat(signatureBase64).isNotBlank();
// Verify original payload fields are preserved
assertThat(eventNode.get("key").asText()).isEqualTo("value");
assertThat(eventNode.get("setting").asText()).isEqualTo("enabled");
// Verify signature against original payload using server's public key
byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
PublicKey publicKey = loadPublicKey(serverPublicKey);
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(originalPayload.getBytes(StandardCharsets.UTF_8));
assertThat(verifier.verify(signatureBytes))
.as("Ed25519 signature should verify against original payload")
.isTrue();
}
@Test
void deepTraceEvent_containsValidSignature() throws Exception {
String agentId = "sse-sign-trace-" + UUID.randomUUID().toString().substring(0, 8);
JsonNode registration = registerAgentWithAuth(agentId);
String accessToken = registration.get("accessToken").asText();
String operatorToken = securityHelper.operatorToken();
String serverPublicKey = registration.get("serverPublicKey").asText();
SseStream stream = openSseStream(agentId, accessToken);
assertThat(stream.awaitConnection(5000)).isTrue();
assertThat(stream.statusCode().get()).isEqualTo(200);
String originalPayload = "{\"correlationId\":\"trace-123\"}";
await().atMost(10, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
.ignoreExceptions()
.until(() -> {
sendCommand(agentId, "deep-trace", originalPayload, operatorToken);
List<String> lines = stream.snapshot();
return lines.stream().anyMatch(l -> l.contains("event:deep-trace"));
});
List<String> lines = stream.snapshot();
String dataLine = lines.stream()
.filter(l -> l.startsWith("data:"))
.findFirst()
.orElseThrow();
JsonNode eventNode = objectMapper.readTree(dataLine.substring("data:".length()));
assertThat(eventNode.has("signature")).isTrue();
// Verify signature
byte[] signatureBytes = Base64.getDecoder().decode(eventNode.get("signature").asText());
PublicKey publicKey = loadPublicKey(serverPublicKey);
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(originalPayload.getBytes(StandardCharsets.UTF_8));
assertThat(verifier.verify(signatureBytes)).isTrue();
}
private PublicKey loadPublicKey(String base64PublicKey) throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(base64PublicKey);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
return keyFactory.generatePublic(keySpec);
}
}

View File

@@ -0,0 +1,12 @@
package com.cameleer.server.app.security;
/**
* Previously this class provided a permit-all SecurityFilterChain for tests.
* Now that the real {@link SecurityConfig} is in place, this class is no longer needed.
* <p>
* Kept as an empty marker to avoid import errors in case any test referenced it.
* The real security configuration in {@link SecurityConfig} is active during tests.
*/
public class TestSecurityConfig {
// Intentionally empty -- real SecurityConfig is now active in tests
}

View File

@@ -0,0 +1,156 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.core.agent.AgentEventRecord;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.cameleer.server.app.ClickHouseTestHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.nio.charset.StandardCharsets;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class ClickHouseAgentEventRepositoryIT {
@Container
static final ClickHouseContainer clickhouse =
new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
private JdbcTemplate jdbc;
private ClickHouseAgentEventRepository repo;
@BeforeEach
void setUp() throws Exception {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(clickhouse.getJdbcUrl());
ds.setUsername(clickhouse.getUsername());
ds.setPassword(clickhouse.getPassword());
jdbc = new JdbcTemplate(ds);
ClickHouseTestHelper.executeInitSql(jdbc);
jdbc.execute("TRUNCATE TABLE agent_events");
repo = new ClickHouseAgentEventRepository("default", jdbc);
}
// ── Helpers ──────────────────────────────────────────────────────────────
/**
* Insert a row with an explicit timestamp so tests can control ordering and ranges.
*/
private void insertAt(String instanceId, String applicationId, String eventType, String detail, Instant ts) {
jdbc.update(
"INSERT INTO agent_events (tenant_id, instance_id, application_id, event_type, detail, timestamp) VALUES (?, ?, ?, ?, ?, ?)",
"default", instanceId, applicationId, eventType, detail, Timestamp.from(ts));
}
// ── Tests ─────────────────────────────────────────────────────────────────
@Test
void insert_writesEvent() {
repo.insert("agent-1", "app-a", "CONNECTED", "agent came online");
Long count = jdbc.queryForObject(
"SELECT count() FROM agent_events WHERE instance_id = 'agent-1'",
Long.class);
assertThat(count).isEqualTo(1);
}
@Test
void query_byAppId_filtersCorrectly() {
repo.insert("agent-1", "app-x", "CONNECTED", "");
repo.insert("agent-2", "app-y", "DISCONNECTED", "");
List<AgentEventRecord> results = repo.query("app-x", null, null, null, 100);
assertThat(results).hasSize(1);
assertThat(results.get(0).applicationId()).isEqualTo("app-x");
assertThat(results.get(0).instanceId()).isEqualTo("agent-1");
}
@Test
void query_byAgentId_filtersCorrectly() {
repo.insert("agent-alpha", "app-shared", "CONNECTED", "");
repo.insert("agent-beta", "app-shared", "CONNECTED", "");
List<AgentEventRecord> results = repo.query(null, "agent-alpha", null, null, 100);
assertThat(results).hasSize(1);
assertThat(results.get(0).instanceId()).isEqualTo("agent-alpha");
}
@Test
void query_byTimeRange_filtersCorrectly() {
Instant t1 = Instant.parse("2026-01-01T10:00:00Z");
Instant t2 = Instant.parse("2026-01-01T11:00:00Z");
Instant t3 = Instant.parse("2026-01-01T12:00:00Z");
insertAt("agent-1", "app-a", "CONNECTED", "early", t1);
insertAt("agent-1", "app-a", "HEARTBEAT", "mid", t2);
insertAt("agent-1", "app-a", "DISCONNECTED", "late", t3);
// Query [t2, t3) — should return only the middle event
List<AgentEventRecord> results = repo.query(null, null, t2, t3, 100);
assertThat(results).hasSize(1);
assertThat(results.get(0).eventType()).isEqualTo("HEARTBEAT");
}
@Test
void query_respectsLimit() {
Instant base = Instant.parse("2026-02-01T00:00:00Z");
for (int i = 0; i < 10; i++) {
insertAt("agent-1", "app-a", "HEARTBEAT", "beat-" + i, base.plusSeconds(i));
}
List<AgentEventRecord> results = repo.query(null, null, null, null, 3);
assertThat(results).hasSize(3);
}
@Test
void query_returnsZeroId() {
repo.insert("agent-1", "app-a", "CONNECTED", "");
List<AgentEventRecord> results = repo.query(null, null, null, null, 10);
assertThat(results).hasSize(1);
assertThat(results.get(0).id()).isEqualTo(0L);
}
@Test
void query_noFilters_returnsAllEvents() {
repo.insert("agent-1", "app-a", "CONNECTED", "");
repo.insert("agent-2", "app-b", "DISCONNECTED", "");
List<AgentEventRecord> results = repo.query(null, null, null, null, 100);
assertThat(results).hasSize(2);
}
@Test
void query_resultsOrderedByTimestampDesc() {
Instant t1 = Instant.parse("2026-03-01T08:00:00Z");
Instant t2 = Instant.parse("2026-03-01T09:00:00Z");
Instant t3 = Instant.parse("2026-03-01T10:00:00Z");
insertAt("agent-1", "app-a", "FIRST", "", t1);
insertAt("agent-1", "app-a", "SECOND", "", t2);
insertAt("agent-1", "app-a", "THIRD", "", t3);
List<AgentEventRecord> results = repo.query(null, null, null, null, 100);
assertThat(results.get(0).eventType()).isEqualTo("THIRD");
assertThat(results.get(1).eventType()).isEqualTo("SECOND");
assertThat(results.get(2).eventType()).isEqualTo("FIRST");
}
}

View File

@@ -0,0 +1,196 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.core.ingestion.ChunkAccumulator;
import com.cameleer.server.core.ingestion.MergedExecution;
import com.cameleer.server.core.storage.DiagramStore;
import com.cameleer.server.core.search.ExecutionSummary;
import com.cameleer.server.core.search.SearchRequest;
import com.cameleer.server.core.search.SearchResult;
import com.cameleer.common.model.ExecutionChunk;
import com.cameleer.common.model.ExecutionStatus;
import com.cameleer.common.model.FlatProcessorRecord;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class ClickHouseChunkPipelineIT {
@Container
static final ClickHouseContainer clickhouse =
new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
private JdbcTemplate jdbc;
private ClickHouseExecutionStore executionStore;
private ClickHouseSearchIndex searchIndex;
private ChunkAccumulator accumulator;
private List<MergedExecution> executionBuffer;
private List<ChunkAccumulator.ProcessorBatch> processorBuffer;
@BeforeEach
void setUp() throws IOException {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(clickhouse.getJdbcUrl());
ds.setUsername(clickhouse.getUsername());
ds.setPassword(clickhouse.getPassword());
jdbc = new JdbcTemplate(ds);
String execDdl = new String(getClass().getResourceAsStream(
"/clickhouse/V2__executions.sql").readAllBytes(), StandardCharsets.UTF_8);
String procDdl = new String(getClass().getResourceAsStream(
"/clickhouse/V3__processor_executions.sql").readAllBytes(), StandardCharsets.UTF_8);
jdbc.execute(execDdl);
jdbc.execute(procDdl);
jdbc.execute("TRUNCATE TABLE executions");
jdbc.execute("TRUNCATE TABLE processor_executions");
executionStore = new ClickHouseExecutionStore("default", jdbc);
searchIndex = new ClickHouseSearchIndex("default", jdbc);
executionBuffer = new ArrayList<>();
processorBuffer = new ArrayList<>();
DiagramStore noOpDiagramStore = org.mockito.Mockito.mock(DiagramStore.class);
accumulator = new ChunkAccumulator("default", executionBuffer::add, processorBuffer::add,
noOpDiagramStore, Duration.ofMinutes(5), id -> "default");
}
@Test
void fullPipeline_chunkedIngestion_thenSearch() {
Instant start = Instant.parse("2026-03-31T12:00:00Z");
// Chunk 0: RUNNING with initial processors
ExecutionChunk chunk0 = new ExecutionChunk();
chunk0.setExchangeId("pipeline-1");
chunk0.setApplicationId("order-service");
chunk0.setInstanceId("pod-1");
chunk0.setRouteId("order-route");
chunk0.setCorrelationId("corr-1");
chunk0.setStatus(ExecutionStatus.RUNNING);
chunk0.setStartTime(start);
chunk0.setEngineLevel("DEEP");
chunk0.setAttributes(Map.of("orderId", "ORD-123"));
chunk0.setChunkSeq(0);
chunk0.setFinal(false);
FlatProcessorRecord p1 = new FlatProcessorRecord(1, "log1", "log");
p1.setStatus(ExecutionStatus.COMPLETED);
p1.setStartTime(start);
p1.setDurationMs(2L);
FlatProcessorRecord p2 = new FlatProcessorRecord(2, "split1", "split");
p2.setIterationSize(3);
p2.setStatus(ExecutionStatus.COMPLETED);
p2.setStartTime(start.plusMillis(2));
p2.setDurationMs(100L);
FlatProcessorRecord p3 = new FlatProcessorRecord(3, "to1", "to");
p3.setParentSeq(2);
p3.setParentProcessorId("split1");
p3.setIteration(0);
p3.setStatus(ExecutionStatus.COMPLETED);
p3.setStartTime(start.plusMillis(5));
p3.setDurationMs(30L);
p3.setResolvedEndpointUri("http://inventory/api");
p3.setInputBody("order ABC-123 check stock");
p3.setOutputBody("stock available");
chunk0.setProcessors(List.of(p1, p2, p3));
accumulator.onChunk(chunk0);
// Processors should be buffered immediately
assertThat(processorBuffer).hasSize(1);
assertThat(executionBuffer).isEmpty();
// Chunk 1: COMPLETED (final)
ExecutionChunk chunk1 = new ExecutionChunk();
chunk1.setExchangeId("pipeline-1");
chunk1.setApplicationId("order-service");
chunk1.setInstanceId("pod-1");
chunk1.setRouteId("order-route");
chunk1.setCorrelationId("corr-1");
chunk1.setStatus(ExecutionStatus.COMPLETED);
chunk1.setStartTime(start);
chunk1.setEndTime(start.plusMillis(750));
chunk1.setDurationMs(750L);
chunk1.setEngineLevel("DEEP");
chunk1.setChunkSeq(1);
chunk1.setFinal(true);
FlatProcessorRecord p4 = new FlatProcessorRecord(4, "to1", "to");
p4.setParentSeq(2);
p4.setParentProcessorId("split1");
p4.setIteration(1);
p4.setStatus(ExecutionStatus.COMPLETED);
p4.setStartTime(start.plusMillis(40));
p4.setDurationMs(25L);
p4.setResolvedEndpointUri("http://inventory/api");
p4.setInputBody("order DEF-456 check stock");
p4.setOutputBody("stock available");
chunk1.setProcessors(List.of(p4));
accumulator.onChunk(chunk1);
assertThat(executionBuffer).hasSize(1);
assertThat(processorBuffer).hasSize(2);
// Flush to ClickHouse (simulating ExecutionFlushScheduler)
executionStore.insertExecutionBatch(executionBuffer);
for (ChunkAccumulator.ProcessorBatch batch : processorBuffer) {
executionStore.insertProcessorBatch(
batch.tenantId(), batch.executionId(),
batch.routeId(), batch.applicationId(),
batch.execStartTime(), batch.processors());
}
// Search by order ID in attributes (via _search_text on executions)
SearchResult<ExecutionSummary> result = searchIndex.search(new SearchRequest(
null, null, null, null, null, null,
"ORD-123", null, null, null,
null, null, null, null, null,
0, 50, null, null, null));
assertThat(result.total()).isEqualTo(1);
assertThat(result.data().get(0).executionId()).isEqualTo("pipeline-1");
assertThat(result.data().get(0).status()).isEqualTo("COMPLETED");
assertThat(result.data().get(0).durationMs()).isEqualTo(750L);
// Search in processor body
SearchResult<ExecutionSummary> bodyResult = searchIndex.search(new SearchRequest(
null, null, null, null, null, null,
null, "ABC-123", null, null,
null, null, null, null, null,
0, 50, null, null, null));
assertThat(bodyResult.total()).isEqualTo(1);
// Verify iteration data in processor_executions
Integer iterSize = jdbc.queryForObject(
"SELECT iteration_size FROM processor_executions WHERE execution_id = 'pipeline-1' AND seq = 2",
Integer.class);
assertThat(iterSize).isEqualTo(3);
Integer iter0 = jdbc.queryForObject(
"SELECT iteration FROM processor_executions WHERE execution_id = 'pipeline-1' AND seq = 3",
Integer.class);
assertThat(iter0).isEqualTo(0);
// Verify total processor count
Integer procCount = jdbc.queryForObject(
"SELECT count() FROM processor_executions WHERE execution_id = 'pipeline-1'",
Integer.class);
assertThat(procCount).isEqualTo(4);
}
}

View File

@@ -0,0 +1,211 @@
package com.cameleer.server.app.storage;
import com.cameleer.common.graph.NodeType;
import com.cameleer.common.graph.RouteGraph;
import com.cameleer.common.graph.RouteNode;
import com.cameleer.server.core.ingestion.TaggedDiagram;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.cameleer.server.app.ClickHouseTestHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class ClickHouseDiagramStoreIT {
@Container
static final ClickHouseContainer clickhouse =
new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
private JdbcTemplate jdbc;
private ClickHouseDiagramStore store;
@BeforeEach
void setUp() throws Exception {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(clickhouse.getJdbcUrl());
ds.setUsername(clickhouse.getUsername());
ds.setPassword(clickhouse.getPassword());
jdbc = new JdbcTemplate(ds);
ClickHouseTestHelper.executeInitSql(jdbc);
jdbc.execute("TRUNCATE TABLE route_diagrams");
store = new ClickHouseDiagramStore("default", jdbc);
}
// ── Helpers ──────────────────────────────────────────────────────────
private RouteGraph buildGraph(String routeId, String... nodeIds) {
RouteGraph graph = new RouteGraph(routeId);
if (nodeIds.length > 0) {
RouteNode root = new RouteNode(nodeIds[0], NodeType.ENDPOINT, "from:" + nodeIds[0]);
for (int i = 1; i < nodeIds.length; i++) {
root.addChild(new RouteNode(nodeIds[i], NodeType.PROCESSOR, "proc:" + nodeIds[i]));
}
graph.setRoot(root);
}
return graph;
}
private TaggedDiagram tagged(String instanceId, String applicationId, RouteGraph graph) {
return new TaggedDiagram(instanceId, applicationId, graph);
}
// ── Tests ─────────────────────────────────────────────────────────────
@Test
void store_insertsNewDiagram() {
RouteGraph graph = buildGraph("route-1", "node-a", "node-b");
store.store(tagged("agent-1", "my-app", graph));
// Allow ReplacingMergeTree to settle
jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");
long count = jdbc.queryForObject(
"SELECT count() FROM route_diagrams WHERE route_id = 'route-1'",
Long.class);
assertThat(count).isEqualTo(1);
}
@Test
void store_duplicateHashIgnored() {
RouteGraph graph = buildGraph("route-1", "node-a");
TaggedDiagram diagram = tagged("agent-1", "my-app", graph);
store.store(diagram);
store.store(diagram); // same graph → same hash
jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");
long count = jdbc.queryForObject(
"SELECT count() FROM route_diagrams FINAL WHERE route_id = 'route-1'",
Long.class);
assertThat(count).isEqualTo(1);
}
@Test
void findByContentHash_returnsGraph() {
RouteGraph graph = buildGraph("route-2", "node-x");
graph.setDescription("Test route");
TaggedDiagram diagram = tagged("agent-2", "app-a", graph);
store.store(diagram);
// Compute the expected hash
String hash = store.findContentHashForRoute("route-2", "agent-2")
.orElseThrow(() -> new AssertionError("No hash found for route-2/agent-2"));
Optional<RouteGraph> result = store.findByContentHash(hash);
assertThat(result).isPresent();
assertThat(result.get().getRouteId()).isEqualTo("route-2");
assertThat(result.get().getDescription()).isEqualTo("Test route");
}
@Test
void findByContentHash_returnsEmptyForUnknownHash() {
Optional<RouteGraph> result = store.findByContentHash("nonexistent-hash-000");
assertThat(result).isEmpty();
}
@Test
void findContentHashForRoute_returnsMostRecent() throws InterruptedException {
RouteGraph graphV1 = buildGraph("route-3", "node-1");
graphV1.setDescription("v1");
RouteGraph graphV2 = buildGraph("route-3", "node-1", "node-2");
graphV2.setDescription("v2");
store.store(tagged("agent-1", "my-app", graphV1));
// Small delay to ensure different created_at timestamps
Thread.sleep(10);
store.store(tagged("agent-1", "my-app", graphV2));
Optional<String> hashOpt = store.findContentHashForRoute("route-3", "agent-1");
assertThat(hashOpt).isPresent();
// The hash should correspond to graphV2 (the most recent)
String expectedHash = ClickHouseDiagramStore.sha256Hex(
store.findByContentHash(hashOpt.get())
.map(g -> {
try {
return new com.fasterxml.jackson.databind.ObjectMapper()
.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule())
.writeValueAsString(g);
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.orElseThrow());
assertThat(hashOpt.get()).isEqualTo(expectedHash);
// Verify retrieved graph has v2's content
RouteGraph retrieved = store.findByContentHash(hashOpt.get()).orElseThrow();
assertThat(retrieved.getDescription()).isEqualTo("v2");
}
@Test
void findContentHashForRouteByAgents_returnsHash() {
RouteGraph graph = buildGraph("route-4", "node-z");
store.store(tagged("agent-10", "app-b", graph));
store.store(tagged("agent-20", "app-b", graph));
Optional<String> result = store.findContentHashForRouteByAgents(
"route-4", java.util.List.of("agent-10", "agent-20"));
assertThat(result).isPresent();
}
@Test
void findContentHashForRouteByAgents_emptyListReturnsEmpty() {
Optional<String> result = store.findContentHashForRouteByAgents("route-x", java.util.List.of());
assertThat(result).isEmpty();
}
@Test
void findProcessorRouteMapping_extractsMapping() {
// Build a graph with 3 nodes: root + 2 children
RouteGraph graph = buildGraph("route-5", "proc-from-1", "proc-to-2", "proc-log-3");
store.store(tagged("agent-1", "app-mapping", graph));
jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");
Map<String, String> mapping = store.findProcessorRouteMapping("app-mapping");
assertThat(mapping).containsEntry("proc-from-1", "route-5");
assertThat(mapping).containsEntry("proc-to-2", "route-5");
assertThat(mapping).containsEntry("proc-log-3", "route-5");
}
@Test
void findProcessorRouteMapping_multipleRoutes() {
RouteGraph graphA = buildGraph("route-a", "proc-a1", "proc-a2");
RouteGraph graphB = buildGraph("route-b", "proc-b1");
store.store(tagged("agent-1", "multi-app", graphA));
store.store(tagged("agent-1", "multi-app", graphB));
jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL");
Map<String, String> mapping = store.findProcessorRouteMapping("multi-app");
assertThat(mapping).containsEntry("proc-a1", "route-a");
assertThat(mapping).containsEntry("proc-a2", "route-a");
assertThat(mapping).containsEntry("proc-b1", "route-b");
}
@Test
void findProcessorRouteMapping_unknownAppReturnsEmpty() {
Map<String, String> mapping = store.findProcessorRouteMapping("nonexistent-app");
assertThat(mapping).isEmpty();
}
}

View File

@@ -0,0 +1,247 @@
package com.cameleer.server.app.storage;
import com.cameleer.common.model.ExecutionStatus;
import com.cameleer.common.model.FlatProcessorRecord;
import com.cameleer.server.core.detail.DetailService;
import com.cameleer.server.core.detail.ExecutionDetail;
import com.cameleer.server.core.detail.ProcessorNode;
import com.cameleer.server.core.ingestion.MergedExecution;
import com.cameleer.server.core.storage.ExecutionStore.ProcessorRecord;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.cameleer.server.app.ClickHouseTestHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class ClickHouseExecutionReadIT {
@Container
static final ClickHouseContainer clickhouse =
new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
private JdbcTemplate jdbc;
private ClickHouseExecutionStore store;
private DetailService detailService;
@BeforeEach
void setUp() throws Exception {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(clickhouse.getJdbcUrl());
ds.setUsername(clickhouse.getUsername());
ds.setPassword(clickhouse.getPassword());
jdbc = new JdbcTemplate(ds);
ClickHouseTestHelper.executeInitSql(jdbc);
jdbc.execute("TRUNCATE TABLE executions");
jdbc.execute("TRUNCATE TABLE processor_executions");
store = new ClickHouseExecutionStore("default", jdbc);
detailService = new DetailService(store);
}
// --- Helper factory methods ---
private MergedExecution minimalExecution(String executionId) {
return new MergedExecution(
"default", 1L, executionId, "route-a", "agent-1", "my-app", "default",
"COMPLETED", "corr-1", "exchange-1",
Instant.parse("2026-04-01T10:00:00Z"),
Instant.parse("2026-04-01T10:00:01Z"),
1000L,
"", "", "", "", "", "",
"", "REGULAR",
"", "", "", "", "", "", "{}",
"", "",
false, false,
null, null
);
}
private FlatProcessorRecord processor(int seq, String processorId, String processorType) {
FlatProcessorRecord p = new FlatProcessorRecord(seq, processorId, processorType);
p.setStatus(ExecutionStatus.COMPLETED);
p.setStartTime(Instant.parse("2026-04-01T10:00:00Z"));
p.setDurationMs(10L);
return p;
}
// --- Tests ---
@Test
void findById_returnsInsertedExecution() {
store.insertExecutionBatch(List.of(minimalExecution("exec-1")));
Optional<com.cameleer.server.core.storage.ExecutionStore.ExecutionRecord> result =
store.findById("exec-1");
assertThat(result).isPresent();
assertThat(result.get().executionId()).isEqualTo("exec-1");
assertThat(result.get().routeId()).isEqualTo("route-a");
assertThat(result.get().status()).isEqualTo("COMPLETED");
assertThat(result.get().instanceId()).isEqualTo("agent-1");
assertThat(result.get().applicationId()).isEqualTo("my-app");
assertThat(result.get().processorsJson()).isNull();
}
@Test
void findById_notFound_returnsEmpty() {
Optional<com.cameleer.server.core.storage.ExecutionStore.ExecutionRecord> result =
store.findById("nonexistent");
assertThat(result).isEmpty();
}
@Test
void findProcessors_returnsOrderedBySeq() {
store.insertExecutionBatch(List.of(minimalExecution("exec-1")));
FlatProcessorRecord p1 = processor(1, "log-1", "log");
FlatProcessorRecord p2 = processor(2, "transform-1", "setBody");
FlatProcessorRecord p3 = processor(3, "to-1", "to");
p2.setParentSeq(1);
p2.setParentProcessorId("log-1");
p3.setParentSeq(1);
p3.setParentProcessorId("log-1");
store.insertProcessorBatch(
"default", "exec-1", "route-a", "my-app",
Instant.parse("2026-04-01T10:00:00Z"),
List.of(p1, p2, p3));
List<ProcessorRecord> records = store.findProcessors("exec-1");
assertThat(records).hasSize(3);
assertThat(records.get(0).seq()).isEqualTo(1);
assertThat(records.get(0).processorId()).isEqualTo("log-1");
assertThat(records.get(1).seq()).isEqualTo(2);
assertThat(records.get(1).processorId()).isEqualTo("transform-1");
assertThat(records.get(1).parentSeq()).isEqualTo(1);
assertThat(records.get(2).seq()).isEqualTo(3);
assertThat(records.get(2).processorId()).isEqualTo("to-1");
assertThat(records.get(2).parentSeq()).isEqualTo(1);
}
@Test
void findProcessorBySeq_returnsCorrectRecord() {
store.insertExecutionBatch(List.of(minimalExecution("exec-1")));
FlatProcessorRecord p1 = processor(1, "log-1", "log");
FlatProcessorRecord p2 = processor(2, "to-1", "to");
FlatProcessorRecord p3 = processor(3, "log-2", "log");
store.insertProcessorBatch(
"default", "exec-1", "route-a", "my-app",
Instant.parse("2026-04-01T10:00:00Z"),
List.of(p1, p2, p3));
Optional<ProcessorRecord> result = store.findProcessorBySeq("exec-1", 2);
assertThat(result).isPresent();
assertThat(result.get().seq()).isEqualTo(2);
assertThat(result.get().processorId()).isEqualTo("to-1");
assertThat(result.get().processorType()).isEqualTo("to");
}
@Test
void findProcessorById_returnsFirstOccurrence() {
store.insertExecutionBatch(List.of(minimalExecution("exec-1")));
// Three processors with the same processorId (iteration scenario)
FlatProcessorRecord iter0 = processor(1, "to-1", "to");
iter0.setIteration(0);
FlatProcessorRecord iter1 = processor(2, "to-1", "to");
iter1.setIteration(1);
FlatProcessorRecord iter2 = processor(3, "to-1", "to");
iter2.setIteration(2);
store.insertProcessorBatch(
"default", "exec-1", "route-a", "my-app",
Instant.parse("2026-04-01T10:00:00Z"),
List.of(iter0, iter1, iter2));
Optional<ProcessorRecord> result = store.findProcessorById("exec-1", "to-1");
assertThat(result).isPresent();
// ClickHouse LIMIT 1 returns one record — verify it has the correct processorId
assertThat(result.get().processorId()).isEqualTo("to-1");
// The returned record should have the lowest seq (first occurrence)
assertThat(result.get().seq()).isEqualTo(1);
}
@Test
void detailService_buildTree_withIterations() {
// Insert an execution
store.insertExecutionBatch(List.of(minimalExecution("exec-1")));
// Build a split scenario:
// seq=1: log-1 (root)
// seq=2: split-1 (root)
// seq=3: to-1 (child of split-1, iteration=0)
// seq=4: to-1 (child of split-1, iteration=1)
// seq=5: to-1 (child of split-1, iteration=2)
// seq=6: log-2 (root)
FlatProcessorRecord log1 = processor(1, "log-1", "log");
FlatProcessorRecord split1 = processor(2, "split-1", "split");
split1.setIterationSize(3);
FlatProcessorRecord to1iter0 = processor(3, "to-1", "to");
to1iter0.setParentSeq(2);
to1iter0.setParentProcessorId("split-1");
to1iter0.setIteration(0);
FlatProcessorRecord to1iter1 = processor(4, "to-1", "to");
to1iter1.setParentSeq(2);
to1iter1.setParentProcessorId("split-1");
to1iter1.setIteration(1);
FlatProcessorRecord to1iter2 = processor(5, "to-1", "to");
to1iter2.setParentSeq(2);
to1iter2.setParentProcessorId("split-1");
to1iter2.setIteration(2);
FlatProcessorRecord log2 = processor(6, "log-2", "log");
store.insertProcessorBatch(
"default", "exec-1", "route-a", "my-app",
Instant.parse("2026-04-01T10:00:00Z"),
List.of(log1, split1, to1iter0, to1iter1, to1iter2, log2));
// Invoke DetailService
Optional<ExecutionDetail> detail = detailService.getDetail("exec-1");
assertThat(detail).isPresent();
List<ProcessorNode> roots = detail.get().processors();
assertThat(roots).hasSize(3);
assertThat(roots.get(0).getProcessorId()).isEqualTo("log-1");
assertThat(roots.get(1).getProcessorId()).isEqualTo("split-1");
assertThat(roots.get(2).getProcessorId()).isEqualTo("log-2");
// Verify split-1 has 3 children (all with processorId "to-1")
ProcessorNode splitNode = roots.get(1);
List<ProcessorNode> children = splitNode.getChildren();
assertThat(children).hasSize(3);
assertThat(children).allMatch(c -> "to-1".equals(c.getProcessorId()));
// Verify iteration values via getLoopIndex() (iteration maps to loopIndex in the seq-based path)
assertThat(children.get(0).getLoopIndex()).isEqualTo(0);
assertThat(children.get(1).getLoopIndex()).isEqualTo(1);
assertThat(children.get(2).getLoopIndex()).isEqualTo(2);
}
}

View File

@@ -0,0 +1,223 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.core.ingestion.MergedExecution;
import com.cameleer.common.model.ExecutionStatus;
import com.cameleer.common.model.FlatProcessorRecord;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.cameleer.server.app.ClickHouseTestHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class ClickHouseExecutionStoreIT {
@Container
static final ClickHouseContainer clickhouse =
new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
private JdbcTemplate jdbc;
private ClickHouseExecutionStore store;
@BeforeEach
void setUp() throws Exception {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(clickhouse.getJdbcUrl());
ds.setUsername(clickhouse.getUsername());
ds.setPassword(clickhouse.getPassword());
jdbc = new JdbcTemplate(ds);
ClickHouseTestHelper.executeInitSql(jdbc);
jdbc.execute("TRUNCATE TABLE executions");
jdbc.execute("TRUNCATE TABLE processor_executions");
store = new ClickHouseExecutionStore("default", jdbc);
}
@Test
void insertExecutionBatch_writesToClickHouse() {
MergedExecution exec = new MergedExecution(
"default", 1L, "exec-1", "route-a", "agent-1", "my-app", "default",
"COMPLETED", "corr-1", "exchange-1",
Instant.parse("2026-03-31T10:00:00Z"),
Instant.parse("2026-03-31T10:00:01Z"),
1000L,
"some error", "stack trace", "IOException", "IO",
"FileNotFoundException", "file not found",
"hash-abc", "FULL",
"{\"key\":\"val\"}", "{\"out\":\"val\"}",
"{\"h1\":\"v1\"}", "{\"h2\":\"v2\"}",
"", "",
"{\"attr\":\"val\"}",
"trace-123", "span-456",
true, false,
null, null
);
store.insertExecutionBatch(List.of(exec));
Integer count = jdbc.queryForObject(
"SELECT count() FROM executions WHERE execution_id = 'exec-1'",
Integer.class);
assertThat(count).isEqualTo(1);
}
@Test
void insertProcessorBatch_writesToClickHouse() {
FlatProcessorRecord proc = new FlatProcessorRecord(1, "proc-1", "to");
proc.setStatus(ExecutionStatus.COMPLETED);
proc.setStartTime(Instant.parse("2026-03-31T10:00:00Z"));
proc.setDurationMs(50L);
proc.setResolvedEndpointUri("http://example.com");
proc.setInputBody("input body");
proc.setOutputBody("output body");
proc.setInputHeaders(Map.of("h1", "v1"));
proc.setOutputHeaders(Map.of("h2", "v2"));
proc.setAttributes(Map.of("a1", "v1"));
store.insertProcessorBatch(
"default", "exec-1", "route-a", "my-app",
Instant.parse("2026-03-31T10:00:00Z"),
List.of(proc));
Integer count = jdbc.queryForObject(
"SELECT count() FROM processor_executions WHERE execution_id = 'exec-1'",
Integer.class);
assertThat(count).isEqualTo(1);
// Verify seq is stored
Integer seq = jdbc.queryForObject(
"SELECT seq FROM processor_executions WHERE execution_id = 'exec-1'",
Integer.class);
assertThat(seq).isEqualTo(1);
}
@Test
void insertProcessorBatch_withIterations() {
FlatProcessorRecord splitContainer = new FlatProcessorRecord(1, "split-1", "split");
splitContainer.setIterationSize(3);
splitContainer.setStatus(ExecutionStatus.COMPLETED);
splitContainer.setStartTime(Instant.parse("2026-03-31T10:00:00Z"));
splitContainer.setDurationMs(300L);
FlatProcessorRecord child0 = new FlatProcessorRecord(2, "child-proc", "to");
child0.setParentSeq(1);
child0.setParentProcessorId("split-1");
child0.setIteration(0);
child0.setStatus(ExecutionStatus.COMPLETED);
child0.setStartTime(Instant.parse("2026-03-31T10:00:00.100Z"));
child0.setDurationMs(80L);
child0.setResolvedEndpointUri("http://svc-a");
child0.setInputBody("body0");
child0.setOutputBody("out0");
FlatProcessorRecord child1 = new FlatProcessorRecord(3, "child-proc", "to");
child1.setParentSeq(1);
child1.setParentProcessorId("split-1");
child1.setIteration(1);
child1.setStatus(ExecutionStatus.COMPLETED);
child1.setStartTime(Instant.parse("2026-03-31T10:00:00.200Z"));
child1.setDurationMs(90L);
child1.setResolvedEndpointUri("http://svc-a");
child1.setInputBody("body1");
child1.setOutputBody("out1");
FlatProcessorRecord child2 = new FlatProcessorRecord(4, "child-proc", "to");
child2.setParentSeq(1);
child2.setParentProcessorId("split-1");
child2.setIteration(2);
child2.setStatus(ExecutionStatus.COMPLETED);
child2.setStartTime(Instant.parse("2026-03-31T10:00:00.300Z"));
child2.setDurationMs(100L);
child2.setResolvedEndpointUri("http://svc-a");
child2.setInputBody("body2");
child2.setOutputBody("out2");
store.insertProcessorBatch(
"default", "exec-2", "route-b", "my-app",
Instant.parse("2026-03-31T10:00:00Z"),
List.of(splitContainer, child0, child1, child2));
Integer count = jdbc.queryForObject(
"SELECT count() FROM processor_executions WHERE execution_id = 'exec-2'",
Integer.class);
assertThat(count).isEqualTo(4);
// Verify iteration data on the split container
Integer iterationSize = jdbc.queryForObject(
"SELECT iteration_size FROM processor_executions " +
"WHERE execution_id = 'exec-2' AND seq = 1",
Integer.class);
assertThat(iterationSize).isEqualTo(3);
// Verify iteration index on a child
Integer iteration = jdbc.queryForObject(
"SELECT iteration FROM processor_executions " +
"WHERE execution_id = 'exec-2' AND seq = 3",
Integer.class);
assertThat(iteration).isEqualTo(1);
}
@Test
void insertExecutionBatch_emptyList_doesNothing() {
store.insertExecutionBatch(List.of());
Integer count = jdbc.queryForObject(
"SELECT count() FROM executions", Integer.class);
assertThat(count).isEqualTo(0);
}
@Test
void insertExecutionBatch_replacingMergeTree_keepsLatestVersion() {
MergedExecution v1 = new MergedExecution(
"default", 1L, "exec-r", "route-a", "agent-1", "my-app", "default",
"RUNNING", "corr-1", "exchange-1",
Instant.parse("2026-03-31T10:00:00Z"),
null, null,
"", "", "", "", "", "",
"", "FULL",
"", "", "", "", "", "", "",
"", "",
false, false,
null, null
);
MergedExecution v2 = new MergedExecution(
"default", 2L, "exec-r", "route-a", "agent-1", "my-app", "default",
"COMPLETED", "corr-1", "exchange-1",
Instant.parse("2026-03-31T10:00:00Z"),
Instant.parse("2026-03-31T10:00:05Z"),
5000L,
"", "", "", "", "", "",
"", "FULL",
"", "", "", "", "", "", "",
"", "",
false, false,
null, null
);
store.insertExecutionBatch(List.of(v1));
store.insertExecutionBatch(List.of(v2));
// Force merge to apply ReplacingMergeTree deduplication
jdbc.execute("OPTIMIZE TABLE executions FINAL");
String status = jdbc.queryForObject(
"SELECT status FROM executions " +
"WHERE execution_id = 'exec-r'",
String.class);
assertThat(status).isEqualTo("COMPLETED");
}
}

View File

@@ -0,0 +1,114 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.core.storage.model.MetricTimeSeries;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class ClickHouseMetricsQueryStoreIT {
@Container
static final ClickHouseContainer clickhouse =
new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
private JdbcTemplate jdbc;
private ClickHouseMetricsQueryStore queryStore;
@BeforeEach
void setUp() {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(clickhouse.getJdbcUrl());
ds.setUsername(clickhouse.getUsername());
ds.setPassword(clickhouse.getPassword());
jdbc = new JdbcTemplate(ds);
jdbc.execute("""
CREATE TABLE IF NOT EXISTS agent_metrics (
tenant_id LowCardinality(String) DEFAULT 'default',
collected_at DateTime64(3),
instance_id LowCardinality(String),
metric_name LowCardinality(String),
metric_value Float64,
tags Map(String, String) DEFAULT map(),
server_received_at DateTime64(3) DEFAULT now64(3)
)
ENGINE = MergeTree()
ORDER BY (tenant_id, instance_id, metric_name, collected_at)
""");
jdbc.execute("TRUNCATE TABLE agent_metrics");
// Seed test data: 6 data points across 1 hour for two metrics
Instant base = Instant.parse("2026-03-31T10:00:00Z");
for (int i = 0; i < 6; i++) {
Instant ts = base.plusSeconds(i * 600); // every 10 minutes
jdbc.update("INSERT INTO agent_metrics (instance_id, metric_name, metric_value, collected_at) VALUES (?, ?, ?, ?)",
"agent-1", "cpu.usage", 50.0 + i * 5, java.sql.Timestamp.from(ts));
jdbc.update("INSERT INTO agent_metrics (instance_id, metric_name, metric_value, collected_at) VALUES (?, ?, ?, ?)",
"agent-1", "memory.free", 1000.0 - i * 100, java.sql.Timestamp.from(ts));
}
queryStore = new ClickHouseMetricsQueryStore("default", jdbc);
}
@Test
void queryTimeSeries_returnsDataGroupedByMetric() {
Instant from = Instant.parse("2026-03-31T10:00:00Z");
Instant to = Instant.parse("2026-03-31T11:00:00Z");
Map<String, List<MetricTimeSeries.Bucket>> result =
queryStore.queryTimeSeries("agent-1", List.of("cpu.usage", "memory.free"), from, to, 6);
assertThat(result).containsKeys("cpu.usage", "memory.free");
assertThat(result.get("cpu.usage")).isNotEmpty();
assertThat(result.get("memory.free")).isNotEmpty();
}
@Test
void queryTimeSeries_bucketsAverageCorrectly() {
Instant from = Instant.parse("2026-03-31T10:00:00Z");
Instant to = Instant.parse("2026-03-31T11:00:00Z");
// 1 bucket for the entire hour = average of all 6 values
Map<String, List<MetricTimeSeries.Bucket>> result =
queryStore.queryTimeSeries("agent-1", List.of("cpu.usage"), from, to, 1);
assertThat(result.get("cpu.usage")).hasSize(1);
// Values: 50, 55, 60, 65, 70, 75 → avg = 62.5
assertThat(result.get("cpu.usage").get(0).value()).isCloseTo(62.5, org.assertj.core.data.Offset.offset(0.1));
}
@Test
void queryTimeSeries_noData_returnsEmptyLists() {
Instant from = Instant.parse("2025-01-01T00:00:00Z");
Instant to = Instant.parse("2025-01-01T01:00:00Z");
Map<String, List<MetricTimeSeries.Bucket>> result =
queryStore.queryTimeSeries("agent-1", List.of("cpu.usage"), from, to, 6);
assertThat(result.get("cpu.usage")).isEmpty();
}
@Test
void queryTimeSeries_unknownAgent_returnsEmpty() {
Instant from = Instant.parse("2026-03-31T10:00:00Z");
Instant to = Instant.parse("2026-03-31T11:00:00Z");
Map<String, List<MetricTimeSeries.Bucket>> result =
queryStore.queryTimeSeries("nonexistent", List.of("cpu.usage"), from, to, 6);
assertThat(result.get("cpu.usage")).isEmpty();
}
}

View File

@@ -0,0 +1,108 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.core.storage.model.MetricsSnapshot;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import com.zaxxer.hikari.HikariDataSource;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class ClickHouseMetricsStoreIT {
@Container
static final ClickHouseContainer clickhouse =
new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
private JdbcTemplate jdbc;
private ClickHouseMetricsStore store;
@BeforeEach
void setUp() {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(clickhouse.getJdbcUrl());
ds.setUsername(clickhouse.getUsername());
ds.setPassword(clickhouse.getPassword());
jdbc = new JdbcTemplate(ds);
jdbc.execute("""
CREATE TABLE IF NOT EXISTS agent_metrics (
tenant_id LowCardinality(String) DEFAULT 'default',
collected_at DateTime64(3),
instance_id LowCardinality(String),
metric_name LowCardinality(String),
metric_value Float64,
tags Map(String, String) DEFAULT map(),
server_received_at DateTime64(3) DEFAULT now64(3)
)
ENGINE = MergeTree()
ORDER BY (tenant_id, instance_id, metric_name, collected_at)
""");
jdbc.execute("TRUNCATE TABLE agent_metrics");
store = new ClickHouseMetricsStore("default", jdbc);
}
@Test
void insertBatch_writesMetricsToClickHouse() {
List<MetricsSnapshot> batch = List.of(
new MetricsSnapshot("agent-1", Instant.parse("2026-03-31T10:00:00Z"),
"cpu.usage", 75.5, Map.of("host", "server-1")),
new MetricsSnapshot("agent-1", Instant.parse("2026-03-31T10:00:01Z"),
"memory.free", 1024.0, null)
);
store.insertBatch(batch);
Integer count = jdbc.queryForObject(
"SELECT count() FROM agent_metrics WHERE instance_id = 'agent-1'",
Integer.class);
assertThat(count).isEqualTo(2);
}
@Test
void insertBatch_storesTags() {
store.insertBatch(List.of(
new MetricsSnapshot("agent-2", Instant.parse("2026-03-31T10:00:00Z"),
"disk.used", 500.0, Map.of("mount", "/data", "fs", "ext4"))
));
// Just verify we can read back the row with tags
Integer count = jdbc.queryForObject(
"SELECT count() FROM agent_metrics WHERE instance_id = 'agent-2'",
Integer.class);
assertThat(count).isEqualTo(1);
}
@Test
void insertBatch_emptyList_doesNothing() {
store.insertBatch(List.of());
Integer count = jdbc.queryForObject("SELECT count() FROM agent_metrics", Integer.class);
assertThat(count).isEqualTo(0);
}
@Test
void insertBatch_nullTags_defaultsToEmptyMap() {
store.insertBatch(List.of(
new MetricsSnapshot("agent-3", Instant.parse("2026-03-31T10:00:00Z"),
"cpu.usage", 50.0, null)
));
Integer count = jdbc.queryForObject(
"SELECT count() FROM agent_metrics WHERE instance_id = 'agent-3'",
Integer.class);
assertThat(count).isEqualTo(1);
}
}

View File

@@ -0,0 +1,344 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.core.search.ExecutionStats;
import com.cameleer.server.core.search.StatsTimeseries;
import com.cameleer.server.core.search.TopError;
import com.cameleer.server.core.storage.StatsStore.PunchcardCell;
import com.zaxxer.hikari.HikariDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import com.cameleer.server.app.ClickHouseTestHelper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.testcontainers.clickhouse.ClickHouseContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.nio.charset.StandardCharsets;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Testcontainers
class ClickHouseStatsStoreIT {
@Container
static final ClickHouseContainer clickhouse =
new ClickHouseContainer("clickhouse/clickhouse-server:24.12");
private JdbcTemplate jdbc;
private ClickHouseStatsStore store;
// base time: 2026-03-31T10:00:00Z (a Tuesday)
private static final Instant BASE = Instant.parse("2026-03-31T10:00:00Z");
@BeforeEach
void setUp() throws Exception {
HikariDataSource ds = new HikariDataSource();
ds.setJdbcUrl(clickhouse.getJdbcUrl());
ds.setUsername(clickhouse.getUsername());
ds.setPassword(clickhouse.getPassword());
jdbc = new JdbcTemplate(ds);
ClickHouseTestHelper.executeInitSql(jdbc);
// Truncate base tables
jdbc.execute("TRUNCATE TABLE executions");
jdbc.execute("TRUNCATE TABLE processor_executions");
seedTestData();
// Try the failing query to capture it in query_log, then check
try {
jdbc.queryForMap(
"SELECT countMerge(total_count) AS tc, countIfMerge(failed_count) AS fc, " +
"sumMerge(duration_sum) / greatest(countMerge(total_count), 1) AS avg, " +
"quantileMerge(0.99)(p99_duration) AS p99, " +
"countIfMerge(running_count) AS rc " +
"FROM stats_1m_all WHERE tenant_id = 'default' " +
"AND bucket >= '2026-03-31 09:59:00' AND bucket < '2026-03-31 10:05:00'");
} catch (Exception e) {
System.out.println("Expected error: " + e.getMessage().substring(0, 80));
}
jdbc.execute("SYSTEM FLUSH LOGS");
// Get ALL recent queries to see what the driver sends
var queryLog = jdbc.queryForList(
"SELECT type, substring(query, 1, 200) AS q " +
"FROM system.query_log WHERE event_time > now() - 30 " +
"AND query NOT LIKE '%system.query_log%' AND query NOT LIKE '%FLUSH%' " +
"ORDER BY event_time DESC LIMIT 20");
for (var entry : queryLog) {
System.out.println("LOG: " + entry.get("type") + " | " + entry.get("q"));
}
store = new ClickHouseStatsStore("default", jdbc);
}
private void seedTestData() {
// 10 executions across 2 apps, 2 routes, spanning 5 minutes
// app-1, route-a: 4 COMPLETED (200ms, 300ms, 400ms, 500ms)
insertExecution("exec-01", BASE.plusSeconds(0), "app-1", "route-a", "agent-1",
"COMPLETED", 200L, "", "");
insertExecution("exec-02", BASE.plusSeconds(60), "app-1", "route-a", "agent-1",
"COMPLETED", 300L, "", "");
insertExecution("exec-03", BASE.plusSeconds(120), "app-1", "route-a", "agent-1",
"COMPLETED", 400L, "", "");
insertExecution("exec-04", BASE.plusSeconds(180), "app-1", "route-a", "agent-1",
"COMPLETED", 500L, "", "");
// app-1, route-a: 2 FAILED (100ms, 150ms) with error_type="NPE"
insertExecution("exec-05", BASE.plusSeconds(60), "app-1", "route-a", "agent-1",
"FAILED", 100L, "NPE", "null ref");
insertExecution("exec-06", BASE.plusSeconds(120), "app-1", "route-a", "agent-1",
"FAILED", 150L, "NPE", "null ref");
// app-1, route-b: 2 COMPLETED (50ms, 60ms)
insertExecution("exec-07", BASE.plusSeconds(60), "app-1", "route-b", "agent-1",
"COMPLETED", 50L, "", "");
insertExecution("exec-08", BASE.plusSeconds(120), "app-1", "route-b", "agent-1",
"COMPLETED", 60L, "", "");
// app-2, route-c: 1 COMPLETED (1000ms)
insertExecution("exec-09", BASE.plusSeconds(60), "app-2", "route-c", "agent-2",
"COMPLETED", 1000L, "", "");
// app-2, route-c: 1 RUNNING (null duration)
insertExecution("exec-10", BASE.plusSeconds(180), "app-2", "route-c", "agent-2",
"RUNNING", null, "", "");
// 5 processor records for processor stats testing
// app-1, route-a, processor_type="to": 3 COMPLETED
insertProcessor("exec-01", 1, "proc-to-1", "to", BASE.plusSeconds(0),
"app-1", "route-a", "COMPLETED", 50L);
insertProcessor("exec-02", 1, "proc-to-2", "to", BASE.plusSeconds(60),
"app-1", "route-a", "COMPLETED", 80L);
insertProcessor("exec-03", 1, "proc-to-3", "to", BASE.plusSeconds(120),
"app-1", "route-a", "COMPLETED", 90L);
// app-1, route-a, processor_type="log": 2 COMPLETED
insertProcessor("exec-01", 2, "proc-log-1", "log", BASE.plusSeconds(1),
"app-1", "route-a", "COMPLETED", 10L);
insertProcessor("exec-02", 2, "proc-log-2", "log", BASE.plusSeconds(61),
"app-1", "route-a", "COMPLETED", 15L);
}
private void insertExecution(String executionId, Instant startTime, String appId,
String routeId, String instanceId, String status,
Long durationMs, String errorType, String errorMessage) {
jdbc.update(
"INSERT INTO executions (tenant_id, execution_id, start_time, route_id, " +
"instance_id, application_id, status, duration_ms, error_type, error_message) " +
"VALUES ('default', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
executionId, Timestamp.from(startTime), routeId, instanceId, appId,
status, durationMs, errorType, errorMessage);
}
private void insertProcessor(String executionId, int seq, String processorId,
String processorType, Instant startTime,
String appId, String routeId, String status,
Long durationMs) {
jdbc.update(
"INSERT INTO processor_executions (tenant_id, execution_id, seq, processor_id, " +
"processor_type, start_time, route_id, application_id, status, duration_ms) " +
"VALUES ('default', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
executionId, seq, processorId, processorType, Timestamp.from(startTime),
routeId, appId, status, durationMs);
}
// ── Stats Tests ──────────────────────────────────────────────────────
@Test
void stats_returnsCorrectGlobalTotals() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
ExecutionStats stats = store.stats(from, to, null);
assertThat(stats.totalCount()).isEqualTo(10);
assertThat(stats.failedCount()).isEqualTo(2);
assertThat(stats.activeCount()).isEqualTo(1);
assertThat(stats.avgDurationMs()).isGreaterThan(0);
assertThat(stats.p99LatencyMs()).isGreaterThan(0);
}
@Test
void statsForApp_filtersCorrectly() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
ExecutionStats app1 = store.statsForApp(from, to, "app-1", null);
assertThat(app1.totalCount()).isEqualTo(8);
ExecutionStats app2 = store.statsForApp(from, to, "app-2", null);
assertThat(app2.totalCount()).isEqualTo(2);
}
@Test
void statsForRoute_filtersCorrectly() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
ExecutionStats routeA = store.statsForRoute(from, to, "route-a", List.of(), null);
assertThat(routeA.totalCount()).isEqualTo(6);
}
// ── Timeseries Tests ─────────────────────────────────────────────────
@Test
void timeseries_returnsBuckets() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
StatsTimeseries ts = store.timeseries(from, to, 5, null);
assertThat(ts.buckets()).isNotEmpty();
long totalAcrossBuckets = ts.buckets().stream()
.mapToLong(StatsTimeseries.TimeseriesBucket::totalCount).sum();
assertThat(totalAcrossBuckets).isEqualTo(10);
}
@Test
void timeseriesForApp_filtersCorrectly() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
StatsTimeseries ts = store.timeseriesForApp(from, to, 5, "app-1", null);
long totalAcrossBuckets = ts.buckets().stream()
.mapToLong(StatsTimeseries.TimeseriesBucket::totalCount).sum();
assertThat(totalAcrossBuckets).isEqualTo(8);
}
@Test
void timeseriesGroupedByApp_returnsMap() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
Map<String, StatsTimeseries> grouped = store.timeseriesGroupedByApp(from, to, 5, null);
assertThat(grouped).containsKeys("app-1", "app-2");
}
@Test
void timeseriesGroupedByRoute_returnsMap() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
Map<String, StatsTimeseries> grouped = store.timeseriesGroupedByRoute(from, to, 5, "app-1", null);
assertThat(grouped).containsKeys("route-a", "route-b");
}
// ── SLA Tests ────────────────────────────────────────────────────────
@Test
void slaCompliance_calculatesCorrectly() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
// threshold=250ms: among 9 non-RUNNING executions:
// compliant (<=250ms): exec-01(200), exec-05(100), exec-06(150), exec-07(50), exec-08(60) = 5
// total non-running: 9
// compliance = 5/9 * 100 ~ 55.56%
double sla = store.slaCompliance(from, to, 250, null, null, null);
assertThat(sla).isBetween(55.0, 56.0);
}
// ── Top Errors Tests ─────────────────────────────────────────────────
@Test
void topErrors_returnsRankedErrors() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
List<TopError> errors = store.topErrors(from, to, null, null, 10, null);
assertThat(errors).isNotEmpty();
assertThat(errors.get(0).errorType()).isEqualTo("NPE");
assertThat(errors.get(0).count()).isEqualTo(2);
}
// ── Active Error Types Test ──────────────────────────────────────────
@Test
void activeErrorTypes_countsDistinct() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
int count = store.activeErrorTypes(from, to, "app-1", null);
assertThat(count).isEqualTo(1); // only "NPE"
}
// ── Punchcard Test ───────────────────────────────────────────────────
@Test
void punchcard_returnsWeekdayHourCells() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
List<PunchcardCell> cells = store.punchcard(from, to, null, null);
assertThat(cells).isNotEmpty();
long totalCount = cells.stream().mapToLong(PunchcardCell::totalCount).sum();
assertThat(totalCount).isEqualTo(10);
}
@Test
void slaCountsByApp_returnsMap() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
// threshold=250ms
Map<String, long[]> counts = store.slaCountsByApp(from, to, 250, null);
assertThat(counts).containsKeys("app-1", "app-2");
// app-1: 8 total executions, all non-RUNNING
// compliant (<=250ms): exec-01(200), exec-05(100), exec-06(150), exec-07(50), exec-08(60) = 5
long[] app1 = counts.get("app-1");
assertThat(app1[0]).isEqualTo(5); // compliant
assertThat(app1[1]).isEqualTo(8); // total non-running
// app-2: 1 COMPLETED(1000ms) + 1 RUNNING → 1 non-RUNNING, 0 compliant
long[] app2 = counts.get("app-2");
assertThat(app2[0]).isEqualTo(0); // compliant
assertThat(app2[1]).isEqualTo(1); // total non-running
}
@Test
void slaCountsByRoute_returnsMap() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
Map<String, long[]> counts = store.slaCountsByRoute(from, to, "app-1", 250, null);
assertThat(counts).containsKeys("route-a", "route-b");
// route-a: exec-01(200)OK, exec-02(300)NO, exec-03(400)NO, exec-04(500)NO,
// exec-05(100)OK, exec-06(150)OK → 3 compliant, 6 total
long[] routeA = counts.get("route-a");
assertThat(routeA[0]).isEqualTo(3); // compliant
assertThat(routeA[1]).isEqualTo(6); // total
// route-b: exec-07(50)OK, exec-08(60)OK → 2 compliant, 2 total
long[] routeB = counts.get("route-b");
assertThat(routeB[0]).isEqualTo(2);
assertThat(routeB[1]).isEqualTo(2);
}
// ── Processor Stats Test ─────────────────────────────────────────────
@Test
void statsForProcessor_filtersCorrectly() {
Instant from = BASE.minusSeconds(60);
Instant to = BASE.plusSeconds(300);
ExecutionStats toStats = store.statsForProcessor(from, to, "route-a", "to");
assertThat(toStats.totalCount()).isEqualTo(3);
assertThat(toStats.activeCount()).isEqualTo(0); // processor stats have no running_count
ExecutionStats logStats = store.statsForProcessor(from, to, "route-a", "log");
assertThat(logStats.totalCount()).isEqualTo(2);
}
}

View File

@@ -0,0 +1,141 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
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.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration test proving that diagram_content_hash is populated during
* execution ingestion when a RouteGraph exists for the same route+agent.
*/
class DiagramLinkingIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private HttpHeaders authHeaders;
@BeforeEach
void setUp() {
String jwt = securityHelper.registerTestAgent("test-agent-diagram-linking-it");
authHeaders = securityHelper.authHeaders(jwt);
}
@Test
void diagramHashPopulated_whenRouteGraphExistsBeforeExecution() {
String graphJson = """
{
"routeId": "diagram-link-route",
"description": "Linking test diagram",
"version": 1,
"nodes": [
{"id": "n1", "type": "ENDPOINT", "label": "direct:start"},
{"id": "n2", "type": "BEAN", "label": "myBean"}
],
"edges": [
{"source": "n1", "target": "n2", "edgeType": "FLOW"}
]
}
""";
ResponseEntity<String> diagramResponse = restTemplate.postForEntity(
"/api/v1/data/diagrams",
new HttpEntity<>(graphJson, authHeaders),
String.class);
assertThat(diagramResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
String diagramHash = jdbcTemplate.queryForObject(
"SELECT content_hash FROM route_diagrams WHERE route_id = 'diagram-link-route' LIMIT 1",
String.class);
assertThat(diagramHash).isNotNull().isNotEmpty();
String executionJson = """
{
"routeId": "diagram-link-route",
"exchangeId": "ex-diag-link-1",
"correlationId": "corr-diag-link-1",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:01Z",
"durationMs": 1000,
"processors": [
{
"processorId": "proc-1",
"processorType": "bean",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:00.500Z",
"durationMs": 500,
"children": []
}
]
}
""";
ResponseEntity<String> execResponse = restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>(executionJson, authHeaders),
String.class);
assertThat(execResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
String hash = jdbcTemplate.queryForObject(
"SELECT diagram_content_hash FROM executions WHERE route_id = 'diagram-link-route'",
String.class);
assertThat(hash)
.isNotNull()
.isNotEmpty()
.hasSize(64)
.matches("[a-f0-9]{64}");
}
@Test
void diagramHashEmpty_whenNoRouteGraphExists() {
String executionJson = """
{
"routeId": "no-diagram-route",
"exchangeId": "ex-no-diag-1",
"correlationId": "corr-no-diag-1",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:01Z",
"durationMs": 1000,
"processors": [
{
"processorId": "proc-no-diag",
"processorType": "log",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:00.500Z",
"durationMs": 500,
"children": []
}
]
}
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>(executionJson, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
String hash = jdbcTemplate.queryForObject(
"SELECT diagram_content_hash FROM executions WHERE route_id = 'no-diagram-route'",
String.class);
assertThat(hash)
.isNotNull()
.isEmpty();
}
}

View File

@@ -0,0 +1,47 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.junit.jupiter.api.Assertions.*;
class FlywayMigrationIT extends AbstractPostgresIT {
@Autowired
JdbcTemplate jdbcTemplate;
@Test
void allMigrationsApplySuccessfully() {
// Verify RBAC tables exist
Integer userCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM users", Integer.class);
assertEquals(0, userCount);
Integer roleCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM roles", Integer.class);
assertEquals(4, roleCount); // AGENT, VIEWER, OPERATOR, ADMIN
Integer groupCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM groups", Integer.class);
assertEquals(1, groupCount); // Admins
// Verify config/audit tables exist
Integer configCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM server_config", Integer.class);
assertEquals(0, configCount);
Integer auditCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log", Integer.class);
assertEquals(0, auditCount);
Integer appConfigCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM application_config", Integer.class);
assertEquals(0, appConfigCount);
Integer appSettingsCount = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM app_settings", Integer.class);
assertEquals(0, appSettingsCount);
}
}

View File

@@ -0,0 +1,213 @@
package com.cameleer.server.app.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
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.HttpHeaders;
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;
/**
* Integration test verifying that processor execution data is correctly populated
* during ingestion of route executions with nested processors and exchange data.
*/
class IngestionSchemaIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private HttpHeaders authHeaders;
@BeforeEach
void setUp() {
String jwt = securityHelper.registerTestAgent("test-agent-ingestion-schema-it");
authHeaders = securityHelper.authHeaders(jwt);
}
@Test
void processorTreeMetadata_depthsAndParentIdsCorrect() {
String json = """
{
"routeId": "schema-test-tree",
"exchangeId": "ex-tree-1",
"correlationId": "corr-tree-1",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:01Z",
"durationMs": 1000,
"processors": [
{
"processorId": "root-proc",
"processorType": "bean",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:00.500Z",
"durationMs": 500,
"inputBody": "root-input",
"outputBody": "root-output",
"inputHeaders": {"Content-Type": "application/json"},
"outputHeaders": {"X-Result": "ok"},
"children": [
{
"processorId": "child-proc",
"processorType": "log",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00.100Z",
"endTime": "2026-03-11T10:00:00.400Z",
"durationMs": 300,
"inputBody": "child-input",
"outputBody": "child-output",
"children": [
{
"processorId": "grandchild-proc",
"processorType": "setHeader",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00.200Z",
"endTime": "2026-03-11T10:00:00.300Z",
"durationMs": 100,
"children": []
}
]
}
]
}
]
}
""";
postExecution(json);
// Verify execution row exists
Integer execCount = jdbcTemplate.queryForObject(
"SELECT count(*) FROM executions WHERE execution_id = 'ex-tree-1'",
Integer.class);
assertThat(execCount).isEqualTo(1);
// Verify processors were flattened into processor_executions
List<Map<String, Object>> processors = jdbcTemplate.queryForList(
"SELECT processor_id, processor_type, depth, parent_processor_id, " +
"input_body, output_body, input_headers " +
"FROM processor_executions WHERE execution_id = 'ex-tree-1' " +
"ORDER BY depth, processor_id");
assertThat(processors).hasSize(3);
// Root processor: depth=0, no parent
assertThat(processors.get(0).get("processor_id")).isEqualTo("root-proc");
assertThat(((Number) processors.get(0).get("depth")).intValue()).isEqualTo(0);
assertThat(processors.get(0).get("parent_processor_id")).isNull();
assertThat(processors.get(0).get("input_body")).isEqualTo("root-input");
assertThat(processors.get(0).get("output_body")).isEqualTo("root-output");
assertThat(processors.get(0).get("input_headers").toString()).contains("Content-Type");
// Child processor: depth=1, parent=root-proc
assertThat(processors.get(1).get("processor_id")).isEqualTo("child-proc");
assertThat(((Number) processors.get(1).get("depth")).intValue()).isEqualTo(1);
assertThat(processors.get(1).get("parent_processor_id")).isEqualTo("root-proc");
assertThat(processors.get(1).get("input_body")).isEqualTo("child-input");
assertThat(processors.get(1).get("output_body")).isEqualTo("child-output");
// Grandchild processor: depth=2, parent=child-proc
assertThat(processors.get(2).get("processor_id")).isEqualTo("grandchild-proc");
assertThat(((Number) processors.get(2).get("depth")).intValue()).isEqualTo(2);
assertThat(processors.get(2).get("parent_processor_id")).isEqualTo("child-proc");
}
@Test
void exchangeBodiesStored() {
String json = """
{
"routeId": "schema-test-bodies",
"exchangeId": "ex-bodies-1",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:01Z",
"durationMs": 1000,
"processors": [
{
"processorId": "proc-1",
"processorType": "bean",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:00.500Z",
"durationMs": 500,
"inputBody": "processor-body-text",
"outputBody": "processor-output-text",
"children": []
}
]
}
""";
postExecution(json);
// Verify processor body data
List<Map<String, Object>> processors = jdbcTemplate.queryForList(
"SELECT input_body, output_body FROM processor_executions " +
"WHERE execution_id = 'ex-bodies-1'");
assertThat(processors).hasSize(1);
assertThat(processors.get(0).get("input_body")).isEqualTo("processor-body-text");
assertThat(processors.get(0).get("output_body")).isEqualTo("processor-output-text");
}
@Test
void nullSnapshots_insertSucceedsWithEmptyDefaults() {
String json = """
{
"routeId": "schema-test-null-snap",
"exchangeId": "ex-null-1",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:01Z",
"durationMs": 1000,
"processors": [
{
"processorId": "proc-null",
"processorType": "log",
"status": "COMPLETED",
"startTime": "2026-03-11T10:00:00Z",
"endTime": "2026-03-11T10:00:00.500Z",
"durationMs": 500,
"children": []
}
]
}
""";
postExecution(json);
// Verify execution exists
Integer count = jdbcTemplate.queryForObject(
"SELECT count(*) FROM executions WHERE execution_id = 'ex-null-1'",
Integer.class);
assertThat(count).isEqualTo(1);
// Verify processor with null bodies inserted successfully
List<Map<String, Object>> processors = jdbcTemplate.queryForList(
"SELECT depth, parent_processor_id, input_body, output_body " +
"FROM processor_executions WHERE execution_id = 'ex-null-1'");
assertThat(processors).hasSize(1);
assertThat(((Number) processors.get(0).get("depth")).intValue()).isEqualTo(0);
assertThat(processors.get(0).get("parent_processor_id")).isNull();
}
private void postExecution(String json) {
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
}
}

View File

@@ -0,0 +1,41 @@
package com.cameleer.server.core.license;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class LicenseGateTest {
@Test
void noLicense_allFeaturesEnabled() {
LicenseGate gate = new LicenseGate();
// No license loaded -> open mode
assertThat(gate.isEnabled(Feature.debugger)).isTrue();
assertThat(gate.isEnabled(Feature.replay)).isTrue();
assertThat(gate.isEnabled(Feature.lineage)).isTrue();
assertThat(gate.getTier()).isEqualTo("open");
}
@Test
void withLicense_onlyLicensedFeaturesEnabled() {
LicenseGate gate = new LicenseGate();
LicenseInfo license = new LicenseInfo("MID",
Set.of(Feature.topology, Feature.lineage, Feature.correlation),
Map.of("max_agents", 10, "retention_days", 30),
Instant.now(), Instant.now().plus(365, ChronoUnit.DAYS));
gate.load(license);
assertThat(gate.isEnabled(Feature.topology)).isTrue();
assertThat(gate.isEnabled(Feature.lineage)).isTrue();
assertThat(gate.isEnabled(Feature.debugger)).isFalse();
assertThat(gate.isEnabled(Feature.replay)).isFalse();
assertThat(gate.getTier()).isEqualTo("MID");
assertThat(gate.getLimit("max_agents", 0)).isEqualTo(10);
}
}

View File

@@ -0,0 +1,87 @@
package com.cameleer.server.core.license;
import org.junit.jupiter.api.Test;
import java.security.*;
import java.security.spec.NamedParameterSpec;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Base64;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LicenseValidatorTest {
private KeyPair generateKeyPair() throws Exception {
KeyPairGenerator kpg = KeyPairGenerator.getInstance("Ed25519");
return kpg.generateKeyPair();
}
private String sign(PrivateKey key, String payload) throws Exception {
Signature signer = Signature.getInstance("Ed25519");
signer.initSign(key);
signer.update(payload.getBytes());
return Base64.getEncoder().encodeToString(signer.sign());
}
@Test
void validate_validLicense_returnsLicenseInfo() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64);
Instant expires = Instant.now().plus(365, ChronoUnit.DAYS);
String payload = """
{"tier":"HIGH","features":["topology","lineage","debugger"],"limits":{"max_agents":50,"retention_days":90},"iat":%d,"exp":%d}
""".formatted(Instant.now().getEpochSecond(), expires.getEpochSecond()).trim();
String signature = sign(kp.getPrivate(), payload);
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
LicenseInfo info = validator.validate(token);
assertThat(info.tier()).isEqualTo("HIGH");
assertThat(info.hasFeature(Feature.debugger)).isTrue();
assertThat(info.hasFeature(Feature.replay)).isFalse();
assertThat(info.getLimit("max_agents", 0)).isEqualTo(50);
assertThat(info.isExpired()).isFalse();
}
@Test
void validate_expiredLicense_throwsException() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64);
Instant past = Instant.now().minus(1, ChronoUnit.DAYS);
String payload = """
{"tier":"LOW","features":["topology"],"limits":{},"iat":%d,"exp":%d}
""".formatted(past.minus(30, ChronoUnit.DAYS).getEpochSecond(), past.getEpochSecond()).trim();
String signature = sign(kp.getPrivate(), payload);
String token = Base64.getEncoder().encodeToString(payload.getBytes()) + "." + signature;
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("expired");
}
@Test
void validate_tamperedPayload_throwsException() throws Exception {
KeyPair kp = generateKeyPair();
String publicKeyBase64 = Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
LicenseValidator validator = new LicenseValidator(publicKeyBase64);
String payload = """
{"tier":"LOW","features":["topology"],"limits":{},"iat":0,"exp":9999999999}
""".trim();
String signature = sign(kp.getPrivate(), payload);
// Tamper with payload
String tampered = payload.replace("LOW", "BUSINESS");
String token = Base64.getEncoder().encodeToString(tampered.getBytes()) + "." + signature;
assertThatThrownBy(() -> validator.validate(token))
.isInstanceOf(SecurityException.class)
.hasMessageContaining("signature");
}
}

View File

@@ -0,0 +1,115 @@
package com.cameleer.server.core.rbac;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
class ClaimMappingServiceTest {
private ClaimMappingService service;
@BeforeEach
void setUp() {
service = new ClaimMappingService();
}
@Test
void evaluate_containsMatch_onStringArrayClaim() {
var rule = new ClaimMappingRule(
UUID.randomUUID(), "groups", "contains", "cameleer-admins",
"assignRole", "ADMIN", 0, null);
Map<String, Object> claims = Map.of("groups", List.of("eng", "cameleer-admins", "devops"));
var results = service.evaluate(List.of(rule), claims);
assertThat(results).hasSize(1);
assertThat(results.get(0).rule()).isEqualTo(rule);
}
@Test
void evaluate_equalsMatch_onStringClaim() {
var rule = new ClaimMappingRule(
UUID.randomUUID(), "department", "equals", "platform",
"assignRole", "OPERATOR", 0, null);
Map<String, Object> claims = Map.of("department", "platform");
var results = service.evaluate(List.of(rule), claims);
assertThat(results).hasSize(1);
}
@Test
void evaluate_regexMatch() {
var rule = new ClaimMappingRule(
UUID.randomUUID(), "email", "regex", ".*@example\\.com$",
"addToGroup", "Example Corp", 0, null);
Map<String, Object> claims = Map.of("email", "john@example.com");
var results = service.evaluate(List.of(rule), claims);
assertThat(results).hasSize(1);
}
@Test
void evaluate_noMatch_returnsEmpty() {
var rule = new ClaimMappingRule(
UUID.randomUUID(), "groups", "contains", "cameleer-admins",
"assignRole", "ADMIN", 0, null);
Map<String, Object> claims = Map.of("groups", List.of("eng", "devops"));
var results = service.evaluate(List.of(rule), claims);
assertThat(results).isEmpty();
}
@Test
void evaluate_missingClaim_returnsEmpty() {
var rule = new ClaimMappingRule(
UUID.randomUUID(), "groups", "contains", "admins",
"assignRole", "ADMIN", 0, null);
Map<String, Object> claims = Map.of("department", "eng");
var results = service.evaluate(List.of(rule), claims);
assertThat(results).isEmpty();
}
@Test
void evaluate_rulesOrderedByPriority() {
var lowPriority = new ClaimMappingRule(
UUID.randomUUID(), "role", "equals", "dev",
"assignRole", "VIEWER", 0, null);
var highPriority = new ClaimMappingRule(
UUID.randomUUID(), "role", "equals", "dev",
"assignRole", "OPERATOR", 10, null);
Map<String, Object> claims = Map.of("role", "dev");
var results = service.evaluate(List.of(highPriority, lowPriority), claims);
assertThat(results).hasSize(2);
assertThat(results.get(0).rule().priority()).isEqualTo(0);
assertThat(results.get(1).rule().priority()).isEqualTo(10);
}
@Test
void evaluate_containsMatch_onSpaceSeparatedString() {
var rule = new ClaimMappingRule(
UUID.randomUUID(), "scope", "contains", "server:admin",
"assignRole", "ADMIN", 0, null);
Map<String, Object> claims = Map.of("scope", "openid profile server:admin");
var results = service.evaluate(List.of(rule), claims);
assertThat(results).hasSize(1);
}
}

View File

@@ -0,0 +1,18 @@
spring:
flyway:
enabled: true
cameleer:
server:
indexer:
debouncems: 100
ingestion:
buffercapacity: 100
batchsize: 10
flushintervalms: 100
agentregistry:
pingintervalms: 1000
security:
bootstraptoken: test-bootstrap-token
bootstraptokenprevious: old-bootstrap-token
infrastructureendpoints: true