diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
index a9209980..d4af1159 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/WebConfig.java
@@ -29,7 +29,9 @@ public class WebConfig implements WebMvcConfigurer {
"/api/v1/api-docs/**",
"/api/v1/swagger-ui/**",
"/api/v1/swagger-ui.html",
- "/api/v1/agents/*/events"
+ "/api/v1/agents/*/events",
+ "/api/v1/agents/register",
+ "/api/v1/agents/*/refresh"
);
}
}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
index 8f3b27c9..2c4bdd77 100644
--- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java
@@ -39,7 +39,8 @@ public class SecurityConfig {
"/api/v1/swagger-ui/**",
"/swagger-ui/**",
"/v3/api-docs/**",
- "/swagger-ui.html"
+ "/swagger-ui.html",
+ "/error"
).permitAll()
.anyRequest().authenticated()
)
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/TestSecurityHelper.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/TestSecurityHelper.java
new file mode 100644
index 00000000..9867cd7a
--- /dev/null
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/TestSecurityHelper.java
@@ -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.
+ *
+ * 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;
+ }
+}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentCommandControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentCommandControllerIT.java
index d89d9995..ab98f30d 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentCommandControllerIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentCommandControllerIT.java
@@ -1,8 +1,10 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
@@ -10,7 +12,6 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.UUID;
@@ -25,17 +26,14 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
@Autowired
private ObjectMapper objectMapper;
- private HttpHeaders protocolHeaders() {
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
- return headers;
- }
+ @Autowired
+ private TestSecurityHelper securityHelper;
- private HttpHeaders protocolHeadersNoBody() {
- HttpHeaders headers = new HttpHeaders();
- headers.set("X-Cameleer-Protocol-Version", "1");
- return headers;
+ private String jwt;
+
+ @BeforeEach
+ void setUp() {
+ jwt = securityHelper.registerTestAgent("test-agent-command-it");
}
private ResponseEntity registerAgent(String agentId, String name, String group) {
@@ -52,7 +50,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
return restTemplate.postForEntity(
"/api/v1/agents/register",
- new HttpEntity<>(json, protocolHeaders()),
+ new HttpEntity<>(json, securityHelper.bootstrapHeaders()),
String.class);
}
@@ -67,7 +65,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity response = restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands",
- new HttpEntity<>(commandJson, protocolHeaders()),
+ new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -90,7 +88,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity response = restTemplate.postForEntity(
"/api/v1/agents/groups/" + group + "/commands",
- new HttpEntity<>(commandJson, protocolHeaders()),
+ new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -103,7 +101,6 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
@Test
void broadcastCommand_returns202WithLiveAgentCount() throws Exception {
- // Register at least one agent (others may exist from other tests)
String agentId = "cmd-it-broadcast-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Broadcast Agent", "broadcast-group");
@@ -113,7 +110,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity response = restTemplate.postForEntity(
"/api/v1/agents/commands",
- new HttpEntity<>(commandJson, protocolHeaders()),
+ new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -128,24 +125,22 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
String agentId = "cmd-it-ack-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId, "Ack Agent", "test-group");
- // Send a command first
String commandJson = """
{"type": "config-update", "payload": {"key": "ack-test"}}
""";
ResponseEntity cmdResponse = restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands",
- new HttpEntity<>(commandJson, protocolHeaders()),
+ new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class);
JsonNode cmdBody = objectMapper.readTree(cmdResponse.getBody());
String commandId = cmdBody.get("commandId").asText();
- // Acknowledge the command
ResponseEntity ackResponse = restTemplate.exchange(
"/api/v1/agents/" + agentId + "/commands/" + commandId + "/ack",
HttpMethod.POST,
- new HttpEntity<>(protocolHeadersNoBody()),
+ new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
Void.class);
assertThat(ackResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
@@ -159,7 +154,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity response = restTemplate.exchange(
"/api/v1/agents/" + agentId + "/commands/nonexistent-cmd-id/ack",
HttpMethod.POST,
- new HttpEntity<>(protocolHeadersNoBody()),
+ new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
@@ -173,7 +168,7 @@ class AgentCommandControllerIT extends AbstractClickHouseIT {
ResponseEntity response = restTemplate.postForEntity(
"/api/v1/agents/nonexistent-agent-xyz/commands",
- new HttpEntity<>(commandJson, protocolHeaders()),
+ new HttpEntity<>(commandJson, securityHelper.authHeaders(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java
index 8cc25379..bffe775b 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentRegistrationControllerIT.java
@@ -1,8 +1,10 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
@@ -10,7 +12,6 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
@@ -23,17 +24,14 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
@Autowired
private ObjectMapper objectMapper;
- private HttpHeaders protocolHeaders() {
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
- return headers;
- }
+ @Autowired
+ private TestSecurityHelper securityHelper;
- private HttpHeaders protocolHeadersNoBody() {
- HttpHeaders headers = new HttpHeaders();
- headers.set("X-Cameleer-Protocol-Version", "1");
- return headers;
+ private String jwt;
+
+ @BeforeEach
+ void setUp() {
+ jwt = securityHelper.registerTestAgent("test-agent-registration-it");
}
private ResponseEntity registerAgent(String agentId, String name) {
@@ -50,7 +48,7 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
return restTemplate.postForEntity(
"/api/v1/agents/register",
- new HttpEntity<>(json, protocolHeaders()),
+ new HttpEntity<>(json, securityHelper.bootstrapHeaders()),
String.class);
}
@@ -65,6 +63,11 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
assertThat(body.get("sseEndpoint").asText()).isEqualTo("/api/v1/agents/agent-it-1/events");
assertThat(body.get("heartbeatIntervalMs").asLong()).isGreaterThan(0);
assertThat(body.has("serverPublicKey")).isTrue();
+ assertThat(body.get("serverPublicKey").asText()).isNotEmpty();
+ assertThat(body.has("accessToken")).isTrue();
+ assertThat(body.get("accessToken").asText()).isNotEmpty();
+ assertThat(body.has("refreshToken")).isTrue();
+ assertThat(body.get("refreshToken").asText()).isNotEmpty();
}
@Test
@@ -86,7 +89,7 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
ResponseEntity response = restTemplate.exchange(
"/api/v1/agents/agent-it-hb/heartbeat",
HttpMethod.POST,
- new HttpEntity<>(protocolHeadersNoBody()),
+ new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@@ -97,7 +100,7 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
ResponseEntity response = restTemplate.exchange(
"/api/v1/agents/unknown-agent-xyz/heartbeat",
HttpMethod.POST,
- new HttpEntity<>(protocolHeadersNoBody()),
+ new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
@@ -111,14 +114,13 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
ResponseEntity response = restTemplate.exchange(
"/api/v1/agents",
HttpMethod.GET,
- new HttpEntity<>(protocolHeadersNoBody()),
+ new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
- // At minimum, our two agents should be present (may have more from other tests)
assertThat(body.size()).isGreaterThanOrEqualTo(2);
}
@@ -129,14 +131,13 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
ResponseEntity response = restTemplate.exchange(
"/api/v1/agents?status=LIVE",
HttpMethod.GET,
- new HttpEntity<>(protocolHeadersNoBody()),
+ new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
- // All returned agents should be LIVE
for (JsonNode agent : body) {
assertThat(agent.get("state").asText()).isEqualTo("LIVE");
}
@@ -147,7 +148,7 @@ class AgentRegistrationControllerIT extends AbstractClickHouseIT {
ResponseEntity response = restTemplate.exchange(
"/api/v1/agents?status=INVALID",
HttpMethod.GET,
- new HttpEntity<>(protocolHeadersNoBody()),
+ new HttpEntity<>(securityHelper.authHeadersNoBody(jwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentSseControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentSseControllerIT.java
index 7769c40a..1af16ed5 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentSseControllerIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AgentSseControllerIT.java
@@ -1,14 +1,15 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.io.BufferedReader;
@@ -37,14 +38,17 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
@Autowired
private ObjectMapper objectMapper;
+ @Autowired
+ private TestSecurityHelper securityHelper;
+
@LocalServerPort
private int port;
- private HttpHeaders protocolHeaders() {
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
- return headers;
+ private String jwt;
+
+ @BeforeEach
+ void setUp() {
+ jwt = securityHelper.registerTestAgent("test-agent-sse-it");
}
private ResponseEntity registerAgent(String agentId, String name, String group) {
@@ -61,7 +65,7 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
return restTemplate.postForEntity(
"/api/v1/agents/register",
- new HttpEntity<>(json, protocolHeaders()),
+ new HttpEntity<>(json, securityHelper.bootstrapHeaders()),
String.class);
}
@@ -72,13 +76,12 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
return restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands",
- new HttpEntity<>(json, protocolHeaders()),
+ new HttpEntity<>(json, securityHelper.authHeaders(jwt)),
String.class);
}
/**
- * Opens an SSE stream via java.net.http.HttpClient and collects lines in a list.
- * Uses async API to avoid blocking the test thread.
+ * Opens an SSE stream via java.net.http.HttpClient with JWT query param auth.
*/
private SseStream openSseStream(String agentId) {
return openSseStream(agentId, null);
@@ -93,8 +96,9 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
.connectTimeout(Duration.ofSeconds(5))
.build();
+ // Use JWT query parameter for SSE authentication
HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
- .uri(URI.create("http://localhost:" + port + "/api/v1/agents/" + agentId + "/events"))
+ .uri(URI.create("http://localhost:" + port + "/api/v1/agents/" + agentId + "/events?token=" + jwt))
.header("Accept", "text/event-stream")
.GET();
@@ -143,7 +147,6 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
SseStream stream = openSseStream(agentId);
- // Wait for the connection to be established
assertThat(stream.awaitConnection(5000)).isTrue();
assertThat(stream.statusCode().get()).isEqualTo(200);
}
@@ -155,7 +158,7 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
.build();
HttpRequest request = HttpRequest.newBuilder()
- .uri(URI.create("http://localhost:" + port + "/api/v1/agents/unknown-sse-agent/events"))
+ .uri(URI.create("http://localhost:" + port + "/api/v1/agents/unknown-sse-agent/events?token=" + jwt))
.header("Accept", "text/event-stream")
.GET()
.build();
@@ -175,7 +178,6 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
SseStream stream = openSseStream(agentId);
stream.awaitConnection(5000);
- // Give the SSE stream a moment to fully establish
await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
.ignoreExceptions()
.until(() -> {
@@ -240,7 +242,6 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
SseStream stream = openSseStream(agentId);
stream.awaitConnection(5000);
- // Wait for a ping comment (sent every 1 second in test config)
await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
.ignoreExceptions()
.until(() -> {
@@ -259,7 +260,6 @@ class AgentSseControllerIT extends AbstractClickHouseIT {
SseStream stream = openSseStream(agentId, "some-previous-event-id");
- // Just verify the connection succeeds (no replay expected)
assertThat(stream.awaitConnection(5000)).isTrue();
assertThat(stream.statusCode().get()).isEqualTo(200);
}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/BackpressureIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/BackpressureIT.java
index c36934ee..aa8baa17 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/BackpressureIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/BackpressureIT.java
@@ -1,14 +1,15 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
import com.cameleer3.server.core.ingestion.IngestionService;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource;
@@ -34,14 +35,20 @@ class BackpressureIT extends AbstractClickHouseIT {
@Autowired
private IngestionService ingestionService;
+ @Autowired
+ private TestSecurityHelper securityHelper;
+
+ private HttpHeaders authHeaders;
+
+ @BeforeEach
+ void setUp() {
+ String jwt = securityHelper.registerTestAgent("test-agent-backpressure-it");
+ authHeaders = securityHelper.authHeaders(jwt);
+ }
+
@Test
void whenBufferFull_returns503WithRetryAfter() {
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
// Wait for any initial scheduled flush to complete, then fill buffer via batch POST
- // First, wait until the buffer is empty (initial flush may have run)
await().atMost(5, SECONDS).until(() -> ingestionService.getExecutionBufferDepth() == 0);
// Fill the buffer completely with a batch of 5
@@ -57,7 +64,7 @@ class BackpressureIT extends AbstractClickHouseIT {
ResponseEntity batchResponse = restTemplate.postForEntity(
"/api/v1/data/executions",
- new HttpEntity<>(batchJson, headers),
+ new HttpEntity<>(batchJson, authHeaders),
String.class);
assertThat(batchResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -68,7 +75,7 @@ class BackpressureIT extends AbstractClickHouseIT {
ResponseEntity response = restTemplate.postForEntity(
"/api/v1/data/executions",
- new HttpEntity<>(overflowJson, headers),
+ new HttpEntity<>(overflowJson, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
@@ -77,10 +84,6 @@ class BackpressureIT extends AbstractClickHouseIT {
@Test
void bufferedDataNotLost_afterBackpressure() {
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
// Post data to the diagram buffer (separate from executions used above)
for (int i = 0; i < 3; i++) {
String json = String.format("""
@@ -94,12 +97,11 @@ class BackpressureIT extends AbstractClickHouseIT {
restTemplate.postForEntity(
"/api/v1/data/diagrams",
- new HttpEntity<>(json, headers),
+ new HttpEntity<>(json, authHeaders),
String.class);
}
- // Data is in the buffer. Wait for the scheduled flush (60s in this test).
- // Instead, verify the buffer has data.
+ // Data is in the buffer. Verify the buffer has data.
assertThat(ingestionService.getDiagramBufferDepth()).isGreaterThanOrEqualTo(3);
}
}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DetailControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DetailControllerIT.java
index aa03be94..cdd29df7 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DetailControllerIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DetailControllerIT.java
@@ -1,6 +1,7 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeAll;
@@ -12,7 +13,6 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
@@ -28,8 +28,12 @@ class DetailControllerIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
+ @Autowired
+ private TestSecurityHelper securityHelper;
+
private final ObjectMapper objectMapper = new ObjectMapper();
+ private String jwt;
private String seededExecutionId;
/**
@@ -38,6 +42,8 @@ class DetailControllerIT extends AbstractClickHouseIT {
*/
@BeforeAll
void seedTestData() {
+ jwt = securityHelper.registerTestAgent("test-agent-detail-it");
+
String json = """
{
"routeId": "detail-test-route",
@@ -167,7 +173,6 @@ class DetailControllerIT extends AbstractClickHouseIT {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
- // diagramContentHash should be present (may be empty string)
assertThat(body.has("diagramContentHash")).isTrue();
}
@@ -179,7 +184,6 @@ class DetailControllerIT extends AbstractClickHouseIT {
@Test
void getProcessorSnapshot_returnsExchangeData() throws Exception {
- // Processor index 0 is root-proc
ResponseEntity response = detailGet(
"/" + seededExecutionId + "/processors/0/snapshot");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@@ -208,16 +212,12 @@ class DetailControllerIT extends AbstractClickHouseIT {
// --- Helper methods ---
private void ingest(String json) {
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
restTemplate.postForEntity("/api/v1/data/executions",
- new HttpEntity<>(json, headers), String.class);
+ new HttpEntity<>(json, securityHelper.authHeaders(jwt)), String.class);
}
private ResponseEntity detailGet(String path) {
- HttpHeaders headers = new HttpHeaders();
- headers.set("X-Cameleer-Protocol-Version", "1");
+ HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
return restTemplate.exchange(
"/api/v1/executions" + path,
HttpMethod.GET,
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramControllerIT.java
index 0b0fb2f4..832967fc 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramControllerIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramControllerIT.java
@@ -1,13 +1,14 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
@@ -19,6 +20,17 @@ class DiagramControllerIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
+ @Autowired
+ private TestSecurityHelper securityHelper;
+
+ private HttpHeaders authHeaders;
+
+ @BeforeEach
+ void setUp() {
+ String jwt = securityHelper.registerTestAgent("test-agent-diagram-it");
+ authHeaders = securityHelper.authHeaders(jwt);
+ }
+
@Test
void postSingleDiagram_returns202() {
String json = """
@@ -32,13 +44,9 @@ class DiagramControllerIT extends AbstractClickHouseIT {
}
""";
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
ResponseEntity response = restTemplate.postForEntity(
"/api/v1/data/diagrams",
- new HttpEntity<>(json, headers),
+ new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -57,13 +65,9 @@ class DiagramControllerIT extends AbstractClickHouseIT {
}
""";
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
restTemplate.postForEntity(
"/api/v1/data/diagrams",
- new HttpEntity<>(json, headers),
+ new HttpEntity<>(json, authHeaders),
String.class);
await().atMost(10, SECONDS).untilAsserted(() -> {
@@ -91,13 +95,9 @@ class DiagramControllerIT extends AbstractClickHouseIT {
}]
""";
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
ResponseEntity response = restTemplate.postForEntity(
"/api/v1/data/diagrams",
- new HttpEntity<>(json, headers),
+ new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java
index 2f5995ba..f4b0308d 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DiagramRenderControllerIT.java
@@ -1,6 +1,7 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -9,7 +10,6 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
@@ -25,6 +25,10 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
+ @Autowired
+ private TestSecurityHelper securityHelper;
+
+ private String jwt;
private String contentHash;
/**
@@ -32,6 +36,8 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
*/
@BeforeEach
void seedDiagram() {
+ jwt = securityHelper.registerTestAgent("test-agent-diagram-render-it");
+
String json = """
{
"routeId": "render-test-route",
@@ -50,13 +56,9 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
}
""";
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
restTemplate.postForEntity(
"/api/v1/data/diagrams",
- new HttpEntity<>(json, headers),
+ new HttpEntity<>(json, securityHelper.authHeaders(jwt)),
String.class);
// Wait for flush to ClickHouse and retrieve the content hash
@@ -71,9 +73,8 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
@Test
void getSvg_withAcceptHeader_returnsSvg() {
- HttpHeaders headers = new HttpHeaders();
+ HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
headers.set("Accept", "image/svg+xml");
- headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
@@ -89,9 +90,8 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
@Test
void getJson_withAcceptHeader_returnsJson() {
- HttpHeaders headers = new HttpHeaders();
+ HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
headers.set("Accept", "application/json");
- headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
@@ -107,9 +107,8 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
@Test
void getNonExistentHash_returns404() {
- HttpHeaders headers = new HttpHeaders();
+ HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
headers.set("Accept", "image/svg+xml");
- headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
@@ -123,8 +122,7 @@ class DiagramRenderControllerIT extends AbstractClickHouseIT {
@Test
void getWithNoAcceptHeader_defaultsToSvg() {
- HttpHeaders headers = new HttpHeaders();
- headers.set("X-Cameleer-Protocol-Version", "1");
+ HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
ResponseEntity response = restTemplate.exchange(
"/api/v1/diagrams/{hash}/render",
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ExecutionControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ExecutionControllerIT.java
index 0358ba74..a2bf59d5 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ExecutionControllerIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ExecutionControllerIT.java
@@ -1,13 +1,14 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -20,6 +21,17 @@ class ExecutionControllerIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
+ @Autowired
+ private TestSecurityHelper securityHelper;
+
+ private HttpHeaders authHeaders;
+
+ @BeforeEach
+ void setUp() {
+ String jwt = securityHelper.registerTestAgent("test-agent-execution-it");
+ authHeaders = securityHelper.authHeaders(jwt);
+ }
+
@Test
void postSingleExecution_returns202() {
String json = """
@@ -37,13 +49,9 @@ class ExecutionControllerIT extends AbstractClickHouseIT {
}
""";
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
ResponseEntity response = restTemplate.postForEntity(
"/api/v1/data/executions",
- new HttpEntity<>(json, headers),
+ new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -73,13 +81,9 @@ class ExecutionControllerIT extends AbstractClickHouseIT {
}]
""";
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
ResponseEntity response = restTemplate.postForEntity(
"/api/v1/data/executions",
- new HttpEntity<>(json, headers),
+ new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -100,13 +104,9 @@ class ExecutionControllerIT extends AbstractClickHouseIT {
}
""";
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
restTemplate.postForEntity(
"/api/v1/data/executions",
- new HttpEntity<>(json, headers),
+ new HttpEntity<>(json, authHeaders),
String.class);
await().atMost(10, SECONDS).untilAsserted(() -> {
@@ -132,13 +132,9 @@ class ExecutionControllerIT extends AbstractClickHouseIT {
}
""";
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
ResponseEntity response = restTemplate.postForEntity(
"/api/v1/data/executions",
- new HttpEntity<>(json, headers),
+ new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ForwardCompatIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ForwardCompatIT.java
index 1f7f23fe..9d68212d 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ForwardCompatIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/ForwardCompatIT.java
@@ -1,13 +1,14 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
-import org.springframework.http.MediaType;
import static org.assertj.core.api.Assertions.assertThat;
@@ -20,12 +21,18 @@ class ForwardCompatIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
+ @Autowired
+ private TestSecurityHelper securityHelper;
+
+ private String jwt;
+
+ @BeforeEach
+ void setUp() {
+ jwt = securityHelper.registerTestAgent("test-agent-forward-compat-it");
+ }
+
@Test
void unknownFieldsInRequestBodyDoNotCauseError() {
- // JSON body with an unknown field that should not cause a 400 deserialization error.
- // Jackson is configured with fail-on-unknown-properties: false in application.yml.
- // Without the ExecutionController (Plan 01-02), this returns 404 -- which is acceptable.
- // The key assertion: it must NOT be 400 (i.e., Jackson did not reject unknown fields).
String jsonWithUnknownFields = """
{
"futureField": "value",
@@ -33,16 +40,12 @@ class ForwardCompatIT extends AbstractClickHouseIT {
}
""";
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
+ HttpHeaders headers = securityHelper.authHeaders(jwt);
var entity = new HttpEntity<>(jsonWithUnknownFields, headers);
var response = restTemplate.exchange(
"/api/v1/data/executions", HttpMethod.POST, entity, String.class);
- // The interceptor passes (correct protocol header), and Jackson should not reject
- // unknown fields. Without a controller, expect 404 (not 400 or 422).
assertThat(response.getStatusCode().value())
.as("Unknown JSON fields must not cause 400 or 422 deserialization error")
.isNotIn(400, 422);
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/MetricsControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/MetricsControllerIT.java
index 2d3b5c71..d0eb9793 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/MetricsControllerIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/MetricsControllerIT.java
@@ -1,13 +1,14 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
@@ -19,6 +20,17 @@ class MetricsControllerIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
+ @Autowired
+ private TestSecurityHelper securityHelper;
+
+ private HttpHeaders authHeaders;
+
+ @BeforeEach
+ void setUp() {
+ String jwt = securityHelper.registerTestAgent("test-agent-metrics-it");
+ authHeaders = securityHelper.authHeaders(jwt);
+ }
+
@Test
void postMetrics_returns202() {
String json = """
@@ -31,13 +43,9 @@ class MetricsControllerIT extends AbstractClickHouseIT {
}]
""";
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
ResponseEntity response = restTemplate.postForEntity(
"/api/v1/data/metrics",
- new HttpEntity<>(json, headers),
+ new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
@@ -55,13 +63,9 @@ class MetricsControllerIT extends AbstractClickHouseIT {
}]
""";
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
restTemplate.postForEntity(
"/api/v1/data/metrics",
- new HttpEntity<>(json, headers),
+ new HttpEntity<>(json, authHeaders),
String.class);
await().atMost(10, SECONDS).untilAsserted(() -> {
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/SearchControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/SearchControllerIT.java
index 1aa36f92..8ae4e072 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/SearchControllerIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/SearchControllerIT.java
@@ -1,6 +1,7 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeAll;
@@ -12,7 +13,6 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
@@ -29,14 +29,21 @@ class SearchControllerIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
+ @Autowired
+ private TestSecurityHelper securityHelper;
+
private final ObjectMapper objectMapper = new ObjectMapper();
+ private String jwt;
+
/**
* Seed test data: Insert executions with varying statuses, times, durations,
* correlationIds, error messages, and exchange snapshot data.
*/
@BeforeAll
void seedTestData() {
+ jwt = securityHelper.registerTestAgent("test-agent-search-it");
+
// Execution 1: COMPLETED, short duration, no errors
ingest("""
{
@@ -160,20 +167,16 @@ class SearchControllerIT extends AbstractClickHouseIT {
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
- // At least 2 FAILED from our seed data (other test classes may add more)
assertThat(body.get("total").asLong()).isGreaterThanOrEqualTo(2);
assertThat(body.get("offset").asInt()).isEqualTo(0);
assertThat(body.get("limit").asInt()).isEqualTo(50);
assertThat(body.get("data")).isNotNull();
- // All returned results must be FAILED
body.get("data").forEach(item ->
assertThat(item.get("status").asText()).isEqualTo("FAILED"));
}
@Test
void searchByTimeRange_returnsOnlyExecutionsInRange() throws Exception {
- // Use correlationId + time range to precisely verify time filtering
- // corr-alpha is at 10:00, within [09:00, 13:00]
ResponseEntity response = searchGet(
"?timeFrom=2026-03-10T09:00:00Z&timeTo=2026-03-10T13:00:00Z&correlationId=corr-alpha");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
@@ -182,7 +185,6 @@ class SearchControllerIT extends AbstractClickHouseIT {
assertThat(body.get("total").asLong()).isEqualTo(1);
assertThat(body.get("data").get(0).get("correlationId").asText()).isEqualTo("corr-alpha");
- // corr-gamma is at 2026-03-11T08:00, outside [09:00, 13:00 on 03-10]
ResponseEntity response2 = searchGet(
"?timeFrom=2026-03-10T09:00:00Z&timeTo=2026-03-10T13:00:00Z&correlationId=corr-gamma");
JsonNode body2 = objectMapper.readTree(response2.getBody());
@@ -191,13 +193,10 @@ class SearchControllerIT extends AbstractClickHouseIT {
@Test
void searchByDuration_returnsOnlyMatchingExecutions() throws Exception {
- // Use correlationId to verify duration filter precisely
- // corr-beta has 200ms, corr-delta has 300ms -- both in [100, 500]
ResponseEntity response = searchGet("?correlationId=corr-beta");
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("total").asLong()).isEqualTo(1);
- // Verify duration filter excludes corr-alpha (50ms) when min=100
ResponseEntity response2 = searchPost("""
{
"durationMin": 100,
@@ -208,7 +207,6 @@ class SearchControllerIT extends AbstractClickHouseIT {
JsonNode body2 = objectMapper.readTree(response2.getBody());
assertThat(body2.get("total").asLong()).isZero();
- // Verify duration filter includes corr-delta (300ms) when in [100, 500]
ResponseEntity response3 = searchPost("""
{
"durationMin": 100,
@@ -268,7 +266,6 @@ class SearchControllerIT extends AbstractClickHouseIT {
@Test
void fullTextSearchInHeaders_findsMatchInExchangeHeaders() throws Exception {
- // Content-Type appears in exec 1 and exec 4 headers
ResponseEntity response = searchPost("""
{ "textInHeaders": "Content-Type" }
""");
@@ -292,7 +289,6 @@ class SearchControllerIT extends AbstractClickHouseIT {
@Test
void combinedFilters_statusAndText() throws Exception {
- // Only FAILED + NullPointer = exec 2
ResponseEntity response = searchPost("""
{
"status": "FAILED",
@@ -327,14 +323,11 @@ class SearchControllerIT extends AbstractClickHouseIT {
@Test
void pagination_worksCorrectly() throws Exception {
- // First, get total count of COMPLETED executions (7 from our seed data:
- // exec 1 + execs 5-10; execs 2,4 are FAILED, exec 3 is RUNNING)
ResponseEntity countResponse = searchGet("?status=COMPLETED&limit=1");
JsonNode countBody = objectMapper.readTree(countResponse.getBody());
long totalCompleted = countBody.get("total").asLong();
assertThat(totalCompleted).isGreaterThanOrEqualTo(7);
- // Now test pagination with offset=2, limit=3
ResponseEntity response = searchPost("""
{
"status": "COMPLETED",
@@ -366,16 +359,12 @@ class SearchControllerIT extends AbstractClickHouseIT {
// --- Helper methods ---
private void ingest(String json) {
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
restTemplate.postForEntity("/api/v1/data/executions",
- new HttpEntity<>(json, headers), String.class);
+ new HttpEntity<>(json, securityHelper.authHeaders(jwt)), String.class);
}
private ResponseEntity searchGet(String queryString) {
- HttpHeaders headers = new HttpHeaders();
- headers.set("X-Cameleer-Protocol-Version", "1");
+ HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
return restTemplate.exchange(
"/api/v1/search/executions" + queryString,
HttpMethod.GET,
@@ -384,13 +373,10 @@ class SearchControllerIT extends AbstractClickHouseIT {
}
private ResponseEntity searchPost(String jsonBody) {
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
return restTemplate.exchange(
"/api/v1/search/executions",
HttpMethod.POST,
- new HttpEntity<>(jsonBody, headers),
+ new HttpEntity<>(jsonBody, securityHelper.authHeaders(jwt)),
String.class);
}
}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/interceptor/ProtocolVersionIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/interceptor/ProtocolVersionIT.java
index bb782707..26e8d5a9 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/interceptor/ProtocolVersionIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/interceptor/ProtocolVersionIT.java
@@ -1,6 +1,8 @@
package com.cameleer3.server.app.interceptor;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
@@ -13,16 +15,29 @@ import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for the protocol version interceptor.
+ * With security enabled, requests to protected endpoints need JWT auth
+ * to reach the interceptor layer.
*/
class ProtocolVersionIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
+ @Autowired
+ private TestSecurityHelper securityHelper;
+
+ private String jwt;
+
+ @BeforeEach
+ void setUp() {
+ jwt = securityHelper.registerTestAgent("test-agent-protocol-it");
+ }
+
@Test
void requestWithoutProtocolHeaderReturns400() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set("Authorization", "Bearer " + jwt);
var entity = new HttpEntity<>("{}", headers);
var response = restTemplate.exchange(
@@ -35,6 +50,7 @@ class ProtocolVersionIT extends AbstractClickHouseIT {
void requestWithWrongProtocolVersionReturns400() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set("Authorization", "Bearer " + jwt);
headers.set("X-Cameleer-Protocol-Version", "2");
var entity = new HttpEntity<>("{}", headers);
@@ -47,13 +63,12 @@ class ProtocolVersionIT extends AbstractClickHouseIT {
void requestWithCorrectProtocolVersionPassesInterceptor() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
+ headers.set("Authorization", "Bearer " + jwt);
headers.set("X-Cameleer-Protocol-Version", "1");
var entity = new HttpEntity<>("{}", headers);
var response = restTemplate.exchange(
"/api/v1/data/executions", HttpMethod.POST, entity, String.class);
- // The interceptor should NOT reject this request (not 400 from interceptor).
- // Without the controller (Plan 01-02), this will be 404 -- which is fine.
assertThat(response.getStatusCode().value()).isNotEqualTo(400);
}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenIT.java
new file mode 100644
index 00000000..1309517b
--- /dev/null
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenIT.java
@@ -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 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 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 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 response = restTemplate.postForEntity(
+ "/api/v1/agents/register",
+ new HttpEntity<>(json, headers),
+ String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ }
+}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtRefreshIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtRefreshIT.java
new file mode 100644
index 00000000..7e40e0a1
--- /dev/null
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtRefreshIT.java
@@ -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 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 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 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 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 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 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 response = restTemplate.exchange(
+ "/api/v1/agents",
+ HttpMethod.GET,
+ new HttpEntity<>(authHeaders),
+ String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ }
+}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/RegistrationSecurityIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/RegistrationSecurityIT.java
new file mode 100644
index 00000000..abd35524
--- /dev/null
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/RegistrationSecurityIT.java
@@ -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 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 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 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 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 response = restTemplate.exchange(
+ "/api/v1/agents",
+ HttpMethod.GET,
+ new HttpEntity<>(headers),
+ String.class);
+
+ assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
+ }
+}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SecurityFilterIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SecurityFilterIT.java
new file mode 100644
index 00000000..38f25766
--- /dev/null
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SecurityFilterIT.java
@@ -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 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 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 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 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 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 response = restTemplate.exchange(
+ "/api/v1/agents",
+ HttpMethod.GET,
+ new HttpEntity<>(headers),
+ String.class);
+
+ assertThat(response.getStatusCode().value()).isIn(401, 403);
+ }
+}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java
index 300b5048..2f67ae8f 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/TestSecurityConfig.java
@@ -1,28 +1,12 @@
package com.cameleer3.server.app.security;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Configuration;
-import org.springframework.core.annotation.Order;
-import org.springframework.security.config.annotation.web.builders.HttpSecurity;
-import org.springframework.security.web.SecurityFilterChain;
-
/**
- * Temporary test security configuration that permits all requests.
+ * Previously this class provided a permit-all SecurityFilterChain for tests.
+ * Now that the real {@link SecurityConfig} is in place, this class is no longer needed.
*
- * Adding {@code spring-boot-starter-security} enables security by default (all endpoints
- * return 401). This configuration overrides that behavior in tests until the real
- * security filter chain is configured in Plan 02.
- *
- * Uses {@code @Order(-1)} to take precedence over any auto-configured security filter chain.
+ * Kept as an empty marker to avoid import errors in case any test referenced it.
+ * The real security configuration in {@link SecurityConfig} is active during tests.
*/
-@Configuration
public class TestSecurityConfig {
-
- @Bean
- public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception {
- http
- .csrf(csrf -> csrf.disable())
- .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
- return http.build();
- }
+ // Intentionally empty -- real SecurityConfig is now active in tests
}
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/DiagramLinkingIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/DiagramLinkingIT.java
index 2c464323..7322ec26 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/DiagramLinkingIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/DiagramLinkingIT.java
@@ -1,13 +1,14 @@
package com.cameleer3.server.app.storage;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static java.util.concurrent.TimeUnit.SECONDS;
@@ -23,9 +24,19 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
+ @Autowired
+ private TestSecurityHelper securityHelper;
+
+ private HttpHeaders authHeaders;
+
+ @BeforeEach
+ void setUp() {
+ String jwt = securityHelper.registerTestAgent("test-agent-diagram-linking-it");
+ authHeaders = securityHelper.authHeaders(jwt);
+ }
+
@Test
void diagramHashPopulated_whenRouteGraphExistsBeforeExecution() {
- // 1. Ingest a RouteGraph for route "diagram-link-route" via the diagrams endpoint
String graphJson = """
{
"routeId": "diagram-link-route",
@@ -42,17 +53,12 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
}
""";
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
ResponseEntity diagramResponse = restTemplate.postForEntity(
"/api/v1/data/diagrams",
- new HttpEntity<>(graphJson, headers),
+ new HttpEntity<>(graphJson, authHeaders),
String.class);
assertThat(diagramResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
- // 2. Wait for diagram to be flushed to ClickHouse before ingesting execution
await().atMost(10, SECONDS).untilAsserted(() -> {
String hash = jdbcTemplate.queryForObject(
"SELECT content_hash FROM route_diagrams WHERE route_id = 'diagram-link-route' LIMIT 1",
@@ -60,7 +66,6 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
assertThat(hash).isNotNull().isNotEmpty();
});
- // 3. Ingest a RouteExecution for the same routeId
String executionJson = """
{
"routeId": "diagram-link-route",
@@ -86,11 +91,10 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
ResponseEntity execResponse = restTemplate.postForEntity(
"/api/v1/data/executions",
- new HttpEntity<>(executionJson, headers),
+ new HttpEntity<>(executionJson, authHeaders),
String.class);
assertThat(execResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
- // 4. Verify diagram_content_hash is a non-empty SHA-256 hash (64 hex chars)
await().atMost(10, SECONDS).ignoreExceptions().untilAsserted(() -> {
String hash = jdbcTemplate.queryForObject(
"SELECT diagram_content_hash FROM route_executions WHERE route_id = 'diagram-link-route'",
@@ -98,14 +102,13 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
assertThat(hash)
.isNotNull()
.isNotEmpty()
- .hasSize(64) // SHA-256 hex = 64 characters
+ .hasSize(64)
.matches("[a-f0-9]{64}");
});
}
@Test
void diagramHashEmpty_whenNoRouteGraphExists() {
- // Ingest a RouteExecution for a route with NO prior diagram
String executionJson = """
{
"routeId": "no-diagram-route",
@@ -129,17 +132,12 @@ class DiagramLinkingIT extends AbstractClickHouseIT {
}
""";
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
ResponseEntity response = restTemplate.postForEntity(
"/api/v1/data/executions",
- new HttpEntity<>(executionJson, headers),
+ new HttpEntity<>(executionJson, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
- // Verify diagram_content_hash is empty string (graceful fallback)
await().atMost(10, SECONDS).ignoreExceptions().untilAsserted(() -> {
String hash = jdbcTemplate.queryForObject(
"SELECT diagram_content_hash FROM route_executions WHERE route_id = 'no-diagram-route'",
diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/IngestionSchemaIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/IngestionSchemaIT.java
index 23710a6c..d0d79e02 100644
--- a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/IngestionSchemaIT.java
+++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/storage/IngestionSchemaIT.java
@@ -1,18 +1,18 @@
package com.cameleer3.server.app.storage;
import com.cameleer3.server.app.AbstractClickHouseIT;
+import com.cameleer3.server.app.TestSecurityHelper;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
-import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.Arrays;
import java.util.List;
-import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
@@ -27,9 +27,19 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
+ @Autowired
+ private TestSecurityHelper securityHelper;
+
+ private HttpHeaders authHeaders;
+
+ @BeforeEach
+ void setUp() {
+ String jwt = securityHelper.registerTestAgent("test-agent-ingestion-schema-it");
+ authHeaders = securityHelper.authHeaders(jwt);
+ }
+
@Test
void processorTreeMetadata_depthsAndParentIndexesCorrect() {
- // Build a 3-level processor tree: root -> child -> grandchild
String json = """
{
"routeId": "schema-test-tree",
@@ -85,7 +95,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
postExecution(json);
await().atMost(30, SECONDS).ignoreExceptions().untilAsserted(() -> {
- // Use individual typed queries to avoid ClickHouse Array cast issues
var depths = queryArray(
"SELECT processor_depths FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(depths).containsExactly("0", "1", "2");
@@ -98,7 +107,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
"SELECT processor_diagram_node_ids FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(diagramNodeIds).containsExactly("node-root", "node-child", "node-grandchild");
- // Verify exchange_bodies contains concatenated text
String bodies = jdbcTemplate.queryForObject(
"SELECT exchange_bodies FROM route_executions WHERE route_id = 'schema-test-tree'",
String.class);
@@ -107,7 +115,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
assertThat(bodies).contains("child-input");
assertThat(bodies).contains("child-output");
- // Verify per-processor input/output bodies
var inputBodies = queryArray(
"SELECT processor_input_bodies FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(inputBodies).containsExactly("root-input", "child-input", "");
@@ -116,7 +123,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
"SELECT processor_output_bodies FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(outputBodies).containsExactly("root-output", "child-output", "");
- // Verify per-processor input headers stored as JSON strings
var inputHeaders = queryArray(
"SELECT processor_input_headers FROM route_executions WHERE route_id = 'schema-test-tree'");
assertThat(inputHeaders.get(0)).contains("Content-Type");
@@ -161,7 +167,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
postExecution(json);
await().atMost(30, SECONDS).ignoreExceptions().untilAsserted(() -> {
- // Bodies should contain all sources
String bodies = jdbcTemplate.queryForObject(
"SELECT exchange_bodies FROM route_executions WHERE route_id = 'schema-test-bodies'",
String.class);
@@ -170,7 +175,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
assertThat(bodies).contains("route-level-input-body");
assertThat(bodies).contains("route-level-output-body");
- // Headers should contain route-level header
String headers = jdbcTemplate.queryForObject(
"SELECT exchange_headers FROM route_executions WHERE route_id = 'schema-test-bodies'",
String.class);
@@ -181,7 +185,6 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
@Test
void nullSnapshots_insertSucceedsWithEmptyDefaults() {
- // Execution with no exchange snapshots and no processor snapshot data
String json = """
{
"routeId": "schema-test-null-snap",
@@ -207,13 +210,11 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
postExecution(json);
await().atMost(30, SECONDS).ignoreExceptions().untilAsserted(() -> {
- // Empty but not null
String bodies = jdbcTemplate.queryForObject(
"SELECT exchange_bodies FROM route_executions WHERE route_id = 'schema-test-null-snap'",
String.class);
assertThat(bodies).isNotNull();
- // Depths and parent indexes still populated for tree metadata
var depths = queryArray(
"SELECT processor_depths FROM route_executions WHERE route_id = 'schema-test-null-snap'");
assertThat(depths).containsExactly("0");
@@ -225,22 +226,14 @@ class IngestionSchemaIT extends AbstractClickHouseIT {
}
private void postExecution(String json) {
- HttpHeaders headers = new HttpHeaders();
- headers.setContentType(MediaType.APPLICATION_JSON);
- headers.set("X-Cameleer-Protocol-Version", "1");
-
ResponseEntity response = restTemplate.postForEntity(
"/api/v1/data/executions",
- new HttpEntity<>(json, headers),
+ new HttpEntity<>(json, authHeaders),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
}
- /**
- * Query an array column from ClickHouse and return it as a List of strings.
- * Handles the ClickHouse JDBC Array type by converting via toString on elements.
- */
private List queryArray(String sql) {
return jdbcTemplate.query(sql, (rs, rowNum) -> {
Object arr = rs.getArray(1).getArray();