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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user