diff --git a/cameleer-server-app/src/main/java/io/cameleer/server/app/agent/SseConnectionManager.java b/cameleer-server-app/src/main/java/io/cameleer/server/app/agent/SseConnectionManager.java index 534c3874..9909a511 100644 --- a/cameleer-server-app/src/main/java/io/cameleer/server/app/agent/SseConnectionManager.java +++ b/cameleer-server-app/src/main/java/io/cameleer/server/app/agent/SseConnectionManager.java @@ -1,6 +1,7 @@ package io.cameleer.server.app.agent; import io.cameleer.server.app.config.AgentRegistryConfig; +import io.cameleer.server.app.metrics.ServerMetrics; import io.cameleer.server.core.agent.AgentCommand; import io.cameleer.server.core.agent.AgentEventListener; import io.cameleer.server.core.agent.AgentRegistryService; @@ -9,6 +10,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.http.MediaType; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -35,6 +38,10 @@ public class SseConnectionManager implements AgentEventListener { private final AgentRegistryConfig config; private final SsePayloadSigner ssePayloadSigner; private final ObjectMapper objectMapper; + // Injected via setter to break the ServerMetrics <-> SseConnectionManager circular dependency. + // ServerMetrics holds a gauge over SseConnectionManager.getConnectionCount(), so constructor + // injection in either direction creates a cycle. @Lazy proxy defers the actual bean lookup. + private ServerMetrics serverMetrics; public SseConnectionManager(AgentRegistryService registryService, AgentRegistryConfig config, SsePayloadSigner ssePayloadSigner, ObjectMapper objectMapper) { @@ -44,6 +51,11 @@ public class SseConnectionManager implements AgentEventListener { this.objectMapper = objectMapper; } + @Autowired + public void setServerMetrics(@Lazy ServerMetrics serverMetrics) { + this.serverMetrics = serverMetrics; + } + @PostConstruct void init() { registryService.setEventListener(this); @@ -159,7 +171,15 @@ public class SseConnectionManager implements AgentEventListener { @Override public void onCommandReady(String agentId, AgentCommand command) { String eventType = command.type().name().toLowerCase().replace('_', '-'); - String signedPayload = ssePayloadSigner.signPayload(command.payload()); + String signedPayload; + try { + signedPayload = ssePayloadSigner.signPayload(command.payload()); + } catch (RuntimeException e) { + log.error("SSE signing failed for command {} ({}) targeting agent {}: {}", + command.id(), eventType, agentId, e.getMessage()); + serverMetrics.recordSseSigningFailure(eventType); + return; // command stays PENDING; expires per commandExpiryMs + } // Parse to JsonNode so SseEmitter serializes the tree correctly (avoids double-quoting a raw string) Object data; try { diff --git a/cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentCommandController.java b/cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentCommandController.java index ef8a18ec..9a35153f 100644 --- a/cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentCommandController.java +++ b/cameleer-server-app/src/main/java/io/cameleer/server/app/controller/AgentCommandController.java @@ -2,7 +2,9 @@ package io.cameleer.server.app.controller; import io.cameleer.server.app.agent.SseConnectionManager; import io.cameleer.server.app.dto.CommandAckRequest; +import io.cameleer.server.app.metrics.ServerMetrics; import io.cameleer.server.app.security.AgentOwnershipGuard; +import io.cameleer.server.app.security.SecurityProperties; import io.cameleer.server.app.dto.CommandBroadcastResponse; import io.cameleer.server.app.dto.CommandGroupResponse; import io.cameleer.server.app.dto.CommandRequest; @@ -70,19 +72,25 @@ public class AgentCommandController { private final AgentEventService agentEventService; private final AuditService auditService; private final AgentOwnershipGuard ownershipGuard; + private final ServerMetrics serverMetrics; + private final SecurityProperties securityProperties; public AgentCommandController(AgentRegistryService registryService, SseConnectionManager connectionManager, ObjectMapper objectMapper, AgentEventService agentEventService, AuditService auditService, - AgentOwnershipGuard ownershipGuard) { + AgentOwnershipGuard ownershipGuard, + ServerMetrics serverMetrics, + SecurityProperties securityProperties) { this.registryService = registryService; this.connectionManager = connectionManager; this.objectMapper = objectMapper; this.agentEventService = agentEventService; this.auditService = auditService; this.ownershipGuard = ownershipGuard; + this.serverMetrics = serverMetrics; + this.securityProperties = securityProperties; } @PostMapping("/{id}/commands") @@ -100,6 +108,23 @@ public class AgentCommandController { } CommandType type = mapCommandType(request.type()); + + // E4: warn when targeting a non-capable agent (one that won't verify the signature) + if (!agent.requireSignedCommands()) { + log.warn("Operator sent {} command to agent {} which does not advertise requireSignedCommands=true. " + + "The agent will accept the signed payload but won't actually verify the signature. Upgrade the agent.", + type, id); + serverMetrics.recordCommandToNonCapableAgent(type.name()); + } + + // E5: hard-enforce when flag is enabled + if (securityProperties.isEnforceSignedCommands() && !agent.requireSignedCommands()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Refusing to send " + type + " command to agent " + id + ": agent does not advertise " + + "requireSignedCommands=true and cameleer.server.security.enforce-signed-commands is enabled. " + + "Upgrade the agent or disable enforcement."); + } + String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}"; AgentCommand command = registryService.addCommand(id, type, payloadJson); @@ -123,6 +148,36 @@ public class AgentCommandController { @RequestBody CommandRequest request, HttpServletRequest httpRequest) throws JsonProcessingException { CommandType type = mapCommandType(request.type()); + + // E4/E5: per-agent capability check before dispatching to the group. + // We resolve the group members the same way addGroupCommandWithReplies does: + // by applicationId (== group) and optional environmentId. + List groupMembers = registryService.findByState(AgentState.LIVE).stream() + .filter(a -> group.equals(a.applicationId())) + .filter(a -> environment == null || environment.equals(a.environmentId())) + .toList(); + + List nonCapableIds = groupMembers.stream() + .filter(a -> !a.requireSignedCommands()) + .map(AgentInfo::instanceId) + .toList(); + + if (!nonCapableIds.isEmpty()) { + log.warn("Operator sent {} command to group {} — {} of {} agents do not advertise " + + "requireSignedCommands=true (ids: {}). They will accept but not verify the signature. Upgrade the agents.", + type, group, nonCapableIds.size(), groupMembers.size(), nonCapableIds); + nonCapableIds.forEach(agentId -> serverMetrics.recordCommandToNonCapableAgent(type.name())); + } + + // E5: refuse the whole batch — loud failure is better than silent partial fan-out + if (securityProperties.isEnforceSignedCommands() && !nonCapableIds.isEmpty()) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "Refusing to send " + type + " command to group " + group + ": " + + nonCapableIds.size() + " agent(s) do not advertise requireSignedCommands=true " + + "and cameleer.server.security.enforce-signed-commands is enabled. " + + "Non-capable agents: " + nonCapableIds + ". Upgrade the agents or disable enforcement."); + } + String payloadJson = request.payload() != null ? objectMapper.writeValueAsString(request.payload()) : "{}"; Map> futures = @@ -190,6 +245,16 @@ public class AgentCommandController { .toList(); } + // E4: emit a single summary warning for the whole broadcast scope; per-agent warns + // would be too noisy in large fleets. Broadcast skips E5 enforcement — blocking an + // ops broadcast because one old agent is still in the fleet is too disruptive. + long nonCapableBroadcastCount = liveAgents.stream().filter(a -> !a.requireSignedCommands()).count(); + if (nonCapableBroadcastCount > 0) { + log.warn("Broadcast {} command targets {} live agent(s) of which {} do not advertise " + + "requireSignedCommands=true. They will accept but not verify the signature. Upgrade those agents.", + type, liveAgents.size(), nonCapableBroadcastCount); + } + List commandIds = new ArrayList<>(); for (AgentInfo agent : liveAgents) { AgentCommand command = registryService.addCommand(agent.instanceId(), type, payloadJson); diff --git a/cameleer-server-app/src/main/java/io/cameleer/server/app/dto/AgentInstanceResponse.java b/cameleer-server-app/src/main/java/io/cameleer/server/app/dto/AgentInstanceResponse.java index cbf8d0ec..db7b9c3a 100644 --- a/cameleer-server-app/src/main/java/io/cameleer/server/app/dto/AgentInstanceResponse.java +++ b/cameleer-server-app/src/main/java/io/cameleer/server/app/dto/AgentInstanceResponse.java @@ -26,7 +26,8 @@ public record AgentInstanceResponse( int activeRoutes, int totalRoutes, long uptimeSeconds, - @Schema(description = "Recent average CPU usage (0.0–1.0), -1 if unavailable") double cpuUsage + @Schema(description = "Recent average CPU usage (0.0–1.0), -1 if unavailable") double cpuUsage, + @Schema(description = "True if the agent advertises support for verifying signed SSE commands") boolean requireSignedCommands ) { public static AgentInstanceResponse from(AgentInfo info) { long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds(); @@ -38,7 +39,8 @@ public record AgentInstanceResponse( info.version(), info.capabilities(), 0.0, 0.0, 0, info.routeIds() != null ? info.routeIds().size() : 0, - uptime, -1 + uptime, -1, + info.requireSignedCommands() ); } @@ -47,7 +49,8 @@ public record AgentInstanceResponse( instanceId, displayName, applicationId, environmentId, status, routeIds, registeredAt, lastHeartbeat, version, capabilities, - tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds, cpuUsage + tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds, cpuUsage, + requireSignedCommands ); } @@ -56,7 +59,8 @@ public record AgentInstanceResponse( instanceId, displayName, applicationId, environmentId, status, routeIds, registeredAt, lastHeartbeat, version, capabilities, - tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds, cpuUsage + tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds, cpuUsage, + requireSignedCommands ); } } diff --git a/cameleer-server-app/src/main/java/io/cameleer/server/app/metrics/ServerMetrics.java b/cameleer-server-app/src/main/java/io/cameleer/server/app/metrics/ServerMetrics.java index 50356d31..a6cc827a 100644 --- a/cameleer-server-app/src/main/java/io/cameleer/server/app/metrics/ServerMetrics.java +++ b/cameleer-server-app/src/main/java/io/cameleer/server/app/metrics/ServerMetrics.java @@ -15,6 +15,7 @@ import io.micrometer.core.instrument.Timer; import org.springframework.stereotype.Component; import java.time.Duration; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @Component @@ -187,4 +188,30 @@ public class ServerMetrics { case "oidc_rejected" -> authFailuresOidc.increment(); } } + + // ── SSE signing failures ──────────────────────────────────────────── + + private final ConcurrentHashMap sseSigningFailureCounters = new ConcurrentHashMap<>(); + + public void recordSseSigningFailure(String commandType) { + sseSigningFailureCounters.computeIfAbsent(commandType, ct -> + Counter.builder("cameleer.sse.signing.failures") + .description("Count of SSE command signing failures, by command type") + .tag("command_type", ct) + .register(registry) + ).increment(); + } + + // ── Commands to non-capable agents ───────────────────────────────── + + private final ConcurrentHashMap nonCapableTargetCounters = new ConcurrentHashMap<>(); + + public void recordCommandToNonCapableAgent(String commandType) { + nonCapableTargetCounters.computeIfAbsent(commandType, ct -> + Counter.builder("cameleer.sse.commands.non_capable_target") + .description("Count of commands sent to agents that do not advertise requireSignedCommands=true") + .tag("command_type", ct) + .register(registry) + ).increment(); + } } diff --git a/cameleer-server-app/src/main/java/io/cameleer/server/app/security/SecurityProperties.java b/cameleer-server-app/src/main/java/io/cameleer/server/app/security/SecurityProperties.java index 39a3cec7..073576e3 100644 --- a/cameleer-server-app/src/main/java/io/cameleer/server/app/security/SecurityProperties.java +++ b/cameleer-server-app/src/main/java/io/cameleer/server/app/security/SecurityProperties.java @@ -22,6 +22,7 @@ public class SecurityProperties { private String uiOrigin; private String jwtSecret; private String corsAllowedOrigins; + private boolean enforceSignedCommands = false; private Oidc oidc = new Oidc(); public static class Oidc { @@ -58,6 +59,8 @@ public class SecurityProperties { public void setJwtSecret(String jwtSecret) { this.jwtSecret = jwtSecret; } public String getCorsAllowedOrigins() { return corsAllowedOrigins; } public void setCorsAllowedOrigins(String corsAllowedOrigins) { this.corsAllowedOrigins = corsAllowedOrigins; } + public boolean isEnforceSignedCommands() { return enforceSignedCommands; } + public void setEnforceSignedCommands(boolean enforceSignedCommands) { this.enforceSignedCommands = enforceSignedCommands; } public Oidc getOidc() { return oidc; } public void setOidc(Oidc oidc) { this.oidc = oidc; } diff --git a/cameleer-server-app/src/test/java/io/cameleer/server/app/agent/SseConnectionManagerSigningFailureIT.java b/cameleer-server-app/src/test/java/io/cameleer/server/app/agent/SseConnectionManagerSigningFailureIT.java new file mode 100644 index 00000000..8a501a15 --- /dev/null +++ b/cameleer-server-app/src/test/java/io/cameleer/server/app/agent/SseConnectionManagerSigningFailureIT.java @@ -0,0 +1,143 @@ +package io.cameleer.server.app.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.cameleer.server.app.config.AgentRegistryConfig; +import io.cameleer.server.app.metrics.ServerMetrics; +import io.cameleer.server.core.agent.AgentCommand; +import io.cameleer.server.core.agent.AgentRegistryService; +import io.cameleer.server.core.agent.CommandStatus; +import io.cameleer.server.core.agent.CommandType; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Instant; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit-style test for SseConnectionManager signing failure handling (E1). + *

+ * Verifies that when {@link SsePayloadSigner#signPayload} throws, the manager: + *

    + *
  • does NOT send any SSE event,
  • + *
  • does NOT call markDelivered,
  • + *
  • increments the {@code cameleer.sse.signing.failures} counter.
  • + *
+ */ +@ExtendWith(MockitoExtension.class) +class SseConnectionManagerSigningFailureIT { + + @Mock + private AgentRegistryService registryService; + + @Mock + private AgentRegistryConfig agentRegistryConfig; + + @Mock + private SsePayloadSigner ssePayloadSigner; + + @Mock + private ServerMetrics serverMetrics; + + private SseConnectionManager manager; + + @BeforeEach + void setUp() { + manager = new SseConnectionManager( + registryService, agentRegistryConfig, ssePayloadSigner, new ObjectMapper()); + // Inject via setter (mirrors production wiring — breaks circular dep with ServerMetrics) + manager.setServerMetrics(serverMetrics); + } + + @Test + void onCommandReady_whenSigningThrows_doesNotSendAndIncrementsCounter() { + // Arrange + AgentCommand command = new AgentCommand( + "cmd-fail-1", + CommandType.CONFIG_UPDATE, + "{\"key\":\"value\"}", + "agent-x", + Instant.now(), + CommandStatus.PENDING + ); + + when(ssePayloadSigner.signPayload(anyString())) + .thenThrow(new IllegalStateException("signing key not available")); + + // Act + manager.onCommandReady("agent-x", command); + + // Assert: no event sent (no emitter registered for agent-x anyway, but markDelivered must not be called) + verify(registryService, never()).markDelivered(anyString(), anyString()); + + // Assert: signing failure metric incremented with the correct command type tag + verify(serverMetrics).recordSseSigningFailure("config-update"); + } + + @Test + void onCommandReady_whenSigningThrowsIllegalArgument_doesNotDeliverAndCountsMetric() { + AgentCommand command = new AgentCommand( + "cmd-fail-2", + CommandType.DEEP_TRACE, + "{}", + "agent-y", + Instant.now(), + CommandStatus.PENDING + ); + + when(ssePayloadSigner.signPayload(anyString())) + .thenThrow(new IllegalArgumentException("null payload")); + + manager.onCommandReady("agent-y", command); + + verify(registryService, never()).markDelivered(anyString(), anyString()); + verify(serverMetrics).recordSseSigningFailure("deep-trace"); + } + + /** + * Smoke test with a real SimpleMeterRegistry to verify the counter actually registers. + */ + @Test + void recordSseSigningFailure_incrementsRealCounter() { + SimpleMeterRegistry realRegistry = new SimpleMeterRegistry(); + + // Build a minimal ServerMetrics using only the fields needed for lazy counters. + // We re-use the existing recordSseSigningFailure method via a partial-real approach: + // construct ServerMetrics with all its dependencies mocked, but use a real registry. + io.cameleer.server.core.ingestion.WriteBuffer execBuf = + org.mockito.Mockito.mock(io.cameleer.server.core.ingestion.WriteBuffer.class); + io.cameleer.server.core.ingestion.WriteBuffer procBuf = + org.mockito.Mockito.mock(io.cameleer.server.core.ingestion.WriteBuffer.class); + io.cameleer.server.core.ingestion.WriteBuffer logBuf = + org.mockito.Mockito.mock(io.cameleer.server.core.ingestion.WriteBuffer.class); + io.cameleer.server.core.ingestion.WriteBuffer metBuf = + org.mockito.Mockito.mock(io.cameleer.server.core.ingestion.WriteBuffer.class); + io.cameleer.server.core.ingestion.ChunkAccumulator accumulator = + org.mockito.Mockito.mock(io.cameleer.server.core.ingestion.ChunkAccumulator.class); + SseConnectionManager dummyManager = + org.mockito.Mockito.mock(SseConnectionManager.class); + + ServerMetrics realMetrics = new ServerMetrics(realRegistry, registryService, dummyManager, + execBuf, procBuf, logBuf, metBuf, accumulator); + + realMetrics.recordSseSigningFailure("route-control"); + realMetrics.recordSseSigningFailure("route-control"); + + Counter counter = realRegistry.find("cameleer.sse.signing.failures") + .tag("command_type", "route-control") + .counter(); + + assertThat(counter).isNotNull(); + assertThat(counter.count()).isEqualTo(2.0); + } +} diff --git a/cameleer-server-app/src/test/java/io/cameleer/server/app/controller/AgentCommandEnforcementIT.java b/cameleer-server-app/src/test/java/io/cameleer/server/app/controller/AgentCommandEnforcementIT.java new file mode 100644 index 00000000..706995f9 --- /dev/null +++ b/cameleer-server-app/src/test/java/io/cameleer/server/app/controller/AgentCommandEnforcementIT.java @@ -0,0 +1,137 @@ +package io.cameleer.server.app.controller; + +import io.cameleer.server.app.AbstractPostgresIT; +import io.cameleer.server.app.TestSecurityHelper; +import io.cameleer.server.core.agent.AgentRegistryService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +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.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.TestPropertySource; + +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for E4 (operator warning) and E5 (hard enforcement) of the SSE signing handoff. + *

+ * Two top-level classes share this file. Each spins up its own application context via + * {@code @TestPropertySource} to flip the {@code enforce-signed-commands} flag: + *

    + *
  • {@link EnforcementDisabled} — flag off (default): non-capable agent gets 202.
  • + *
  • {@link EnforcementEnabled} — flag on: non-capable gets 409, capable gets 202.
  • + *
+ */ +class AgentCommandEnforcementIT { + + // ── Flag OFF ───────────────────────────────────────────────────────────── + + @TestPropertySource(properties = "cameleer.server.security.enforce-signed-commands=false") + static class EnforcementDisabled extends AbstractPostgresIT { + + @Autowired private TestRestTemplate restTemplate; + @Autowired private ObjectMapper objectMapper; + @Autowired private TestSecurityHelper securityHelper; + @Autowired private AgentRegistryService registryService; + + private String operatorJwt; + + @BeforeEach + void setUp() { + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); + operatorJwt = securityHelper.operatorToken(); + } + + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + + @Test + void sendCommand_nonCapableAgent_returns202WhenEnforcementDisabled() throws Exception { + String agentId = "enforce-off-" + UUID.randomUUID().toString().substring(0, 8); + // requireSignedCommands=false (the default overload) + registryService.register(agentId, agentId, "test-app", "default", "1.0", List.of(), Map.of()); + + ResponseEntity response = sendConfigUpdate(agentId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.has("commandId")).isTrue(); + } + + private ResponseEntity sendConfigUpdate(String agentId) { + return restTemplate.postForEntity( + "/api/v1/agents/" + agentId + "/commands", + new HttpEntity<>(""" + {"type": "config-update", "payload": {"key": "value"}} + """, securityHelper.authHeaders(operatorJwt)), + String.class); + } + } + + // ── Flag ON ────────────────────────────────────────────────────────────── + + @TestPropertySource(properties = "cameleer.server.security.enforce-signed-commands=true") + static class EnforcementEnabled extends AbstractPostgresIT { + + @Autowired private TestRestTemplate restTemplate; + @Autowired private ObjectMapper objectMapper; + @Autowired private TestSecurityHelper securityHelper; + @Autowired private AgentRegistryService registryService; + + private String operatorJwt; + + @BeforeEach + void setUp() { + securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100)); + operatorJwt = securityHelper.operatorToken(); + } + + @AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + + @Test + void sendCommand_nonCapableAgent_returns409WhenEnforcementEnabled() { + String agentId = "enforce-on-noncap-" + UUID.randomUUID().toString().substring(0, 8); + registryService.register(agentId, agentId, "test-app", "default", "1.0", List.of(), Map.of()); + + ResponseEntity response = sendConfigUpdate(agentId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + } + + @Test + void sendCommand_capableAgent_returns202WhenEnforcementEnabled() throws Exception { + String agentId = "enforce-on-cap-" + UUID.randomUUID().toString().substring(0, 8); + // requireSignedCommands=true — agent has been upgraded to verify signatures + registryService.register(agentId, agentId, "test-app", "default", "2.0", List.of(), Map.of(), true); + + ResponseEntity response = sendConfigUpdate(agentId); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.has("commandId")).isTrue(); + } + + private ResponseEntity sendConfigUpdate(String agentId) { + return restTemplate.postForEntity( + "/api/v1/agents/" + agentId + "/commands", + new HttpEntity<>(""" + {"type": "config-update", "payload": {"key": "value"}} + """, securityHelper.authHeaders(operatorJwt)), + String.class); + } + } +}