feat(audit): audit deploy/stop/promote with DEPLOYMENT category

Wires AuditService and AppVersionRepository into DeploymentController.
Replaces null createdBy placeholder with currentUserId() on createDeployment/promote.
Adds audit log entries (SUCCESS + FAILURE) for deploy_app, stop_deployment,
and promote_deployment actions. Fixes FK violations in affected ITs by
seeding the test-operator and alice users into the users table before deploy calls.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-23 12:24:27 +02:00
parent 9043dc00b0
commit 47d5611462
7 changed files with 330 additions and 8 deletions

View File

@@ -65,6 +65,10 @@ class AppDirtyStateIT extends AbstractPostgresIT {
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
// Ensure test-operator exists in users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
}
// -----------------------------------------------------------------------

View File

@@ -0,0 +1,252 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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.core.io.ByteArrayResource;
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 org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
class DeploymentControllerAuditIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
private String aliceJwt;
private String adminJwt;
private String appSlug;
private String versionId;
@BeforeEach
void setUp() throws Exception {
// Mint JWT for alice (OPERATOR) — subject must start with "user:" for JwtAuthenticationFilter
aliceJwt = securityHelper.createToken("user:alice", "user", List.of("OPERATOR"));
adminJwt = securityHelper.adminToken();
// Clean up deployment-related tables
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM audit_log");
// Ensure alice exists in the users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('alice', 'local', 'Alice Test') ON CONFLICT (user_id) DO NOTHING");
// Create app in the seeded "default" environment
appSlug = "audit-test-" + UUID.randomUUID().toString().substring(0, 8);
String appJson = String.format("""
{"slug": "%s", "displayName": "Audit Test App"}
""", appSlug);
ResponseEntity<String> appResponse = restTemplate.exchange(
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>(appJson, authHeaders(aliceJwt)),
String.class);
assertThat(appResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Upload a JAR version
byte[] jarContent = "fake-jar-for-audit-test".getBytes();
ByteArrayResource resource = new ByteArrayResource(jarContent) {
@Override
public String getFilename() {
return "audit-test.jar";
}
};
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource);
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + aliceJwt);
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
ResponseEntity<String> versionResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/versions", HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);
assertThat(versionResponse.getStatusCode().is2xxSuccessful()).isTrue();
versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText();
}
@Test
void deploy_writes_audit_row_with_DEPLOYMENT_category_and_alice_actor() throws Exception {
String json = String.format("""
{"appVersionId": "%s"}
""", versionId);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
new HttpEntity<>(json, authHeaders(aliceJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
Map<String, Object> row = queryAuditRow("deploy_app");
assertThat(row).isNotNull();
assertThat(row.get("username")).isEqualTo("alice");
assertThat(row.get("action")).isEqualTo("deploy_app");
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
assertThat(row.get("result")).isEqualTo("SUCCESS");
assertThat(row.get("target")).isNotNull();
assertThat(row.get("target").toString()).isNotBlank();
}
@Test
void stop_writes_audit_row() throws Exception {
// First deploy
String deployJson = String.format("""
{"appVersionId": "%s"}
""", versionId);
ResponseEntity<String> deployResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
new HttpEntity<>(deployJson, authHeaders(aliceJwt)),
String.class);
assertThat(deployResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
String deploymentId = objectMapper.readTree(deployResponse.getBody()).path("id").asText();
// Clear audit log to isolate stop audit row
jdbcTemplate.update("DELETE FROM audit_log");
// Stop the deployment
ResponseEntity<String> stopResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments/" + deploymentId + "/stop",
HttpMethod.POST,
new HttpEntity<>(authHeadersNoBody(aliceJwt)),
String.class);
assertThat(stopResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
Map<String, Object> row = queryAuditRow("stop_deployment");
assertThat(row).isNotNull();
assertThat(row.get("username")).isEqualTo("alice");
assertThat(row.get("action")).isEqualTo("stop_deployment");
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
assertThat(row.get("result")).isEqualTo("SUCCESS");
assertThat(row.get("target").toString()).isEqualTo(deploymentId);
}
@Test
void promote_writes_audit_row() throws Exception {
// Create a second environment for promotion target
String targetEnvSlug = "promote-target-" + UUID.randomUUID().toString().substring(0, 8);
String envJson = String.format("""
{"slug": "%s", "displayName": "Promote Target Env"}
""", targetEnvSlug);
ResponseEntity<String> envResponse = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(envJson, authHeaders(adminJwt)),
String.class);
assertThat(envResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Create the same app slug in the target environment
String appJson = String.format("""
{"slug": "%s", "displayName": "Audit Test App (target)"}
""", appSlug);
ResponseEntity<String> targetAppResponse = restTemplate.exchange(
"/api/v1/environments/" + targetEnvSlug + "/apps", HttpMethod.POST,
new HttpEntity<>(appJson, authHeaders(aliceJwt)),
String.class);
assertThat(targetAppResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Deploy in source (default) env
String deployJson = String.format("""
{"appVersionId": "%s"}
""", versionId);
ResponseEntity<String> deployResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
new HttpEntity<>(deployJson, authHeaders(aliceJwt)),
String.class);
assertThat(deployResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
String deploymentId = objectMapper.readTree(deployResponse.getBody()).path("id").asText();
// Clear audit log to isolate promote audit row
jdbcTemplate.update("DELETE FROM audit_log");
// Promote to target env
String promoteJson = String.format("""
{"targetEnvironment": "%s"}
""", targetEnvSlug);
ResponseEntity<String> promoteResponse = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments/" + deploymentId + "/promote",
HttpMethod.POST,
new HttpEntity<>(promoteJson, authHeaders(aliceJwt)),
String.class);
assertThat(promoteResponse.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
Map<String, Object> row = queryAuditRow("promote_deployment");
assertThat(row).isNotNull();
assertThat(row.get("username")).isEqualTo("alice");
assertThat(row.get("action")).isEqualTo("promote_deployment");
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
assertThat(row.get("result")).isEqualTo("SUCCESS");
assertThat(row.get("target")).isNotNull();
assertThat(row.get("target").toString()).isNotBlank();
}
@Test
void deploy_with_unknown_appVersion_writes_FAILURE_audit_row() throws Exception {
String unknownVersionId = UUID.randomUUID().toString();
String json = String.format("""
{"appVersionId": "%s"}
""", unknownVersionId);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/environments/default/apps/" + appSlug + "/deployments", HttpMethod.POST,
new HttpEntity<>(json, authHeaders(aliceJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
Map<String, Object> row = queryAuditRow("deploy_app");
assertThat(row).isNotNull();
assertThat(row.get("username")).isEqualTo("alice");
assertThat(row.get("action")).isEqualTo("deploy_app");
assertThat(row.get("category")).isEqualTo("DEPLOYMENT");
assertThat(row.get("result")).isEqualTo("FAILURE");
}
// ---- helpers ----
private 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;
}
private HttpHeaders authHeadersNoBody(String jwt) {
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + jwt);
headers.set("X-Cameleer-Protocol-Version", "1");
return headers;
}
/** Query the most recent audit_log row for the given action. Returns null if not found. */
private Map<String, Object> queryAuditRow(String action) {
List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT username, action, category, target, result FROM audit_log WHERE action = ? ORDER BY timestamp DESC LIMIT 1",
action);
return rows.isEmpty() ? null : rows.get(0);
}
}

View File

@@ -48,6 +48,10 @@ class DeploymentControllerIT extends AbstractPostgresIT {
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
// Ensure test-operator exists in users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
// Get default environment ID
ResponseEntity<String> envResponse = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.GET,

View File

@@ -62,6 +62,10 @@ class BlueGreenStrategyIT extends AbstractPostgresIT {
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
// Ensure test-operator exists in users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
appSlug = "bg-" + UUID.randomUUID().toString().substring(0, 8);

View File

@@ -69,6 +69,10 @@ class DeploymentSnapshotIT extends AbstractPostgresIT {
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
// Ensure test-operator exists in users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
}
// -----------------------------------------------------------------------

View File

@@ -65,6 +65,10 @@ class RollingStrategyIT extends AbstractPostgresIT {
jdbcTemplate.update("DELETE FROM apps");
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
// Ensure test-operator exists in users table (required for deployments.created_by FK)
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, display_name) VALUES ('test-operator', 'local', 'Test Operator') ON CONFLICT (user_id) DO NOTHING");
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
appSlug = "roll-" + UUID.randomUUID().toString().substring(0, 8);