api(config): ?apply=staged|live gates SSE push on PUT /apps/{slug}/config

When apply=staged, saves to DB only — no CONFIG_UPDATE dispatched to agents.
When apply=live (default, back-compat), preserves today's immediate-push behavior.
Unknown apply values return 400. Audit action is stage_app_config vs update_app_config.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-22 22:07:36 +02:00
parent 9b1240274d
commit 76129d407e
2 changed files with 201 additions and 7 deletions

View File

@@ -0,0 +1,177 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.model.ApplicationConfig;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.storage.PostgresApplicationConfigRepository;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.CommandType;
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.mock.mockito.SpyBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
class ApplicationConfigControllerIT extends AbstractPostgresIT {
/**
* Spy on the real AgentRegistryService bean so we can verify whether
* addGroupCommandWithReplies was invoked (live) or skipped (staged).
*/
@SpyBean
AgentRegistryService registryService;
@Autowired private TestRestTemplate restTemplate;
@Autowired private ObjectMapper objectMapper;
@Autowired private TestSecurityHelper securityHelper;
@Autowired private PostgresApplicationConfigRepository configRepository;
private String operatorJwt;
/** Unique env slug per test to avoid cross-test pollution. */
private String envSlug;
private UUID envId;
/** Unique app slug per test run to avoid cross-test row collisions. */
private String appSlug;
@BeforeEach
void setUp() {
operatorJwt = securityHelper.operatorToken();
envSlug = "cfg-it-" + UUID.randomUUID().toString().substring(0, 8);
envId = UUID.randomUUID();
appSlug = "paygw-" + UUID.randomUUID().toString().substring(0, 8);
jdbcTemplate.update(
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING",
envId, envSlug, envSlug);
}
@AfterEach
void cleanUp() {
jdbcTemplate.update("DELETE FROM application_config WHERE environment = ?", envSlug);
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
}
// ── helpers ──────────────────────────────────────────────────────────────
private void registerLiveAgent(String agentId) {
// Use the bootstrap HTTP endpoint — same pattern as AgentCommandControllerIT.
String body = """
{
"instanceId": "%s",
"applicationId": "%s",
"environmentId": "%s",
"version": "1.0.0",
"routeIds": ["route-1"],
"capabilities": {}
}
""".formatted(agentId, appSlug, envSlug);
restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(body, securityHelper.bootstrapHeaders()),
String.class);
}
private ResponseEntity<String> putConfig(String apply) {
String url = "/api/v1/environments/" + envSlug + "/apps/" + appSlug + "/config"
+ (apply != null ? "?apply=" + apply : "");
String body = """
{"samplingRate": 0.1, "metricsEnabled": true}
""";
return restTemplate.exchange(url, HttpMethod.PUT,
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)), String.class);
}
// ── tests ─────────────────────────────────────────────────────────────────
@Test
void putConfig_staged_savesButDoesNotPush() {
// Given — one LIVE agent registered for (appSlug, envSlug)
String agentId = "staged-agent-" + UUID.randomUUID().toString().substring(0, 8);
registerLiveAgent(agentId);
// When — PUT with apply=staged
ResponseEntity<String> response = putConfig("staged");
// Then — HTTP 200
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// And — DB has the new config
ApplicationConfig saved = configRepository
.findByApplicationAndEnvironment(appSlug, envSlug)
.orElseThrow(() -> new AssertionError("Config not found in DB"));
assertThat(saved.getSamplingRate()).isEqualTo(0.1);
// And — NO CONFIG_UPDATE was pushed to any agent
verify(registryService, never())
.addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any());
}
@Test
void putConfig_live_savesAndPushes() {
// Given — one LIVE agent registered for (appSlug, envSlug)
String agentId = "live-agent-" + UUID.randomUUID().toString().substring(0, 8);
registerLiveAgent(agentId);
// When — PUT without apply param (default is live)
ResponseEntity<String> response = putConfig(null);
// Then — HTTP 200
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
// And — DB has the new config
ApplicationConfig saved = configRepository
.findByApplicationAndEnvironment(appSlug, envSlug)
.orElseThrow(() -> new AssertionError("Config not found in DB"));
assertThat(saved.getSamplingRate()).isEqualTo(0.1);
// And — CONFIG_UPDATE was pushed (addGroupCommandWithReplies called once)
verify(registryService)
.addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any());
}
@Test
void putConfig_liveExplicit_savesAndPushes() {
// Same as above but with explicit apply=live
String agentId = "live-explicit-" + UUID.randomUUID().toString().substring(0, 8);
registerLiveAgent(agentId);
ResponseEntity<String> response = putConfig("live");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
verify(registryService)
.addGroupCommandWithReplies(eq(appSlug), eq(envSlug), eq(CommandType.CONFIG_UPDATE), any());
}
@Test
void putConfig_unknownApplyValue_returns400() {
ResponseEntity<String> response = putConfig("BOGUS");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void putConfig_staged_auditActionIsStagedAppConfig() throws Exception {
registerLiveAgent("audit-agent-" + UUID.randomUUID().toString().substring(0, 8));
ResponseEntity<String> response = putConfig("staged");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
// The response carries the saved config; no pushed agents
assertThat(body.path("pushResult").path("total").asInt()).isEqualTo(0);
}
}