|
|
|
|
@@ -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);
|
|
|
|
|
}
|
|
|
|
|
}
|