chore: rename cameleer3 to cameleer
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
18
cameleer-server-app/src/test/resources/application-test.yml
Normal file
18
cameleer-server-app/src/test/resources/application-test.yml
Normal 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
|
||||
Reference in New Issue
Block a user