From 539b85f307f888df3d56a942a26903d02c556671 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 11 Mar 2026 20:38:28 +0100 Subject: [PATCH] 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 --- .../server/app/config/WebConfig.java | 4 +- .../server/app/security/SecurityConfig.java | 3 +- .../server/app/TestSecurityHelper.java | 68 +++++++ .../controller/AgentCommandControllerIT.java | 39 ++-- .../AgentRegistrationControllerIT.java | 39 ++-- .../app/controller/AgentSseControllerIT.java | 32 ++-- .../server/app/controller/BackpressureIT.java | 32 ++-- .../app/controller/DetailControllerIT.java | 18 +- .../app/controller/DiagramControllerIT.java | 32 ++-- .../controller/DiagramRenderControllerIT.java | 26 ++- .../app/controller/ExecutionControllerIT.java | 38 ++-- .../app/controller/ForwardCompatIT.java | 23 +-- .../app/controller/MetricsControllerIT.java | 26 +-- .../app/controller/SearchControllerIT.java | 36 ++-- .../app/interceptor/ProtocolVersionIT.java | 19 +- .../server/app/security/BootstrapTokenIT.java | 114 ++++++++++++ .../server/app/security/JwtRefreshIT.java | 169 ++++++++++++++++++ .../app/security/RegistrationSecurityIT.java | 97 ++++++++++ .../server/app/security/SecurityFilterIT.java | 114 ++++++++++++ .../app/security/TestSecurityConfig.java | 26 +-- .../server/app/storage/DiagramLinkingIT.java | 36 ++-- .../server/app/storage/IngestionSchemaIT.java | 35 ++-- 22 files changed, 783 insertions(+), 243 deletions(-) create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/TestSecurityHelper.java create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/BootstrapTokenIT.java create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/JwtRefreshIT.java create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/RegistrationSecurityIT.java create mode 100644 cameleer3-server-app/src/test/java/com/cameleer3/server/app/security/SecurityFilterIT.java 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();