test(04-02): adapt all ITs for JWT auth and add 4 security integration tests

- Replace TestSecurityConfig permit-all with real SecurityConfig active in tests
- Create TestSecurityHelper for JWT-authenticated test requests
- Update 15 existing ITs to use JWT Bearer auth and bootstrap token headers
- Add SecurityFilterIT: protected/public endpoint access control (6 tests)
- Add BootstrapTokenIT: registration requires valid bootstrap token (4 tests)
- Add RegistrationSecurityIT: registration returns tokens + public key (3 tests)
- Add JwtRefreshIT: refresh flow with valid/invalid/mismatched tokens (5 tests)
- Add /error to SecurityConfig permitAll for proper error page forwarding
- Exclude register and refresh paths from ProtocolVersionInterceptor
- All 91 tests pass (18 new security + 73 existing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-11 20:38:28 +01:00
parent 45f0241079
commit 539b85f307
22 changed files with 783 additions and 243 deletions

View File

@@ -29,7 +29,9 @@ public class WebConfig implements WebMvcConfigurer {
"/api/v1/api-docs/**",
"/api/v1/swagger-ui/**",
"/api/v1/swagger-ui.html",
"/api/v1/agents/*/events"
"/api/v1/agents/*/events",
"/api/v1/agents/register",
"/api/v1/agents/*/refresh"
);
}
}

View File

@@ -39,7 +39,8 @@ public class SecurityConfig {
"/api/v1/swagger-ui/**",
"/swagger-ui/**",
"/v3/api-docs/**",
"/swagger-ui.html"
"/swagger-ui.html",
"/error"
).permitAll()
.anyRequest().authenticated()
)

View File

@@ -0,0 +1,68 @@
package com.cameleer3.server.app;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.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 for it.
*/
public String registerTestAgent(String agentId) {
agentRegistryService.register(agentId, "test", "test-group", "1.0", List.of(), Map.of());
return jwtService.createAccessToken(agentId, "test-group");
}
/**
* 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 bootstrap token authorization, protocol version, and JSON content type.
*/
public HttpHeaders bootstrapHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer test-bootstrap-token");
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.APPLICATION_JSON);
return headers;
}
}

View File

@@ -1,8 +1,10 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.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;
@@ -10,7 +12,6 @@ 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 java.util.UUID;
@@ -25,17 +26,14 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
@Autowired
private ObjectMapper objectMapper;
private HttpHeaders protocolHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
return headers;
}
@Autowired
private TestSecurityHelper securityHelper;
private HttpHeaders protocolHeadersNoBody() {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Cameleer-Protocol-Version", "1");
return headers;
private String jwt;
@BeforeEach
void setUp() {
jwt = securityHelper.registerTestAgent("test-agent-command-it");
}
private ResponseEntity<String> registerAgent(String agentId, String name, String group) {
@@ -52,7 +50,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
return restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, protocolHeaders()),
new HttpEntity<>(json, securityHelper.bootstrapHeaders()),
String.class);
}
@@ -67,7 +65,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands",
new HttpEntity<>(commandJson, protocolHeaders()),
new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -90,7 +88,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/groups/" + group + "/commands",
new HttpEntity<>(commandJson, protocolHeaders()),
new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -103,7 +101,6 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
@Test
void broadcastCommand_returns202WithLiveAgentCount() throws Exception {
// Register at least one agent (others may exist from other tests)
String agentId = "cmd-it-broadcast-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Broadcast Agent", "broadcast-group");
@@ -113,7 +110,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/commands",
new HttpEntity<>(commandJson, protocolHeaders()),
new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -128,24 +125,22 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
String agentId = "cmd-it-ack-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Ack Agent", "test-group");
// Send a command first
String commandJson = """
{"type": "config-update", "payload": {"key": "ack-test"}}
""";
ResponseEntity<String> cmdResponse = restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands",
new HttpEntity<>(commandJson, protocolHeaders()),
new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class);
JsonNode cmdBody = objectMapper.readTree(cmdResponse.getBody());
String commandId = cmdBody.get("commandId").asText();
// Acknowledge the command
ResponseEntity<Void> ackResponse = restTemplate.exchange(
"/api/v1/agents/" + agentId + "/commands/" + commandId + "/ack",
HttpMethod.POST,
new HttpEntity<>(protocolHeadersNoBody()),
new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
Void.class);
assertThat(ackResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
@@ -159,7 +154,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity<Void> response = restTemplate.exchange(
"/api/v1/agents/" + agentId + "/commands/nonexistent-cmd-id/ack",
HttpMethod.POST,
new HttpEntity<>(protocolHeadersNoBody()),
new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
@@ -173,7 +168,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/nonexistent-agent-xyz/commands",
new HttpEntity<>(commandJson, protocolHeaders()),
new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);

View File

@@ -1,8 +1,10 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.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;
@@ -10,7 +12,6 @@ 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;
@@ -23,17 +24,14 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
@Autowired
private ObjectMapper objectMapper;
private HttpHeaders protocolHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
return headers;
}
@Autowired
private TestSecurityHelper securityHelper;
private HttpHeaders protocolHeadersNoBody() {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Cameleer-Protocol-Version", "1");
return headers;
private String jwt;
@BeforeEach
void setUp() {
jwt = securityHelper.registerTestAgent("test-agent-registration-it");
}
private ResponseEntity<String> registerAgent(String agentId, String name) {
@@ -50,7 +48,7 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
return restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, protocolHeaders()),
new HttpEntity<>(json, securityHelper.bootstrapHeaders()),
String.class);
}
@@ -65,6 +63,11 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
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
@@ -86,7 +89,7 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
ResponseEntity<Void> response = restTemplate.exchange(
"/api/v1/agents/agent-it-hb/heartbeat",
HttpMethod.POST,
new HttpEntity<>(protocolHeadersNoBody()),
new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@@ -97,7 +100,7 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
ResponseEntity<Void> response = restTemplate.exchange(
"/api/v1/agents/unknown-agent-xyz/heartbeat",
HttpMethod.POST,
new HttpEntity<>(protocolHeadersNoBody()),
new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
@@ -111,14 +114,13 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents",
HttpMethod.GET,
new HttpEntity<>(protocolHeadersNoBody()),
new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
// At minimum, our two agents should be present (may have more from other tests)
assertThat(body.size()).isGreaterThanOrEqualTo(2);
}
@@ -129,14 +131,13 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents?status=LIVE",
HttpMethod.GET,
new HttpEntity<>(protocolHeadersNoBody()),
new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
// All returned agents should be LIVE
for (JsonNode agent : body) {
assertThat(agent.get("state").asText()).isEqualTo("LIVE");
}
@@ -147,7 +148,7 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents?status=INVALID",
HttpMethod.GET,
new HttpEntity<>(protocolHeadersNoBody()),
new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);

View File

@@ -1,14 +1,15 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.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.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.io.BufferedReader;
@@ -37,14 +38,17 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@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 String jwt;
@BeforeEach
void setUp() {
jwt = securityHelper.registerTestAgent("test-agent-sse-it");
}
private ResponseEntity<String> registerAgent(String agentId, String name, String group) {
@@ -61,7 +65,7 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
return restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, protocolHeaders()),
new HttpEntity<>(json, securityHelper.bootstrapHeaders()),
String.class);
}
@@ -72,13 +76,12 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
return restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands",
new HttpEntity<>(json, protocolHeaders()),
new HttpEntity<>(json, securityHelper.authHeaders(jwt)),
String.class);
}
/**
* Opens an SSE stream via java.net.http.HttpClient and collects lines in a list.
* Uses async API to avoid blocking the test thread.
* Opens an SSE stream via java.net.http.HttpClient with JWT query param auth.
*/
private SseStream openSseStream(String agentId) {
return openSseStream(agentId, null);
@@ -93,8 +96,9 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
.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"))
.uri(URI.create("http://localhost:" + port + "/api/v1/agents/" + agentId + "/events?token=" + jwt))
.header("Accept", "text/event-stream")
.GET();
@@ -143,7 +147,6 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
SseStream stream = openSseStream(agentId);
// Wait for the connection to be established
assertThat(stream.awaitConnection(5000)).isTrue();
assertThat(stream.statusCode().get()).isEqualTo(200);
}
@@ -155,7 +158,7 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/api/v1/agents/unknown-sse-agent/events"))
.uri(URI.create("http://localhost:" + port + "/api/v1/agents/unknown-sse-agent/events?token=" + jwt))
.header("Accept", "text/event-stream")
.GET()
.build();
@@ -175,7 +178,6 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
SseStream stream = openSseStream(agentId);
stream.awaitConnection(5000);
// Give the SSE stream a moment to fully establish
await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
.ignoreExceptions()
.until(() -> {
@@ -240,7 +242,6 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
SseStream stream = openSseStream(agentId);
stream.awaitConnection(5000);
// Wait for a ping comment (sent every 1 second in test config)
await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
.ignoreExceptions()
.until(() -> {
@@ -259,7 +260,6 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
SseStream stream = openSseStream(agentId, "some-previous-event-id");
// Just verify the connection succeeds (no replay expected)
assertThat(stream.awaitConnection(5000)).isTrue();
assertThat(stream.statusCode().get()).isEqualTo(200);
}

View File

@@ -1,14 +1,15 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.server.app.TestSecurityHelper;
import com.cameleer3.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.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource;
@@ -34,14 +35,20 @@ class BackpressureIT extends AbstractClickHouseIT {
@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 whenBufferFull_returns503WithRetryAfter() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
// Wait for any initial scheduled flush to complete, then fill buffer via batch POST
// First, wait until the buffer is empty (initial flush may have run)
await().atMost(5, SECONDS).until(() -> ingestionService.getExecutionBufferDepth() == 0);
// Fill the buffer completely with a batch of 5
@@ -57,7 +64,7 @@ class BackpressureIT extends AbstractClickHouseIT {
ResponseEntity<String> batchResponse = restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>(batchJson, headers),
new HttpEntity<>(batchJson, authHeaders),
String.class);
assertThat(batchResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -68,7 +75,7 @@ class BackpressureIT extends AbstractClickHouseIT {
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>(overflowJson, headers),
new HttpEntity<>(overflowJson, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
@@ -77,10 +84,6 @@ class BackpressureIT extends AbstractClickHouseIT {
@Test
void bufferedDataNotLost_afterBackpressure() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
// Post data to the diagram buffer (separate from executions used above)
for (int i = 0; i < 3; i++) {
String json = String.format("""
@@ -94,12 +97,11 @@ class BackpressureIT extends AbstractClickHouseIT {
restTemplate.postForEntity(
"/api/v1/data/diagrams",
new HttpEntity<>(json, headers),
new HttpEntity<>(json, authHeaders),
String.class);
}
// Data is in the buffer. Wait for the scheduled flush (60s in this test).
// Instead, verify the buffer has data.
// Data is in the buffer. Verify the buffer has data.
assertThat(ingestionService.getDiagramBufferDepth()).isGreaterThanOrEqualTo(3);
}
}

View File

@@ -1,6 +1,7 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeAll;
@@ -12,7 +13,6 @@ 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 java.util.concurrent.TimeUnit.SECONDS;
@@ -28,8 +28,12 @@ class DetailControllerIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private final ObjectMapper objectMapper = new ObjectMapper();
private String jwt;
private String seededExecutionId;
/**
@@ -38,6 +42,8 @@ class DetailControllerIT extends AbstractClickHouseIT {
*/
@BeforeAll
void seedTestData() {
jwt = securityHelper.registerTestAgent("test-agent-detail-it");
String json = """
{
"routeId": "detail-test-route",
@@ -167,7 +173,6 @@ class DetailControllerIT extends AbstractClickHouseIT {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
// diagramContentHash should be present (may be empty string)
assertThat(body.has("diagramContentHash")).isTrue();
}
@@ -179,7 +184,6 @@ class DetailControllerIT extends AbstractClickHouseIT {
@Test
void getProcessorSnapshot_returnsExchangeData() throws Exception {
// Processor index 0 is root-proc
ResponseEntity<String> response = detailGet(
"/" + seededExecutionId + "/processors/0/snapshot");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@@ -208,16 +212,12 @@ class DetailControllerIT extends AbstractClickHouseIT {
// --- Helper methods ---
private void ingest(String json) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
restTemplate.postForEntity("/api/v1/data/executions",
new HttpEntity<>(json, headers), String.class);
new HttpEntity<>(json, securityHelper.authHeaders(jwt)), String.class);
}
private ResponseEntity<String> detailGet(String path) {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Cameleer-Protocol-Version", "1");
HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
return restTemplate.exchange(
"/api/v1/executions" + path,
HttpMethod.GET,

View File

@@ -1,13 +1,14 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.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.MediaType;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
@@ -19,6 +20,17 @@ class DiagramControllerIT extends AbstractClickHouseIT {
@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 = """
@@ -32,13 +44,9 @@ class DiagramControllerIT extends AbstractClickHouseIT {
}
""";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/diagrams",
new HttpEntity<>(json, headers),
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -57,13 +65,9 @@ class DiagramControllerIT extends AbstractClickHouseIT {
}
""";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
restTemplate.postForEntity(
"/api/v1/data/diagrams",
new HttpEntity<>(json, headers),
new HttpEntity<>(json, authHeaders),
String.class);
await().atMost(10, SECONDS).untilAsserted(() -> {
@@ -91,13 +95,9 @@ class DiagramControllerIT extends AbstractClickHouseIT {
}]
""";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/diagrams",
new HttpEntity<>(json, headers),
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);

View File

@@ -1,6 +1,7 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.server.app.TestSecurityHelper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -9,7 +10,6 @@ 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 java.util.concurrent.TimeUnit.SECONDS;
@@ -25,6 +25,10 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private String jwt;
private String contentHash;
/**
@@ -32,6 +36,8 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
*/
@BeforeEach
void seedDiagram() {
jwt = securityHelper.registerTestAgent("test-agent-diagram-render-it");
String json = """
{
"routeId": "render-test-route",
@@ -50,13 +56,9 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
}
""";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
restTemplate.postForEntity(
"/api/v1/data/diagrams",
new HttpEntity<>(json, headers),
new HttpEntity<>(json, securityHelper.authHeaders(jwt)),
String.class);
// Wait for flush to ClickHouse and retrieve the content hash
@@ -71,9 +73,8 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
@Test
void getSvg_withAcceptHeader_returnsSvg() {
HttpHeaders headers = new HttpHeaders();
HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
headers.set("Accept", "image/svg+xml");
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
@@ -89,9 +90,8 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
@Test
void getJson_withAcceptHeader_returnsJson() {
HttpHeaders headers = new HttpHeaders();
HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
headers.set("Accept", "application/json");
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
@@ -107,9 +107,8 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
@Test
void getNonExistentHash_returns404() {
HttpHeaders headers = new HttpHeaders();
HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
headers.set("Accept", "image/svg+xml");
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
@@ -123,8 +122,7 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
@Test
void getWithNoAcceptHeader_defaultsToSvg() {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Cameleer-Protocol-Version", "1");
HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",

View File

@@ -1,13 +1,14 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.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.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -20,6 +21,17 @@ class ExecutionControllerIT extends AbstractClickHouseIT {
@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 = """
@@ -37,13 +49,9 @@ class ExecutionControllerIT extends AbstractClickHouseIT {
}
""";
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<>(json, headers),
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -73,13 +81,9 @@ class ExecutionControllerIT extends AbstractClickHouseIT {
}]
""";
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<>(json, headers),
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -100,13 +104,9 @@ class ExecutionControllerIT extends AbstractClickHouseIT {
}
""";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>(json, headers),
new HttpEntity<>(json, authHeaders),
String.class);
await().atMost(10, SECONDS).untilAsserted(() -> {
@@ -132,13 +132,9 @@ class ExecutionControllerIT extends AbstractClickHouseIT {
}
""";
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<>(json, headers),
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);

View File

@@ -1,13 +1,14 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.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;
@@ -20,12 +21,18 @@ class ForwardCompatIT extends AbstractClickHouseIT {
@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() {
// JSON body with an unknown field that should not cause a 400 deserialization error.
// Jackson is configured with fail-on-unknown-properties: false in application.yml.
// Without the ExecutionController (Plan 01-02), this returns 404 -- which is acceptable.
// The key assertion: it must NOT be 400 (i.e., Jackson did not reject unknown fields).
String jsonWithUnknownFields = """
{
"futureField": "value",
@@ -33,16 +40,12 @@ class ForwardCompatIT extends AbstractClickHouseIT {
}
""";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
HttpHeaders headers = securityHelper.authHeaders(jwt);
var entity = new HttpEntity<>(jsonWithUnknownFields, headers);
var response = restTemplate.exchange(
"/api/v1/data/executions", HttpMethod.POST, entity, String.class);
// The interceptor passes (correct protocol header), and Jackson should not reject
// unknown fields. Without a controller, expect 404 (not 400 or 422).
assertThat(response.getStatusCode().value())
.as("Unknown JSON fields must not cause 400 or 422 deserialization error")
.isNotIn(400, 422);

View File

@@ -1,13 +1,14 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.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.MediaType;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
@@ -19,6 +20,17 @@ class MetricsControllerIT extends AbstractClickHouseIT {
@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 = """
@@ -31,13 +43,9 @@ class MetricsControllerIT extends AbstractClickHouseIT {
}]
""";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/metrics",
new HttpEntity<>(json, headers),
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -55,13 +63,9 @@ class MetricsControllerIT extends AbstractClickHouseIT {
}]
""";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
restTemplate.postForEntity(
"/api/v1/data/metrics",
new HttpEntity<>(json, headers),
new HttpEntity<>(json, authHeaders),
String.class);
await().atMost(10, SECONDS).untilAsserted(() -> {

View File

@@ -1,6 +1,7 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeAll;
@@ -12,7 +13,6 @@ 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 java.util.concurrent.TimeUnit.SECONDS;
@@ -29,14 +29,21 @@ class SearchControllerIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private final ObjectMapper objectMapper = new ObjectMapper();
private String jwt;
/**
* Seed test data: Insert executions with varying statuses, times, durations,
* correlationIds, error messages, and exchange snapshot data.
*/
@BeforeAll
void seedTestData() {
jwt = securityHelper.registerTestAgent("test-agent-search-it");
// Execution 1: COMPLETED, short duration, no errors
ingest("""
{
@@ -160,20 +167,16 @@ class SearchControllerIT extends AbstractClickHouseIT {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
// At least 2 FAILED from our seed data (other test classes may add more)
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();
// All returned results must be FAILED
body.get("data").forEach(item ->
assertThat(item.get("status").asText()).isEqualTo("FAILED"));
}
@Test
void searchByTimeRange_returnsOnlyExecutionsInRange() throws Exception {
// Use correlationId + time range to precisely verify time filtering
// corr-alpha is at 10:00, within [09:00, 13:00]
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);
@@ -182,7 +185,6 @@ class SearchControllerIT extends AbstractClickHouseIT {
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-alpha");
// corr-gamma is at 2026-03-11T08:00, outside [09:00, 13:00 on 03-10]
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());
@@ -191,13 +193,10 @@ class SearchControllerIT extends AbstractClickHouseIT {
@Test
void searchByDuration_returnsOnlyMatchingExecutions() throws Exception {
// Use correlationId to verify duration filter precisely
// corr-beta has 200ms, corr-delta has 300ms -- both in [100, 500]
ResponseEntity<String> response = searchGet("?correlationId=corr-beta");
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
// Verify duration filter excludes corr-alpha (50ms) when min=100
ResponseEntity<String> response2 = searchPost("""
{
"durationMin": 100,
@@ -208,7 +207,6 @@ class SearchControllerIT extends AbstractClickHouseIT {
JsonNode body2 = objectMapper.readTree(response2.getBody());
assertThat(body2.get("total").asLong()).isZero();
// Verify duration filter includes corr-delta (300ms) when in [100, 500]
ResponseEntity<String> response3 = searchPost("""
{
"durationMin": 100,
@@ -268,7 +266,6 @@ class SearchControllerIT extends AbstractClickHouseIT {
@Test
void fullTextSearchInHeaders_findsMatchInExchangeHeaders() throws Exception {
// Content-Type appears in exec 1 and exec 4 headers
ResponseEntity<String> response = searchPost("""
{ "textInHeaders": "Content-Type" }
""");
@@ -292,7 +289,6 @@ class SearchControllerIT extends AbstractClickHouseIT {
@Test
void combinedFilters_statusAndText() throws Exception {
// Only FAILED + NullPointer = exec 2
ResponseEntity<String> response = searchPost("""
{
"status": "FAILED",
@@ -327,14 +323,11 @@ class SearchControllerIT extends AbstractClickHouseIT {
@Test
void pagination_worksCorrectly() throws Exception {
// First, get total count of COMPLETED executions (7 from our seed data:
// exec 1 + execs 5-10; execs 2,4 are FAILED, exec 3 is RUNNING)
ResponseEntity<String> countResponse = searchGet("?status=COMPLETED&limit=1");
JsonNode countBody = objectMapper.readTree(countResponse.getBody());
long totalCompleted = countBody.get("total").asLong();
assertThat(totalCompleted).isGreaterThanOrEqualTo(7);
// Now test pagination with offset=2, limit=3
ResponseEntity<String> response = searchPost("""
{
"status": "COMPLETED",
@@ -366,16 +359,12 @@ class SearchControllerIT extends AbstractClickHouseIT {
// --- Helper methods ---
private void ingest(String json) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
restTemplate.postForEntity("/api/v1/data/executions",
new HttpEntity<>(json, headers), String.class);
new HttpEntity<>(json, securityHelper.authHeaders(jwt)), String.class);
}
private ResponseEntity<String> searchGet(String queryString) {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Cameleer-Protocol-Version", "1");
HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
return restTemplate.exchange(
"/api/v1/search/executions" + queryString,
HttpMethod.GET,
@@ -384,13 +373,10 @@ class SearchControllerIT extends AbstractClickHouseIT {
}
private ResponseEntity<String> searchPost(String jsonBody) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
return restTemplate.exchange(
"/api/v1/search/executions",
HttpMethod.POST,
new HttpEntity<>(jsonBody, headers),
new HttpEntity<>(jsonBody, securityHelper.authHeaders(jwt)),
String.class);
}
}

View File

@@ -1,6 +1,8 @@
package com.cameleer3.server.app.interceptor;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.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;
@@ -13,16 +15,29 @@ 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 AbstractClickHouseIT {
@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(
@@ -35,6 +50,7 @@ class ProtocolVersionIT extends AbstractClickHouseIT {
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);
@@ -47,13 +63,12 @@ class ProtocolVersionIT extends AbstractClickHouseIT {
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);
// The interceptor should NOT reject this request (not 400 from interceptor).
// Without the controller (Plan 01-02), this will be 404 -- which is fine.
assertThat(response.getStatusCode().value()).isNotEqualTo(400);
}

View File

@@ -0,0 +1,114 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.app.AbstractClickHouseIT;
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 AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
private static final String REGISTRATION_JSON = """
{
"agentId": "bootstrap-test-agent",
"name": "Bootstrap Test",
"group": "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 = """
{
"agentId": "bootstrap-test-previous",
"name": "Previous Token Test",
"group": "test-group",
"version": "1.0.0",
"routeIds": [],
"capabilities": {}
}
""";
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, headers),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}

View File

@@ -0,0 +1,169 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.server.app.TestSecurityHelper;
import com.cameleer3.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 AbstractClickHouseIT {
@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 = """
{
"agentId": "%s",
"name": "Refresh Test Agent",
"group": "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();
}
@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
HttpHeaders authHeaders = new HttpHeaders();
authHeaders.set("Authorization", "Bearer " + newAccessToken);
authHeaders.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents",
HttpMethod.GET,
new HttpEntity<>(authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}

View File

@@ -0,0 +1,97 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.app.AbstractClickHouseIT;
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 AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
private ResponseEntity<String> registerAgent(String agentId) {
String json = """
{
"agentId": "%s",
"name": "Security Test Agent",
"group": "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
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
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()).isEqualTo(HttpStatus.OK);
}
}

View File

@@ -0,0 +1,114 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.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 AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private String jwt;
@BeforeEach
void setUp() {
jwt = securityHelper.registerTestAgent("test-agent-security-filter-it");
}
@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(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void healthEndpoint_withoutJwt_returns200() {
ResponseEntity<String> response = restTemplate.getForEntity(
"/api/v1/health", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void dataEndpoint_withoutJwt_returns401or403() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>("{}", headers),
String.class);
assertThat(response.getStatusCode().value()).isIn(401, 403);
}
@Test
void protectedEndpoint_withExpiredJwt_returns401or403() {
// An invalid/malformed token should be rejected
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer expired.invalid.token");
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
assertThat(response.getStatusCode().value()).isIn(401, 403);
}
@Test
void protectedEndpoint_withMalformedJwt_returns401or403() {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer not-a-jwt");
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
assertThat(response.getStatusCode().value()).isIn(401, 403);
}
}

View File

@@ -1,28 +1,12 @@
package com.cameleer3.server.app.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
/**
* Temporary test security configuration that permits all requests.
* 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>
* Adding {@code spring-boot-starter-security} enables security by default (all endpoints
* return 401). This configuration overrides that behavior in tests until the real
* security filter chain is configured in Plan 02.
* <p>
* Uses {@code @Order(-1)} to take precedence over any auto-configured security filter chain.
* 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.
*/
@Configuration
public class TestSecurityConfig {
@Bean
public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
// Intentionally empty -- real SecurityConfig is now active in tests
}

View File

@@ -1,13 +1,14 @@
package com.cameleer3.server.app.storage;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.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.MediaType;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
@@ -23,9 +24,19 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
@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() {
// 1. Ingest a RouteGraph for route "diagram-link-route" via the diagrams endpoint
String graphJson = """
{
"routeId": "diagram-link-route",
@@ -42,17 +53,12 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
}
""";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> diagramResponse = restTemplate.postForEntity(
"/api/v1/data/diagrams",
new HttpEntity<>(graphJson, headers),
new HttpEntity<>(graphJson, authHeaders),
String.class);
assertThat(diagramResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
// 2. Wait for diagram to be flushed to ClickHouse before ingesting execution
await().atMost(10, SECONDS).untilAsserted(() -> {
String hash = jdbcTemplate.queryForObject(
"SELECT content_hash FROM route_diagrams WHERE route_id = 'diagram-link-route' LIMIT 1",
@@ -60,7 +66,6 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
assertThat(hash).isNotNull().isNotEmpty();
});
// 3. Ingest a RouteExecution for the same routeId
String executionJson = """
{
"routeId": "diagram-link-route",
@@ -86,11 +91,10 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
ResponseEntity<String> execResponse = restTemplate.postForEntity(
"/api/v1/data/executions",
new HttpEntity<>(executionJson, headers),
new HttpEntity<>(executionJson, authHeaders),
String.class);
assertThat(execResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
// 4. Verify diagram_content_hash is a non-empty SHA-256 hash (64 hex chars)
await().atMost(10, SECONDS).ignoreExceptions().untilAsserted(() -> {
String hash = jdbcTemplate.queryForObject(
"SELECT diagram_content_hash FROM route_executions WHERE route_id = 'diagram-link-route'",
@@ -98,14 +102,13 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
assertThat(hash)
.isNotNull()
.isNotEmpty()
.hasSize(64) // SHA-256 hex = 64 characters
.hasSize(64)
.matches("[a-f0-9]{64}");
});
}
@Test
void diagramHashEmpty_whenNoRouteGraphExists() {
// Ingest a RouteExecution for a route with NO prior diagram
String executionJson = """
{
"routeId": "no-diagram-route",
@@ -129,17 +132,12 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
}
""";
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<>(executionJson, headers),
new HttpEntity<>(executionJson, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
// Verify diagram_content_hash is empty string (graceful fallback)
await().atMost(10, SECONDS).ignoreExceptions().untilAsserted(() -> {
String hash = jdbcTemplate.queryForObject(
"SELECT diagram_content_hash FROM route_executions WHERE route_id = 'no-diagram-route'",

View File

@@ -1,18 +1,18 @@
package com.cameleer3.server.app.storage;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.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.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
@@ -27,9 +27,19 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
@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_depthsAndParentIndexesCorrect() {
// Build a 3-level processor tree: root -> child -> grandchild
String json = """
{
"routeId": "schema-test-tree",
@@ -85,7 +95,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
postExecution(json);
await().atMost(30, SECONDS).ignoreExceptions().untilAsserted(() -> {
// Use individual typed queries to avoid ClickHouse Array cast issues
var depths = queryArray(
"SELECT processor_depths FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(depths).containsExactly("0", "1", "2");
@@ -98,7 +107,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
"SELECT processor_diagram_node_ids FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(diagramNodeIds).containsExactly("node-root", "node-child", "node-grandchild");
// Verify exchange_bodies contains concatenated text
String bodies = jdbcTemplate.queryForObject(
"SELECT exchange_bodies FROM route_executions WHERE route_id = 'schema-test-tree'",
String.class);
@@ -107,7 +115,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
assertThat(bodies).contains("child-input");
assertThat(bodies).contains("child-output");
// Verify per-processor input/output bodies
var inputBodies = queryArray(
"SELECT processor_input_bodies FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(inputBodies).containsExactly("root-input", "child-input", "");
@@ -116,7 +123,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
"SELECT processor_output_bodies FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(outputBodies).containsExactly("root-output", "child-output", "");
// Verify per-processor input headers stored as JSON strings
var inputHeaders = queryArray(
"SELECT processor_input_headers FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(inputHeaders.get(0)).contains("Content-Type");
@@ -161,7 +167,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
postExecution(json);
await().atMost(30, SECONDS).ignoreExceptions().untilAsserted(() -> {
// Bodies should contain all sources
String bodies = jdbcTemplate.queryForObject(
"SELECT exchange_bodies FROM route_executions WHERE route_id = 'schema-test-bodies'",
String.class);
@@ -170,7 +175,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
assertThat(bodies).contains("route-level-input-body");
assertThat(bodies).contains("route-level-output-body");
// Headers should contain route-level header
String headers = jdbcTemplate.queryForObject(
"SELECT exchange_headers FROM route_executions WHERE route_id = 'schema-test-bodies'",
String.class);
@@ -181,7 +185,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
@Test
void nullSnapshots_insertSucceedsWithEmptyDefaults() {
// Execution with no exchange snapshots and no processor snapshot data
String json = """
{
"routeId": "schema-test-null-snap",
@@ -207,13 +210,11 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
postExecution(json);
await().atMost(30, SECONDS).ignoreExceptions().untilAsserted(() -> {
// Empty but not null
String bodies = jdbcTemplate.queryForObject(
"SELECT exchange_bodies FROM route_executions WHERE route_id = 'schema-test-null-snap'",
String.class);
assertThat(bodies).isNotNull();
// Depths and parent indexes still populated for tree metadata
var depths = queryArray(
"SELECT processor_depths FROM route_executions WHERE route_id = 'schema-test-null-snap'");
assertThat(depths).containsExactly("0");
@@ -225,22 +226,14 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
}
private void postExecution(String json) {
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<>(json, headers),
new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
}
/**
* Query an array column from ClickHouse and return it as a List of strings.
* Handles the ClickHouse JDBC Array type by converting via toString on elements.
*/
private List<String> queryArray(String sql) {
return jdbcTemplate.query(sql, (rs, rowNum) -> {
Object arr = rs.getArray(1).getArray();