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:
@@ -2,8 +2,13 @@ package com.cameleer.server.app.controller;
|
|||||||
|
|
||||||
import com.cameleer.server.app.runtime.DeploymentExecutor;
|
import com.cameleer.server.app.runtime.DeploymentExecutor;
|
||||||
import com.cameleer.server.app.web.EnvPath;
|
import com.cameleer.server.app.web.EnvPath;
|
||||||
|
import com.cameleer.server.core.admin.AuditCategory;
|
||||||
|
import com.cameleer.server.core.admin.AuditResult;
|
||||||
|
import com.cameleer.server.core.admin.AuditService;
|
||||||
import com.cameleer.server.core.runtime.App;
|
import com.cameleer.server.core.runtime.App;
|
||||||
import com.cameleer.server.core.runtime.AppService;
|
import com.cameleer.server.core.runtime.AppService;
|
||||||
|
import com.cameleer.server.core.runtime.AppVersion;
|
||||||
|
import com.cameleer.server.core.runtime.AppVersionRepository;
|
||||||
import com.cameleer.server.core.runtime.Deployment;
|
import com.cameleer.server.core.runtime.Deployment;
|
||||||
import com.cameleer.server.core.runtime.DeploymentService;
|
import com.cameleer.server.core.runtime.DeploymentService;
|
||||||
import com.cameleer.server.core.runtime.Environment;
|
import com.cameleer.server.core.runtime.Environment;
|
||||||
@@ -12,14 +17,18 @@ import com.cameleer.server.core.runtime.RuntimeOrchestrator;
|
|||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -42,17 +51,23 @@ public class DeploymentController {
|
|||||||
private final RuntimeOrchestrator orchestrator;
|
private final RuntimeOrchestrator orchestrator;
|
||||||
private final AppService appService;
|
private final AppService appService;
|
||||||
private final EnvironmentService environmentService;
|
private final EnvironmentService environmentService;
|
||||||
|
private final AuditService auditService;
|
||||||
|
private final AppVersionRepository appVersionRepository;
|
||||||
|
|
||||||
public DeploymentController(DeploymentService deploymentService,
|
public DeploymentController(DeploymentService deploymentService,
|
||||||
DeploymentExecutor deploymentExecutor,
|
DeploymentExecutor deploymentExecutor,
|
||||||
RuntimeOrchestrator orchestrator,
|
RuntimeOrchestrator orchestrator,
|
||||||
AppService appService,
|
AppService appService,
|
||||||
EnvironmentService environmentService) {
|
EnvironmentService environmentService,
|
||||||
|
AuditService auditService,
|
||||||
|
AppVersionRepository appVersionRepository) {
|
||||||
this.deploymentService = deploymentService;
|
this.deploymentService = deploymentService;
|
||||||
this.deploymentExecutor = deploymentExecutor;
|
this.deploymentExecutor = deploymentExecutor;
|
||||||
this.orchestrator = orchestrator;
|
this.orchestrator = orchestrator;
|
||||||
this.appService = appService;
|
this.appService = appService;
|
||||||
this.environmentService = environmentService;
|
this.environmentService = environmentService;
|
||||||
|
this.auditService = auditService;
|
||||||
|
this.appVersionRepository = appVersionRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -86,13 +101,25 @@ public class DeploymentController {
|
|||||||
@ApiResponse(responseCode = "202", description = "Deployment accepted and starting")
|
@ApiResponse(responseCode = "202", description = "Deployment accepted and starting")
|
||||||
public ResponseEntity<Deployment> deploy(@EnvPath Environment env,
|
public ResponseEntity<Deployment> deploy(@EnvPath Environment env,
|
||||||
@PathVariable String appSlug,
|
@PathVariable String appSlug,
|
||||||
@RequestBody DeployRequest request) {
|
@RequestBody DeployRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
try {
|
try {
|
||||||
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||||
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id(), null);
|
AppVersion appVersion = appVersionRepository.findById(request.appVersionId())
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("AppVersion not found: " + request.appVersionId()));
|
||||||
|
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id(), currentUserId());
|
||||||
deploymentExecutor.executeAsync(deployment);
|
deploymentExecutor.executeAsync(deployment);
|
||||||
|
auditService.log("deploy_app", AuditCategory.DEPLOYMENT, deployment.id().toString(),
|
||||||
|
Map.of("appSlug", appSlug, "envSlug", env.slug(),
|
||||||
|
"appVersionId", request.appVersionId().toString(),
|
||||||
|
"jarFilename", appVersion.jarFilename() != null ? appVersion.jarFilename() : "",
|
||||||
|
"version", appVersion.version()),
|
||||||
|
AuditResult.SUCCESS, httpRequest);
|
||||||
return ResponseEntity.accepted().body(deployment);
|
return ResponseEntity.accepted().body(deployment);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
|
auditService.log("deploy_app", AuditCategory.DEPLOYMENT, null,
|
||||||
|
Map.of("appSlug", appSlug, "envSlug", env.slug(), "error", e.getMessage()),
|
||||||
|
AuditResult.FAILURE, httpRequest);
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,12 +130,19 @@ public class DeploymentController {
|
|||||||
@ApiResponse(responseCode = "404", description = "Deployment not found")
|
@ApiResponse(responseCode = "404", description = "Deployment not found")
|
||||||
public ResponseEntity<Deployment> stop(@EnvPath Environment env,
|
public ResponseEntity<Deployment> stop(@EnvPath Environment env,
|
||||||
@PathVariable String appSlug,
|
@PathVariable String appSlug,
|
||||||
@PathVariable UUID deploymentId) {
|
@PathVariable UUID deploymentId,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
try {
|
try {
|
||||||
Deployment deployment = deploymentService.getById(deploymentId);
|
Deployment deployment = deploymentService.getById(deploymentId);
|
||||||
deploymentExecutor.stopDeployment(deployment);
|
deploymentExecutor.stopDeployment(deployment);
|
||||||
|
auditService.log("stop_deployment", AuditCategory.DEPLOYMENT, deploymentId.toString(),
|
||||||
|
Map.of("appSlug", appSlug, "envSlug", env.slug()),
|
||||||
|
AuditResult.SUCCESS, httpRequest);
|
||||||
return ResponseEntity.ok(deploymentService.getById(deploymentId));
|
return ResponseEntity.ok(deploymentService.getById(deploymentId));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
|
auditService.log("stop_deployment", AuditCategory.DEPLOYMENT, deploymentId.toString(),
|
||||||
|
Map.of("appSlug", appSlug, "envSlug", env.slug(), "error", e.getMessage()),
|
||||||
|
AuditResult.FAILURE, httpRequest);
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,18 +156,25 @@ public class DeploymentController {
|
|||||||
public ResponseEntity<?> promote(@EnvPath Environment env,
|
public ResponseEntity<?> promote(@EnvPath Environment env,
|
||||||
@PathVariable String appSlug,
|
@PathVariable String appSlug,
|
||||||
@PathVariable UUID deploymentId,
|
@PathVariable UUID deploymentId,
|
||||||
@RequestBody PromoteRequest request) {
|
@RequestBody PromoteRequest request,
|
||||||
|
HttpServletRequest httpRequest) {
|
||||||
try {
|
try {
|
||||||
App sourceApp = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
|
||||||
Deployment source = deploymentService.getById(deploymentId);
|
Deployment source = deploymentService.getById(deploymentId);
|
||||||
Environment targetEnv = environmentService.getBySlug(request.targetEnvironment());
|
Environment targetEnv = environmentService.getBySlug(request.targetEnvironment());
|
||||||
// Target must also have the app with the same slug
|
// Target must also have the app with the same slug
|
||||||
App targetApp = appService.getByEnvironmentAndSlug(targetEnv.id(), appSlug);
|
App targetApp = appService.getByEnvironmentAndSlug(targetEnv.id(), appSlug);
|
||||||
Deployment promoted = deploymentService.promote(targetApp.id(), source.appVersionId(), targetEnv.id(), null);
|
Deployment promoted = deploymentService.promote(targetApp.id(), source.appVersionId(), targetEnv.id(), currentUserId());
|
||||||
deploymentExecutor.executeAsync(promoted);
|
deploymentExecutor.executeAsync(promoted);
|
||||||
|
auditService.log("promote_deployment", AuditCategory.DEPLOYMENT, promoted.id().toString(),
|
||||||
|
Map.of("sourceEnv", env.slug(), "targetEnv", request.targetEnvironment(),
|
||||||
|
"appSlug", appSlug, "appVersionId", source.appVersionId().toString()),
|
||||||
|
AuditResult.SUCCESS, httpRequest);
|
||||||
return ResponseEntity.accepted().body(promoted);
|
return ResponseEntity.accepted().body(promoted);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.status(org.springframework.http.HttpStatus.NOT_FOUND)
|
auditService.log("promote_deployment", AuditCategory.DEPLOYMENT, deploymentId.toString(),
|
||||||
|
Map.of("appSlug", appSlug, "error", e.getMessage()),
|
||||||
|
AuditResult.FAILURE, httpRequest);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
.body(Map.of("error", e.getMessage()));
|
.body(Map.of("error", e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +198,15 @@ public class DeploymentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String currentUserId() {
|
||||||
|
var auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (auth == null || auth.getName() == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "No authentication");
|
||||||
|
}
|
||||||
|
String name = auth.getName();
|
||||||
|
return name.startsWith("user:") ? name.substring(5) : name;
|
||||||
|
}
|
||||||
|
|
||||||
public record DeployRequest(UUID appVersionId) {}
|
public record DeployRequest(UUID appVersionId) {}
|
||||||
public record PromoteRequest(String targetEnvironment) {}
|
public record PromoteRequest(String targetEnvironment) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ class AppDirtyStateIT extends AbstractPostgresIT {
|
|||||||
jdbcTemplate.update("DELETE FROM app_versions");
|
jdbcTemplate.update("DELETE FROM app_versions");
|
||||||
jdbcTemplate.update("DELETE FROM apps");
|
jdbcTemplate.update("DELETE FROM apps");
|
||||||
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,10 @@ class DeploymentControllerIT extends AbstractPostgresIT {
|
|||||||
jdbcTemplate.update("DELETE FROM app_versions");
|
jdbcTemplate.update("DELETE FROM app_versions");
|
||||||
jdbcTemplate.update("DELETE FROM apps");
|
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
|
// Get default environment ID
|
||||||
ResponseEntity<String> envResponse = restTemplate.exchange(
|
ResponseEntity<String> envResponse = restTemplate.exchange(
|
||||||
"/api/v1/admin/environments", HttpMethod.GET,
|
"/api/v1/admin/environments", HttpMethod.GET,
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ class BlueGreenStrategyIT extends AbstractPostgresIT {
|
|||||||
jdbcTemplate.update("DELETE FROM apps");
|
jdbcTemplate.update("DELETE FROM apps");
|
||||||
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
|
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);
|
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
|
||||||
|
|
||||||
appSlug = "bg-" + UUID.randomUUID().toString().substring(0, 8);
|
appSlug = "bg-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ class DeploymentSnapshotIT extends AbstractPostgresIT {
|
|||||||
jdbcTemplate.update("DELETE FROM app_versions");
|
jdbcTemplate.update("DELETE FROM app_versions");
|
||||||
jdbcTemplate.update("DELETE FROM apps");
|
jdbcTemplate.update("DELETE FROM apps");
|
||||||
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
|
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");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -65,6 +65,10 @@ class RollingStrategyIT extends AbstractPostgresIT {
|
|||||||
jdbcTemplate.update("DELETE FROM apps");
|
jdbcTemplate.update("DELETE FROM apps");
|
||||||
jdbcTemplate.update("DELETE FROM application_config WHERE environment = 'default'");
|
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);
|
when(runtimeOrchestrator.isEnabled()).thenReturn(true);
|
||||||
|
|
||||||
appSlug = "roll-" + UUID.randomUUID().toString().substring(0, 8);
|
appSlug = "roll-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
|||||||
Reference in New Issue
Block a user