test: add integration tests for runtime management API
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m40s
CI / docker (push) Successful in 4m11s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 37s

- 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:
hsiegeln
2026-04-07 23:52:07 +02:00
parent 3d20d7a0cb
commit 36e8b2d8ff
5 changed files with 455 additions and 2 deletions

View File

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

View File

@@ -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

View File

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

View File

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

View File

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