test: add integration tests for runtime management API
- EnvironmentAdminControllerIT: CRUD, access control, default env protection - AppControllerIT: create, list, JAR upload, viewer access denied - DeploymentControllerIT: deploy, list, not-found handling - Fix bean name conflict: rename executor bean to deploymentTaskExecutor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -63,7 +63,7 @@ public class RuntimeBeanConfig {
|
||||
return new DeploymentService(deployRepo, appService, envService);
|
||||
}
|
||||
|
||||
@Bean(name = "deploymentExecutor")
|
||||
@Bean(name = "deploymentTaskExecutor")
|
||||
public Executor deploymentTaskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(4);
|
||||
|
||||
@@ -63,7 +63,7 @@ public class DeploymentExecutor {
|
||||
this.envService = envService;
|
||||
}
|
||||
|
||||
@Async("deploymentExecutor")
|
||||
@Async("deploymentTaskExecutor")
|
||||
public void executeAsync(Deployment deployment) {
|
||||
try {
|
||||
// Stop existing deployment in same environment for same app
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.AbstractPostgresIT;
|
||||
import com.cameleer3.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.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 org.springframework.core.io.ByteArrayResource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class AppControllerIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private TestSecurityHelper securityHelper;
|
||||
|
||||
private String operatorJwt;
|
||||
private String viewerJwt;
|
||||
private String defaultEnvId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
operatorJwt = securityHelper.operatorToken();
|
||||
viewerJwt = securityHelper.viewerToken();
|
||||
|
||||
// Clean up test apps
|
||||
jdbcTemplate.update("DELETE FROM apps");
|
||||
|
||||
// Get default environment ID
|
||||
ResponseEntity<String> envResponse = restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(securityHelper.adminToken())),
|
||||
String.class);
|
||||
JsonNode envs = objectMapper.readTree(envResponse.getBody());
|
||||
for (JsonNode env : envs) {
|
||||
if ("default".equals(env.path("slug").asText())) {
|
||||
defaultEnvId = env.path("id").asText();
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertThat(defaultEnvId).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createApp_asOperator_returns201() throws Exception {
|
||||
String json = String.format("""
|
||||
{"environmentId": "%s", "slug": "my-app", "displayName": "My App"}
|
||||
""", defaultEnvId);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/apps", HttpMethod.POST,
|
||||
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.path("slug").asText()).isEqualTo("my-app");
|
||||
assertThat(body.path("displayName").asText()).isEqualTo("My App");
|
||||
assertThat(body.path("environmentId").asText()).isEqualTo(defaultEnvId);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listApps_asOperator_returnsCreatedApp() throws Exception {
|
||||
// Create an app first
|
||||
String json = String.format("""
|
||||
{"environmentId": "%s", "slug": "list-test", "displayName": "List Test"}
|
||||
""", defaultEnvId);
|
||||
restTemplate.exchange(
|
||||
"/api/v1/apps", HttpMethod.POST,
|
||||
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
||||
String.class);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/apps?environmentId=" + defaultEnvId, HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.isArray()).isTrue();
|
||||
assertThat(body.size()).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createApp_asViewer_returns403() {
|
||||
String json = String.format("""
|
||||
{"environmentId": "%s", "slug": "viewer-app", "displayName": "Viewer App"}
|
||||
""", defaultEnvId);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/apps", HttpMethod.POST,
|
||||
new HttpEntity<>(json, securityHelper.authHeaders(viewerJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void uploadJar_asOperator_returns201() throws Exception {
|
||||
// Create app
|
||||
String json = String.format("""
|
||||
{"environmentId": "%s", "slug": "jar-test", "displayName": "JAR Test"}
|
||||
""", defaultEnvId);
|
||||
ResponseEntity<String> createResponse = restTemplate.exchange(
|
||||
"/api/v1/apps", HttpMethod.POST,
|
||||
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
||||
String.class);
|
||||
String appId = objectMapper.readTree(createResponse.getBody()).path("id").asText();
|
||||
|
||||
// Upload JAR (fake content)
|
||||
byte[] jarContent = "fake-jar-content".getBytes();
|
||||
ByteArrayResource resource = new ByteArrayResource(jarContent) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return "test-app.jar";
|
||||
}
|
||||
};
|
||||
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", resource);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + operatorJwt);
|
||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/apps/" + appId + "/versions", HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
JsonNode version = objectMapper.readTree(response.getBody());
|
||||
assertThat(version.path("version").asInt()).isEqualTo(1);
|
||||
assertThat(version.path("jarChecksum").asText()).isNotEmpty();
|
||||
assertThat(version.path("jarFilename").asText()).isEqualTo("test-app.jar");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.AbstractPostgresIT;
|
||||
import com.cameleer3.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.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 org.springframework.core.io.ByteArrayResource;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DeploymentControllerIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private TestSecurityHelper securityHelper;
|
||||
|
||||
private String operatorJwt;
|
||||
private String adminJwt;
|
||||
private String defaultEnvId;
|
||||
private String appId;
|
||||
private String versionId;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
operatorJwt = securityHelper.operatorToken();
|
||||
adminJwt = securityHelper.adminToken();
|
||||
|
||||
// Clean up
|
||||
jdbcTemplate.update("DELETE FROM deployments");
|
||||
jdbcTemplate.update("DELETE FROM app_versions");
|
||||
jdbcTemplate.update("DELETE FROM apps");
|
||||
|
||||
// Get default environment ID
|
||||
ResponseEntity<String> envResponse = restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||
String.class);
|
||||
JsonNode envs = objectMapper.readTree(envResponse.getBody());
|
||||
for (JsonNode env : envs) {
|
||||
if ("default".equals(env.path("slug").asText())) {
|
||||
defaultEnvId = env.path("id").asText();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Create app
|
||||
String appJson = String.format("""
|
||||
{"environmentId": "%s", "slug": "deploy-test", "displayName": "Deploy Test"}
|
||||
""", defaultEnvId);
|
||||
ResponseEntity<String> appResponse = restTemplate.exchange(
|
||||
"/api/v1/apps", HttpMethod.POST,
|
||||
new HttpEntity<>(appJson, securityHelper.authHeaders(operatorJwt)),
|
||||
String.class);
|
||||
appId = objectMapper.readTree(appResponse.getBody()).path("id").asText();
|
||||
|
||||
// Upload a JAR version
|
||||
byte[] jarContent = "fake-jar-for-deploy".getBytes();
|
||||
ByteArrayResource resource = new ByteArrayResource(jarContent) {
|
||||
@Override
|
||||
public String getFilename() {
|
||||
return "deploy-test.jar";
|
||||
}
|
||||
};
|
||||
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
||||
body.add("file", resource);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("Authorization", "Bearer " + operatorJwt);
|
||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||
ResponseEntity<String> versionResponse = restTemplate.exchange(
|
||||
"/api/v1/apps/" + appId + "/versions", HttpMethod.POST,
|
||||
new HttpEntity<>(body, headers),
|
||||
String.class);
|
||||
versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText();
|
||||
}
|
||||
|
||||
@Test
|
||||
void deploy_asOperator_returns202() throws Exception {
|
||||
// Deploy creates a record; the async executor will fail (no Docker) but the record should exist
|
||||
String json = String.format("""
|
||||
{"appVersionId": "%s", "environmentId": "%s"}
|
||||
""", versionId, defaultEnvId);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/apps/" + appId + "/deployments", HttpMethod.POST,
|
||||
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.ACCEPTED);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.path("status").asText()).isEqualTo("STARTING");
|
||||
assertThat(body.path("containerName").asText()).isEqualTo("default-deploy-test");
|
||||
assertThat(body.has("id")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listDeployments_asOperator_returnsDeployments() throws Exception {
|
||||
// Create a deployment first
|
||||
String json = String.format("""
|
||||
{"appVersionId": "%s", "environmentId": "%s"}
|
||||
""", versionId, defaultEnvId);
|
||||
restTemplate.exchange(
|
||||
"/api/v1/apps/" + appId + "/deployments", HttpMethod.POST,
|
||||
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
||||
String.class);
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/apps/" + appId + "/deployments", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.isArray()).isTrue();
|
||||
assertThat(body.size()).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getDeployment_notFound_returns404() {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/apps/" + appId + "/deployments/00000000-0000-0000-0000-000000000000",
|
||||
HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.AbstractPostgresIT;
|
||||
import com.cameleer3.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.http.HttpEntity;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class EnvironmentAdminControllerIT extends AbstractPostgresIT {
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private TestSecurityHelper securityHelper;
|
||||
|
||||
private String adminJwt;
|
||||
private String viewerJwt;
|
||||
private String operatorJwt;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
adminJwt = securityHelper.adminToken();
|
||||
viewerJwt = securityHelper.viewerToken();
|
||||
operatorJwt = securityHelper.operatorToken();
|
||||
// Clean up test environments (keep default)
|
||||
jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
|
||||
}
|
||||
|
||||
@Test
|
||||
void listEnvironments_asAdmin_returnsDefaultEnvironment() throws Exception {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.isArray()).isTrue();
|
||||
assertThat(body.size()).isGreaterThanOrEqualTo(1);
|
||||
// Default environment should exist
|
||||
boolean hasDefault = false;
|
||||
for (JsonNode env : body) {
|
||||
if ("default".equals(env.path("slug").asText())) {
|
||||
hasDefault = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertThat(hasDefault).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void listEnvironments_asViewer_returns403() {
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEnvironment_asAdmin_returns201() throws Exception {
|
||||
String json = """
|
||||
{"slug": "staging", "displayName": "Staging"}
|
||||
""";
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.POST,
|
||||
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
JsonNode body = objectMapper.readTree(response.getBody());
|
||||
assertThat(body.path("slug").asText()).isEqualTo("staging");
|
||||
assertThat(body.path("displayName").asText()).isEqualTo("Staging");
|
||||
assertThat(body.path("status").asText()).isEqualTo("ACTIVE");
|
||||
assertThat(body.has("id")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEnvironment_duplicateSlug_returns400() {
|
||||
String json = """
|
||||
{"slug": "dup-test", "displayName": "Dup Test"}
|
||||
""";
|
||||
|
||||
// First create should succeed
|
||||
restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.POST,
|
||||
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
// Second create with same slug should fail
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.POST,
|
||||
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteEnvironment_defaultEnv_returns400() throws Exception {
|
||||
// Find the default environment ID
|
||||
ResponseEntity<String> listResponse = restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.GET,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||
String.class);
|
||||
|
||||
JsonNode envs = objectMapper.readTree(listResponse.getBody());
|
||||
String defaultId = null;
|
||||
for (JsonNode env : envs) {
|
||||
if ("default".equals(env.path("slug").asText())) {
|
||||
defaultId = env.path("id").asText();
|
||||
break;
|
||||
}
|
||||
}
|
||||
assertThat(defaultId).isNotNull();
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/environments/" + defaultId, HttpMethod.DELETE,
|
||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createEnvironment_asOperator_returns403() {
|
||||
String json = """
|
||||
{"slug": "op-test", "displayName": "Op Test"}
|
||||
""";
|
||||
|
||||
ResponseEntity<String> response = restTemplate.exchange(
|
||||
"/api/v1/admin/environments", HttpMethod.POST,
|
||||
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
||||
String.class);
|
||||
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user