test(04-03): add failing tests for SSE payload signing

- SsePayloadSignerTest: 7 unit tests for sign/verify roundtrip and edge cases
- SseSigningIT: 2 integration tests for end-to-end SSE signature verification

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-11 20:13:44 +01:00
parent c5a5c28fe0
commit b3b4e62d34
2 changed files with 370 additions and 0 deletions

View File

@@ -0,0 +1,127 @@
package com.cameleer3.server.app.agent;
import com.cameleer3.server.app.security.Ed25519SigningServiceImpl;
import com.cameleer3.server.core.security.Ed25519SigningService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Unit tests for SsePayloadSigner.
* Uses real Ed25519SigningServiceImpl (no Spring context needed).
*/
class SsePayloadSignerTest {
private Ed25519SigningService signingService;
private ObjectMapper objectMapper;
private SsePayloadSigner signer;
@BeforeEach
void setUp() {
signingService = new Ed25519SigningServiceImpl();
objectMapper = new ObjectMapper();
signer = new SsePayloadSigner(signingService, objectMapper);
}
@Test
void signPayload_addsSignatureFieldToJson() throws Exception {
String payload = "{\"key\":\"value\",\"count\":42}";
String signed = signer.signPayload(payload);
JsonNode node = objectMapper.readTree(signed);
assertThat(node.has("signature")).isTrue();
assertThat(node.get("key").asText()).isEqualTo("value");
assertThat(node.get("count").asInt()).isEqualTo(42);
}
@Test
void signPayload_signatureIsBase64Encoded() throws Exception {
String payload = "{\"key\":\"value\"}";
String signed = signer.signPayload(payload);
JsonNode node = objectMapper.readTree(signed);
String signature = node.get("signature").asText();
// Should not throw -- valid Base64
byte[] decoded = Base64.getDecoder().decode(signature);
assertThat(decoded).isNotEmpty();
}
@Test
void signPayload_signatureVerifiesAgainstPublicKey() throws Exception {
String payload = "{\"key\":\"value\",\"nested\":{\"a\":1}}";
String signed = signer.signPayload(payload);
JsonNode node = objectMapper.readTree(signed);
String signatureBase64 = node.get("signature").asText();
// Verify signature against original payload (before signature was added)
byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
PublicKey publicKey = loadPublicKey(signingService.getPublicKeyBase64());
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(payload.getBytes(StandardCharsets.UTF_8));
assertThat(verifier.verify(signatureBytes)).isTrue();
}
@Test
void signPayload_signatureIsOverOriginalPayloadWithoutSignatureField() throws Exception {
String payload = "{\"data\":\"test\"}";
String signed = signer.signPayload(payload);
// Remove signature field and verify the original payload was what was signed
JsonNode node = objectMapper.readTree(signed);
String signatureBase64 = node.get("signature").asText();
byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
PublicKey publicKey = loadPublicKey(signingService.getPublicKeyBase64());
// Verify against original payload string (not reconstructed from parsed JSON)
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(payload.getBytes(StandardCharsets.UTF_8));
assertThat(verifier.verify(signatureBytes))
.as("Signature should be computed over the original JSON string")
.isTrue();
}
@Test
void signPayload_nullPayloadReturnsNull() {
String result = signer.signPayload(null);
assertThat(result).isNull();
}
@Test
void signPayload_emptyPayloadReturnsEmpty() {
String result = signer.signPayload("");
assertThat(result).isEmpty();
}
@Test
void signPayload_blankPayloadReturnsAsIs() {
String result = signer.signPayload(" ");
assertThat(result).isEqualTo(" ");
}
private PublicKey loadPublicKey(String base64PublicKey) throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(base64PublicKey);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
return keyFactory.generatePublic(keySpec);
}
}

View File

@@ -0,0 +1,243 @@
package com.cameleer3.server.app.security;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.cameleer3.server.core.security.Ed25519SigningService;
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.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
/**
* Integration test verifying that SSE command events carry valid Ed25519 signatures.
* <p>
* Flow: register agent -> open SSE stream -> push config-update command ->
* receive SSE event -> verify signature field against server's public key.
* <p>
* NOTE: Uses TestSecurityConfig (permit-all) since Plan 02 (Spring Security filter chain)
* may not yet be complete. When Plan 02 is done, this test should be updated to use
* bootstrap token for registration and JWT for SSE connection.
*/
class SseSigningIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private Ed25519SigningService ed25519SigningService;
@LocalServerPort
private int port;
private HttpHeaders protocolHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
return headers;
}
private ResponseEntity<String> registerAgent(String agentId) {
String json = """
{
"agentId": "%s",
"name": "SSE Signing Test Agent",
"group": "test-group",
"version": "1.0.0",
"routeIds": ["route-1"],
"capabilities": {}
}
""".formatted(agentId);
return restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, protocolHeaders()),
String.class);
}
private ResponseEntity<String> sendCommand(String agentId, String type, String payloadJson) {
String json = """
{"type": "%s", "payload": %s}
""".formatted(type, payloadJson);
return restTemplate.postForEntity(
"/api/v1/agents/" + agentId + "/commands",
new HttpEntity<>(json, protocolHeaders()),
String.class);
}
private SseStream openSseStream(String agentId) {
List<String> lines = new ArrayList<>();
CountDownLatch connected = new CountDownLatch(1);
AtomicInteger statusCode = new AtomicInteger(0);
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:" + port + "/api/v1/agents/" + agentId + "/events"))
.header("Accept", "text/event-stream")
.GET()
.build();
CompletableFuture<Void> future = client.sendAsync(request, HttpResponse.BodyHandlers.ofInputStream())
.thenAccept(response -> {
statusCode.set(response.statusCode());
connected.countDown();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(response.body()))) {
String line;
while ((line = reader.readLine()) != null) {
synchronized (lines) {
lines.add(line);
}
}
} catch (Exception e) {
// Stream closed -- expected
}
});
return new SseStream(lines, future, connected, statusCode);
}
private record SseStream(List<String> lines, CompletableFuture<Void> future,
CountDownLatch connected, AtomicInteger statusCode) {
List<String> snapshot() {
synchronized (lines) {
return new ArrayList<>(lines);
}
}
boolean awaitConnection(long timeoutMs) throws InterruptedException {
return connected.await(timeoutMs, TimeUnit.MILLISECONDS);
}
}
@Test
void configUpdateEvent_containsValidEd25519Signature() throws Exception {
String agentId = "sse-sign-it-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId);
SseStream stream = openSseStream(agentId);
stream.awaitConnection(5000);
String originalPayload = "{\"key\":\"value\",\"setting\":\"enabled\"}";
// Send config-update and wait for SSE event
await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
.ignoreExceptions()
.until(() -> {
sendCommand(agentId, "config-update", originalPayload);
List<String> lines = stream.snapshot();
return lines.stream().anyMatch(l -> l.contains("event:config-update"));
});
// Extract the data line from SSE event
List<String> lines = stream.snapshot();
String dataLine = lines.stream()
.filter(l -> l.startsWith("data:"))
.findFirst()
.orElseThrow(() -> new AssertionError("No data line in SSE event"));
String eventData = dataLine.substring("data:".length());
JsonNode eventNode = objectMapper.readTree(eventData);
// Verify signature field exists
assertThat(eventNode.has("signature"))
.as("SSE event data should contain 'signature' field")
.isTrue();
String signatureBase64 = eventNode.get("signature").asText();
assertThat(signatureBase64).isNotBlank();
// Verify original payload fields are preserved
assertThat(eventNode.get("key").asText()).isEqualTo("value");
assertThat(eventNode.get("setting").asText()).isEqualTo("enabled");
// Verify signature against original payload using server's public key
byte[] signatureBytes = Base64.getDecoder().decode(signatureBase64);
PublicKey publicKey = loadPublicKey(ed25519SigningService.getPublicKeyBase64());
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(originalPayload.getBytes(StandardCharsets.UTF_8));
assertThat(verifier.verify(signatureBytes))
.as("Ed25519 signature should verify against original payload")
.isTrue();
}
@Test
void deepTraceEvent_containsValidSignature() throws Exception {
String agentId = "sse-sign-trace-" + UUID.randomUUID().toString().substring(0, 8);
registerAgent(agentId);
SseStream stream = openSseStream(agentId);
stream.awaitConnection(5000);
String originalPayload = "{\"correlationId\":\"trace-123\"}";
await().atMost(5, TimeUnit.SECONDS).pollInterval(200, TimeUnit.MILLISECONDS)
.ignoreExceptions()
.until(() -> {
sendCommand(agentId, "deep-trace", originalPayload);
List<String> lines = stream.snapshot();
return lines.stream().anyMatch(l -> l.contains("event:deep-trace"));
});
List<String> lines = stream.snapshot();
String dataLine = lines.stream()
.filter(l -> l.startsWith("data:"))
.findFirst()
.orElseThrow();
JsonNode eventNode = objectMapper.readTree(dataLine.substring("data:".length()));
assertThat(eventNode.has("signature")).isTrue();
// Verify signature
byte[] signatureBytes = Base64.getDecoder().decode(eventNode.get("signature").asText());
PublicKey publicKey = loadPublicKey(ed25519SigningService.getPublicKeyBase64());
Signature verifier = Signature.getInstance("Ed25519");
verifier.initVerify(publicKey);
verifier.update(originalPayload.getBytes(StandardCharsets.UTF_8));
assertThat(verifier.verify(signatureBytes)).isTrue();
}
private PublicKey loadPublicKey(String base64PublicKey) throws Exception {
byte[] keyBytes = Base64.getDecoder().decode(base64PublicKey);
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("Ed25519");
return keyFactory.generatePublic(keySpec);
}
}