diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java index 104d83b2..790ca789 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/RuntimeBeanConfig.java @@ -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); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java index d6565f9f..8481e5a6 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/runtime/DeploymentExecutor.java @@ -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 diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AppControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AppControllerIT.java new file mode 100644 index 00000000..e29ddb57 --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/AppControllerIT.java @@ -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 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 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 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 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 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 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 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"); + } +} diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DeploymentControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DeploymentControllerIT.java new file mode 100644 index 00000000..1b0182ba --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/DeploymentControllerIT.java @@ -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 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 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 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 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 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 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 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); + } +} diff --git a/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/EnvironmentAdminControllerIT.java b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/EnvironmentAdminControllerIT.java new file mode 100644 index 00000000..7d2b9e1f --- /dev/null +++ b/cameleer3-server-app/src/test/java/com/cameleer3/server/app/controller/EnvironmentAdminControllerIT.java @@ -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 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 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 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 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 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 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 response = restTemplate.exchange( + "/api/v1/admin/environments", HttpMethod.POST, + new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } +}