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/api-docs/**",
"/api/v1/swagger-ui/**", "/api/v1/swagger-ui/**",
"/api/v1/swagger-ui.html", "/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/**", "/api/v1/swagger-ui/**",
"/swagger-ui/**", "/swagger-ui/**",
"/v3/api-docs/**", "/v3/api-docs/**",
"/swagger-ui.html" "/swagger-ui.html",
"/error"
).permitAll() ).permitAll()
.anyRequest().authenticated() .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; package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT; import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate; 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.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.UUID; import java.util.UUID;
@@ -25,17 +26,14 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
@Autowired @Autowired
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
private HttpHeaders protocolHeaders() { @Autowired
HttpHeaders headers = new HttpHeaders(); private TestSecurityHelper securityHelper;
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
return headers;
}
private HttpHeaders protocolHeadersNoBody() { private String jwt;
HttpHeaders headers = new HttpHeaders();
headers.set("X-Cameleer-Protocol-Version", "1"); @BeforeEach
return headers; void setUp() {
jwt = securityHelper.registerTestAgent("test-agent-command-it");
} }
private ResponseEntity<String> registerAgent(String agentId, String name, String group) { private ResponseEntity<String> registerAgent(String agentId, String name, String group) {
@@ -52,7 +50,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
return restTemplate.postForEntity( return restTemplate.postForEntity(
"/api/v1/agents/register", "/api/v1/agents/register",
new HttpEntity<>(json, protocolHeaders()), new HttpEntity<>(json, securityHelper.bootstrapHeaders()),
String.class); String.class);
} }
@@ -67,7 +65,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity<String> response = restTemplate.postForEntity( ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands", "/api/v1/agents/" + agentId + "/commands",
new HttpEntity<>(commandJson, protocolHeaders()), new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class); String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -90,7 +88,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity<String> response = restTemplate.postForEntity( ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/groups/" + group + "/commands", "/api/v1/agents/groups/" + group + "/commands",
new HttpEntity<>(commandJson, protocolHeaders()), new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class); String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -103,7 +101,6 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
@Test @Test
void broadcastCommand_returns202WithLiveAgentCount() throws Exception { 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); String agentId = "cmd-it-broadcast-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Broadcast Agent", "broadcast-group"); registerAgent(agentId, "Broadcast Agent", "broadcast-group");
@@ -113,7 +110,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity<String> response = restTemplate.postForEntity( ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/commands", "/api/v1/agents/commands",
new HttpEntity<>(commandJson, protocolHeaders()), new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class); String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); 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); String agentId = "cmd-it-ack-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Ack Agent", "test-group"); registerAgent(agentId, "Ack Agent", "test-group");
// Send a command first
String commandJson = """ String commandJson = """
{"type": "config-update", "payload": {"key": "ack-test"}} {"type": "config-update", "payload": {"key": "ack-test"}}
"""; """;
ResponseEntity<String> cmdResponse = restTemplate.postForEntity( ResponseEntity<String> cmdResponse = restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands", "/api/v1/agents/" + agentId + "/commands",
new HttpEntity<>(commandJson, protocolHeaders()), new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class); String.class);
JsonNode cmdBody = objectMapper.readTree(cmdResponse.getBody()); JsonNode cmdBody = objectMapper.readTree(cmdResponse.getBody());
String commandId = cmdBody.get("commandId").asText(); String commandId = cmdBody.get("commandId").asText();
// Acknowledge the command
ResponseEntity<Void> ackResponse = restTemplate.exchange( ResponseEntity<Void> ackResponse = restTemplate.exchange(
"/api/v1/agents/" + agentId + "/commands/" + commandId + "/ack", "/api/v1/agents/" + agentId + "/commands/" + commandId + "/ack",
HttpMethod.POST, HttpMethod.POST,
new HttpEntity<>(protocolHeadersNoBody()), new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
Void.class); Void.class);
assertThat(ackResponse.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(ackResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
@@ -159,7 +154,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity<Void> response = restTemplate.exchange( ResponseEntity<Void> response = restTemplate.exchange(
"/api/v1/agents/" + agentId + "/commands/nonexistent-cmd-id/ack", "/api/v1/agents/" + agentId + "/commands/nonexistent-cmd-id/ack",
HttpMethod.POST, HttpMethod.POST,
new HttpEntity<>(protocolHeadersNoBody()), new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
Void.class); Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
@@ -173,7 +168,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity<String> response = restTemplate.postForEntity( ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/agents/nonexistent-agent-xyz/commands", "/api/v1/agents/nonexistent-agent-xyz/commands",
new HttpEntity<>(commandJson, protocolHeaders()), new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class); String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);

View File

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

View File

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

View File

@@ -1,14 +1,15 @@
package com.cameleer3.server.app.controller; package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT; import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.server.app.TestSecurityHelper;
import com.cameleer3.server.core.ingestion.IngestionService; import com.cameleer3.server.core.ingestion.IngestionService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
@@ -34,14 +35,20 @@ class BackpressureIT extends AbstractClickHouseIT {
@Autowired @Autowired
private IngestionService ingestionService; 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 @Test
void whenBufferFull_returns503WithRetryAfter() { 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 // 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); await().atMost(5, SECONDS).until(() -> ingestionService.getExecutionBufferDepth() == 0);
// Fill the buffer completely with a batch of 5 // Fill the buffer completely with a batch of 5
@@ -57,7 +64,7 @@ class BackpressureIT extends AbstractClickHouseIT {
ResponseEntity<String> batchResponse = restTemplate.postForEntity( ResponseEntity<String> batchResponse = restTemplate.postForEntity(
"/api/v1/data/executions", "/api/v1/data/executions",
new HttpEntity<>(batchJson, headers), new HttpEntity<>(batchJson, authHeaders),
String.class); String.class);
assertThat(batchResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(batchResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -68,7 +75,7 @@ class BackpressureIT extends AbstractClickHouseIT {
ResponseEntity<String> response = restTemplate.postForEntity( ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions", "/api/v1/data/executions",
new HttpEntity<>(overflowJson, headers), new HttpEntity<>(overflowJson, authHeaders),
String.class); String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
@@ -77,10 +84,6 @@ class BackpressureIT extends AbstractClickHouseIT {
@Test @Test
void bufferedDataNotLost_afterBackpressure() { 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) // Post data to the diagram buffer (separate from executions used above)
for (int i = 0; i < 3; i++) { for (int i = 0; i < 3; i++) {
String json = String.format(""" String json = String.format("""
@@ -94,12 +97,11 @@ class BackpressureIT extends AbstractClickHouseIT {
restTemplate.postForEntity( restTemplate.postForEntity(
"/api/v1/data/diagrams", "/api/v1/data/diagrams",
new HttpEntity<>(json, headers), new HttpEntity<>(json, authHeaders),
String.class); String.class);
} }
// Data is in the buffer. Wait for the scheduled flush (60s in this test). // Data is in the buffer. Verify the buffer has data.
// Instead, verify the buffer has data.
assertThat(ingestionService.getDiagramBufferDepth()).isGreaterThanOrEqualTo(3); assertThat(ingestionService.getDiagramBufferDepth()).isGreaterThanOrEqualTo(3);
} }
} }

View File

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

View File

@@ -1,13 +1,14 @@
package com.cameleer3.server.app.controller; package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT; 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.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
@@ -19,6 +20,17 @@ class DiagramControllerIT extends AbstractClickHouseIT {
@Autowired @Autowired
private TestRestTemplate restTemplate; 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 @Test
void postSingleDiagram_returns202() { void postSingleDiagram_returns202() {
String json = """ 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( ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/diagrams", "/api/v1/data/diagrams",
new HttpEntity<>(json, headers), new HttpEntity<>(json, authHeaders),
String.class); String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); 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( restTemplate.postForEntity(
"/api/v1/data/diagrams", "/api/v1/data/diagrams",
new HttpEntity<>(json, headers), new HttpEntity<>(json, authHeaders),
String.class); String.class);
await().atMost(10, SECONDS).untilAsserted(() -> { 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( ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/diagrams", "/api/v1/data/diagrams",
new HttpEntity<>(json, headers), new HttpEntity<>(json, authHeaders),
String.class); String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);

View File

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

View File

@@ -1,13 +1,14 @@
package com.cameleer3.server.app.controller; package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT; 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.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@@ -20,6 +21,17 @@ class ExecutionControllerIT extends AbstractClickHouseIT {
@Autowired @Autowired
private TestRestTemplate restTemplate; 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 @Test
void postSingleExecution_returns202() { void postSingleExecution_returns202() {
String json = """ 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( ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions", "/api/v1/data/executions",
new HttpEntity<>(json, headers), new HttpEntity<>(json, authHeaders),
String.class); String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); 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( ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions", "/api/v1/data/executions",
new HttpEntity<>(json, headers), new HttpEntity<>(json, authHeaders),
String.class); String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); 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( restTemplate.postForEntity(
"/api/v1/data/executions", "/api/v1/data/executions",
new HttpEntity<>(json, headers), new HttpEntity<>(json, authHeaders),
String.class); String.class);
await().atMost(10, SECONDS).untilAsserted(() -> { 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( ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions", "/api/v1/data/executions",
new HttpEntity<>(json, headers), new HttpEntity<>(json, authHeaders),
String.class); String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);

View File

@@ -1,13 +1,14 @@
package com.cameleer3.server.app.controller; package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT; 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.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -20,12 +21,18 @@ class ForwardCompatIT extends AbstractClickHouseIT {
@Autowired @Autowired
private TestRestTemplate restTemplate; private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private String jwt;
@BeforeEach
void setUp() {
jwt = securityHelper.registerTestAgent("test-agent-forward-compat-it");
}
@Test @Test
void unknownFieldsInRequestBodyDoNotCauseError() { 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 = """ String jsonWithUnknownFields = """
{ {
"futureField": "value", "futureField": "value",
@@ -33,16 +40,12 @@ class ForwardCompatIT extends AbstractClickHouseIT {
} }
"""; """;
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = securityHelper.authHeaders(jwt);
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
var entity = new HttpEntity<>(jsonWithUnknownFields, headers); var entity = new HttpEntity<>(jsonWithUnknownFields, headers);
var response = restTemplate.exchange( var response = restTemplate.exchange(
"/api/v1/data/executions", HttpMethod.POST, entity, String.class); "/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()) assertThat(response.getStatusCode().value())
.as("Unknown JSON fields must not cause 400 or 422 deserialization error") .as("Unknown JSON fields must not cause 400 or 422 deserialization error")
.isNotIn(400, 422); .isNotIn(400, 422);

View File

@@ -1,13 +1,14 @@
package com.cameleer3.server.app.controller; package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT; 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.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
@@ -19,6 +20,17 @@ class MetricsControllerIT extends AbstractClickHouseIT {
@Autowired @Autowired
private TestRestTemplate restTemplate; 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 @Test
void postMetrics_returns202() { void postMetrics_returns202() {
String json = """ 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( ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/metrics", "/api/v1/data/metrics",
new HttpEntity<>(json, headers), new HttpEntity<>(json, authHeaders),
String.class); String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); 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( restTemplate.postForEntity(
"/api/v1/data/metrics", "/api/v1/data/metrics",
new HttpEntity<>(json, headers), new HttpEntity<>(json, authHeaders),
String.class); String.class);
await().atMost(10, SECONDS).untilAsserted(() -> { await().atMost(10, SECONDS).untilAsserted(() -> {

View File

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

View File

@@ -1,6 +1,8 @@
package com.cameleer3.server.app.interceptor; package com.cameleer3.server.app.interceptor;
import com.cameleer3.server.app.AbstractClickHouseIT; 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.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate; 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. * 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 { class ProtocolVersionIT extends AbstractClickHouseIT {
@Autowired @Autowired
private TestRestTemplate restTemplate; private TestRestTemplate restTemplate;
@Autowired
private TestSecurityHelper securityHelper;
private String jwt;
@BeforeEach
void setUp() {
jwt = securityHelper.registerTestAgent("test-agent-protocol-it");
}
@Test @Test
void requestWithoutProtocolHeaderReturns400() { void requestWithoutProtocolHeaderReturns400() {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + jwt);
var entity = new HttpEntity<>("{}", headers); var entity = new HttpEntity<>("{}", headers);
var response = restTemplate.exchange( var response = restTemplate.exchange(
@@ -35,6 +50,7 @@ class ProtocolVersionIT extends AbstractClickHouseIT {
void requestWithWrongProtocolVersionReturns400() { void requestWithWrongProtocolVersionReturns400() {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + jwt);
headers.set("X-Cameleer-Protocol-Version", "2"); headers.set("X-Cameleer-Protocol-Version", "2");
var entity = new HttpEntity<>("{}", headers); var entity = new HttpEntity<>("{}", headers);
@@ -47,13 +63,12 @@ class ProtocolVersionIT extends AbstractClickHouseIT {
void requestWithCorrectProtocolVersionPassesInterceptor() { void requestWithCorrectProtocolVersionPassesInterceptor() {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("Authorization", "Bearer " + jwt);
headers.set("X-Cameleer-Protocol-Version", "1"); headers.set("X-Cameleer-Protocol-Version", "1");
var entity = new HttpEntity<>("{}", headers); var entity = new HttpEntity<>("{}", headers);
var response = restTemplate.exchange( var response = restTemplate.exchange(
"/api/v1/data/executions", HttpMethod.POST, entity, String.class); "/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); 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; 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> * <p>
* Adding {@code spring-boot-starter-security} enables security by default (all endpoints * Kept as an empty marker to avoid import errors in case any test referenced it.
* return 401). This configuration overrides that behavior in tests until the real * The real security configuration in {@link SecurityConfig} is active during tests.
* security filter chain is configured in Plan 02.
* <p>
* Uses {@code @Order(-1)} to take precedence over any auto-configured security filter chain.
*/ */
@Configuration
public class TestSecurityConfig { public class TestSecurityConfig {
// Intentionally empty -- real SecurityConfig is now active in tests
@Bean
public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
} }

View File

@@ -1,13 +1,14 @@
package com.cameleer3.server.app.storage; package com.cameleer3.server.app.storage;
import com.cameleer3.server.app.AbstractClickHouseIT; 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.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
@@ -23,9 +24,19 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
@Autowired @Autowired
private TestRestTemplate restTemplate; 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 @Test
void diagramHashPopulated_whenRouteGraphExistsBeforeExecution() { void diagramHashPopulated_whenRouteGraphExistsBeforeExecution() {
// 1. Ingest a RouteGraph for route "diagram-link-route" via the diagrams endpoint
String graphJson = """ String graphJson = """
{ {
"routeId": "diagram-link-route", "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( ResponseEntity<String> diagramResponse = restTemplate.postForEntity(
"/api/v1/data/diagrams", "/api/v1/data/diagrams",
new HttpEntity<>(graphJson, headers), new HttpEntity<>(graphJson, authHeaders),
String.class); String.class);
assertThat(diagramResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(diagramResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
// 2. Wait for diagram to be flushed to ClickHouse before ingesting execution
await().atMost(10, SECONDS).untilAsserted(() -> { await().atMost(10, SECONDS).untilAsserted(() -> {
String hash = jdbcTemplate.queryForObject( String hash = jdbcTemplate.queryForObject(
"SELECT content_hash FROM route_diagrams WHERE route_id = 'diagram-link-route' LIMIT 1", "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(); assertThat(hash).isNotNull().isNotEmpty();
}); });
// 3. Ingest a RouteExecution for the same routeId
String executionJson = """ String executionJson = """
{ {
"routeId": "diagram-link-route", "routeId": "diagram-link-route",
@@ -86,11 +91,10 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
ResponseEntity<String> execResponse = restTemplate.postForEntity( ResponseEntity<String> execResponse = restTemplate.postForEntity(
"/api/v1/data/executions", "/api/v1/data/executions",
new HttpEntity<>(executionJson, headers), new HttpEntity<>(executionJson, authHeaders),
String.class); String.class);
assertThat(execResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); 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(() -> { await().atMost(10, SECONDS).ignoreExceptions().untilAsserted(() -> {
String hash = jdbcTemplate.queryForObject( String hash = jdbcTemplate.queryForObject(
"SELECT diagram_content_hash FROM route_executions WHERE route_id = 'diagram-link-route'", "SELECT diagram_content_hash FROM route_executions WHERE route_id = 'diagram-link-route'",
@@ -98,14 +102,13 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
assertThat(hash) assertThat(hash)
.isNotNull() .isNotNull()
.isNotEmpty() .isNotEmpty()
.hasSize(64) // SHA-256 hex = 64 characters .hasSize(64)
.matches("[a-f0-9]{64}"); .matches("[a-f0-9]{64}");
}); });
} }
@Test @Test
void diagramHashEmpty_whenNoRouteGraphExists() { void diagramHashEmpty_whenNoRouteGraphExists() {
// Ingest a RouteExecution for a route with NO prior diagram
String executionJson = """ String executionJson = """
{ {
"routeId": "no-diagram-route", "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( ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions", "/api/v1/data/executions",
new HttpEntity<>(executionJson, headers), new HttpEntity<>(executionJson, authHeaders),
String.class); String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
// Verify diagram_content_hash is empty string (graceful fallback)
await().atMost(10, SECONDS).ignoreExceptions().untilAsserted(() -> { await().atMost(10, SECONDS).ignoreExceptions().untilAsserted(() -> {
String hash = jdbcTemplate.queryForObject( String hash = jdbcTemplate.queryForObject(
"SELECT diagram_content_hash FROM route_executions WHERE route_id = 'no-diagram-route'", "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; package com.cameleer3.server.app.storage;
import com.cameleer3.server.app.AbstractClickHouseIT; 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.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity; import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -27,9 +27,19 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
@Autowired @Autowired
private TestRestTemplate restTemplate; 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 @Test
void processorTreeMetadata_depthsAndParentIndexesCorrect() { void processorTreeMetadata_depthsAndParentIndexesCorrect() {
// Build a 3-level processor tree: root -> child -> grandchild
String json = """ String json = """
{ {
"routeId": "schema-test-tree", "routeId": "schema-test-tree",
@@ -85,7 +95,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
postExecution(json); postExecution(json);
await().atMost(30, SECONDS).ignoreExceptions().untilAsserted(() -> { await().atMost(30, SECONDS).ignoreExceptions().untilAsserted(() -> {
// Use individual typed queries to avoid ClickHouse Array cast issues
var depths = queryArray( var depths = queryArray(
"SELECT processor_depths FROM route_executions WHERE route_id = 'schema-test-tree'"); "SELECT processor_depths FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(depths).containsExactly("0", "1", "2"); 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'"); "SELECT processor_diagram_node_ids FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(diagramNodeIds).containsExactly("node-root", "node-child", "node-grandchild"); assertThat(diagramNodeIds).containsExactly("node-root", "node-child", "node-grandchild");
// Verify exchange_bodies contains concatenated text
String bodies = jdbcTemplate.queryForObject( String bodies = jdbcTemplate.queryForObject(
"SELECT exchange_bodies FROM route_executions WHERE route_id = 'schema-test-tree'", "SELECT exchange_bodies FROM route_executions WHERE route_id = 'schema-test-tree'",
String.class); String.class);
@@ -107,7 +115,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
assertThat(bodies).contains("child-input"); assertThat(bodies).contains("child-input");
assertThat(bodies).contains("child-output"); assertThat(bodies).contains("child-output");
// Verify per-processor input/output bodies
var inputBodies = queryArray( var inputBodies = queryArray(
"SELECT processor_input_bodies FROM route_executions WHERE route_id = 'schema-test-tree'"); "SELECT processor_input_bodies FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(inputBodies).containsExactly("root-input", "child-input", ""); 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'"); "SELECT processor_output_bodies FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(outputBodies).containsExactly("root-output", "child-output", ""); assertThat(outputBodies).containsExactly("root-output", "child-output", "");
// Verify per-processor input headers stored as JSON strings
var inputHeaders = queryArray( var inputHeaders = queryArray(
"SELECT processor_input_headers FROM route_executions WHERE route_id = 'schema-test-tree'"); "SELECT processor_input_headers FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(inputHeaders.get(0)).contains("Content-Type"); assertThat(inputHeaders.get(0)).contains("Content-Type");
@@ -161,7 +167,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
postExecution(json); postExecution(json);
await().atMost(30, SECONDS).ignoreExceptions().untilAsserted(() -> { await().atMost(30, SECONDS).ignoreExceptions().untilAsserted(() -> {
// Bodies should contain all sources
String bodies = jdbcTemplate.queryForObject( String bodies = jdbcTemplate.queryForObject(
"SELECT exchange_bodies FROM route_executions WHERE route_id = 'schema-test-bodies'", "SELECT exchange_bodies FROM route_executions WHERE route_id = 'schema-test-bodies'",
String.class); String.class);
@@ -170,7 +175,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
assertThat(bodies).contains("route-level-input-body"); assertThat(bodies).contains("route-level-input-body");
assertThat(bodies).contains("route-level-output-body"); assertThat(bodies).contains("route-level-output-body");
// Headers should contain route-level header
String headers = jdbcTemplate.queryForObject( String headers = jdbcTemplate.queryForObject(
"SELECT exchange_headers FROM route_executions WHERE route_id = 'schema-test-bodies'", "SELECT exchange_headers FROM route_executions WHERE route_id = 'schema-test-bodies'",
String.class); String.class);
@@ -181,7 +185,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
@Test @Test
void nullSnapshots_insertSucceedsWithEmptyDefaults() { void nullSnapshots_insertSucceedsWithEmptyDefaults() {
// Execution with no exchange snapshots and no processor snapshot data
String json = """ String json = """
{ {
"routeId": "schema-test-null-snap", "routeId": "schema-test-null-snap",
@@ -207,13 +210,11 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
postExecution(json); postExecution(json);
await().atMost(30, SECONDS).ignoreExceptions().untilAsserted(() -> { await().atMost(30, SECONDS).ignoreExceptions().untilAsserted(() -> {
// Empty but not null
String bodies = jdbcTemplate.queryForObject( String bodies = jdbcTemplate.queryForObject(
"SELECT exchange_bodies FROM route_executions WHERE route_id = 'schema-test-null-snap'", "SELECT exchange_bodies FROM route_executions WHERE route_id = 'schema-test-null-snap'",
String.class); String.class);
assertThat(bodies).isNotNull(); assertThat(bodies).isNotNull();
// Depths and parent indexes still populated for tree metadata
var depths = queryArray( var depths = queryArray(
"SELECT processor_depths FROM route_executions WHERE route_id = 'schema-test-null-snap'"); "SELECT processor_depths FROM route_executions WHERE route_id = 'schema-test-null-snap'");
assertThat(depths).containsExactly("0"); assertThat(depths).containsExactly("0");
@@ -225,22 +226,14 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
} }
private void postExecution(String json) { 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( ResponseEntity<String> response = restTemplate.postForEntity(
"/api/v1/data/executions", "/api/v1/data/executions",
new HttpEntity<>(json, headers), new HttpEntity<>(json, authHeaders),
String.class); String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); 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) { private List<String> queryArray(String sql) {
return jdbcTemplate.query(sql, (rs, rowNum) -> { return jdbcTemplate.query(sql, (rs, rowNum) -> {
Object arr = rs.getArray(1).getArray(); Object arr = rs.getArray(1).getArray();