From 47d56114624214f4c932a9498f2c13aac8276c28 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 23 Apr 2026 12:24:27 +0200 Subject: [PATCH] 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 --- .../app/controller/DeploymentController.java | 66 ++++- .../app/controller/AppDirtyStateIT.java | 4 + .../DeploymentControllerAuditIT.java | 252 ++++++++++++++++++ .../controller/DeploymentControllerIT.java | 4 + .../app/runtime/BlueGreenStrategyIT.java | 4 + .../app/runtime/DeploymentSnapshotIT.java | 4 + .../server/app/runtime/RollingStrategyIT.java | 4 + 7 files changed, 330 insertions(+), 8 deletions(-) create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java index be86e9ee..8354b358 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DeploymentController.java @@ -2,8 +2,13 @@ package com.cameleer.server.app.controller; import com.cameleer.server.app.runtime.DeploymentExecutor; 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.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.DeploymentService; 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.responses.ApiResponse; 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.security.access.prepost.PreAuthorize; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.Map; @@ -42,17 +51,23 @@ public class DeploymentController { private final RuntimeOrchestrator orchestrator; private final AppService appService; private final EnvironmentService environmentService; + private final AuditService auditService; + private final AppVersionRepository appVersionRepository; public DeploymentController(DeploymentService deploymentService, DeploymentExecutor deploymentExecutor, RuntimeOrchestrator orchestrator, AppService appService, - EnvironmentService environmentService) { + EnvironmentService environmentService, + AuditService auditService, + AppVersionRepository appVersionRepository) { this.deploymentService = deploymentService; this.deploymentExecutor = deploymentExecutor; this.orchestrator = orchestrator; this.appService = appService; this.environmentService = environmentService; + this.auditService = auditService; + this.appVersionRepository = appVersionRepository; } @GetMapping @@ -86,13 +101,25 @@ public class DeploymentController { @ApiResponse(responseCode = "202", description = "Deployment accepted and starting") public ResponseEntity deploy(@EnvPath Environment env, @PathVariable String appSlug, - @RequestBody DeployRequest request) { + @RequestBody DeployRequest request, + HttpServletRequest httpRequest) { try { 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); + 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); } 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(); } } @@ -103,12 +130,19 @@ public class DeploymentController { @ApiResponse(responseCode = "404", description = "Deployment not found") public ResponseEntity stop(@EnvPath Environment env, @PathVariable String appSlug, - @PathVariable UUID deploymentId) { + @PathVariable UUID deploymentId, + HttpServletRequest httpRequest) { try { Deployment deployment = deploymentService.getById(deploymentId); 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)); } 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(); } } @@ -122,18 +156,25 @@ public class DeploymentController { public ResponseEntity promote(@EnvPath Environment env, @PathVariable String appSlug, @PathVariable UUID deploymentId, - @RequestBody PromoteRequest request) { + @RequestBody PromoteRequest request, + HttpServletRequest httpRequest) { try { - App sourceApp = appService.getByEnvironmentAndSlug(env.id(), appSlug); Deployment source = deploymentService.getById(deploymentId); Environment targetEnv = environmentService.getBySlug(request.targetEnvironment()); // Target must also have the app with the same slug 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); + 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); } 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())); } } @@ -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 PromoteRequest(String targetEnvironment) {} } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AppDirtyStateIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AppDirtyStateIT.java index 0b188ef7..9b6c5892 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AppDirtyStateIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/AppDirtyStateIT.java @@ -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"); } // ----------------------------------------------------------------------- diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java new file mode 100644 index 00000000..196fdd87 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java @@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 queryAuditRow(String action) { + List> 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); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerIT.java index 6f792357..7293fca9 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerIT.java @@ -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 envResponse = restTemplate.exchange( "/api/v1/admin/environments", HttpMethod.GET, diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/BlueGreenStrategyIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/BlueGreenStrategyIT.java index 26605a42..874f663d 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/BlueGreenStrategyIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/BlueGreenStrategyIT.java @@ -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); diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java index 91d8c476..fd1df53d 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/DeploymentSnapshotIT.java @@ -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"); } // ----------------------------------------------------------------------- diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/RollingStrategyIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/RollingStrategyIT.java index e8cba532..c7012ccf 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/RollingStrategyIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/runtime/RollingStrategyIT.java @@ -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);