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