From 80dafe685b7bea53e1de30aa625d1d40eb056ebd Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:36:34 +0200 Subject: [PATCH] feat(license): enforce max_apps at AppService.createApp Adds CreateGuard hook to AppService.createApp using the same pattern as T18 (EnvironmentService). AppRepository.count() added; the bean wires LicenseEnforcer.assertWithinCap("max_apps", current, 1). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../server/app/config/RuntimeBeanConfig.java | 6 +- .../app/storage/PostgresAppRepository.java | 6 ++ .../app/license/AppCapEnforcementIT.java | 98 +++++++++++++++++++ .../server/core/runtime/AppRepository.java | 1 + .../server/core/runtime/AppService.java | 8 ++ 5 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 cameleer-server-app/src/test/java/com/cameleer/server/app/license/AppCapEnforcementIT.java diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java index 385dd625..a7fd5941 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/config/RuntimeBeanConfig.java @@ -58,8 +58,10 @@ public class RuntimeBeanConfig { @Bean public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo, - @Value("${cameleer.server.runtime.jarstoragepath:/data/jars}") String jarStoragePath) { - return new AppService(appRepo, versionRepo, jarStoragePath); + @Value("${cameleer.server.runtime.jarstoragepath:/data/jars}") String jarStoragePath, + com.cameleer.server.app.license.LicenseEnforcer enforcer) { + return new AppService(appRepo, versionRepo, jarStoragePath, + current -> enforcer.assertWithinCap("max_apps", current, 1)); } @Bean diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppRepository.java index cfaffdf6..3b45ae43 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppRepository.java @@ -70,6 +70,12 @@ public class PostgresAppRepository implements AppRepository { (rs, rowNum) -> mapRow(rs)); } + @Override + public long count() { + Long n = jdbc.queryForObject("SELECT COUNT(*) FROM apps", Long.class); + return n == null ? 0L : n; + } + @Override public void updateContainerConfig(UUID id, Map containerConfig) { try { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AppCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AppCapEnforcementIT.java new file mode 100644 index 00000000..46db41bd --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/AppCapEnforcementIT.java @@ -0,0 +1,98 @@ +package com.cameleer.server.app.license; + +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.AfterEach; +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; + +/** + * Verifies that the {@code max_apps} cap from the default tier is enforced at + * {@code POST /api/v1/environments/{envSlug}/apps}. Default tier {@code max_apps = 3}; with no + * license installed the gate is in {@link com.cameleer.server.core.license.LicenseState#ABSENT} + * and the defaults are authoritative. The fourth create attempt must be rejected with the + * structured 403 envelope produced by {@link LicenseExceptionAdvice}. + */ +class AppCapEnforcementIT extends AbstractPostgresIT { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TestSecurityHelper securityHelper; + + private String adminJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + // Defensive: a sibling IT may have left a license installed (LicenseGate is a singleton + // per Spring context; @SpringBootTest reuses contexts across ITs). + securityHelper.clearTestLicense(); + // Strip dependents first, then the apps themselves. Keep the seeded "default" environment. + jdbcTemplate.update("DELETE FROM deployments"); + jdbcTemplate.update("DELETE FROM app_versions"); + jdbcTemplate.update("DELETE FROM apps"); + } + + @AfterEach + void tearDown() { + // Defensive cleanup — we never installed a license, but make sure later ITs see ABSENT. + securityHelper.clearTestLicense(); + jdbcTemplate.update("DELETE FROM deployments"); + jdbcTemplate.update("DELETE FROM app_versions"); + jdbcTemplate.update("DELETE FROM apps"); + } + + @Test + void createBeyondCap_returns403WithStateAndMessage() throws Exception { + // Default tier: max_apps = 3. Three creates succeed; the fourth rejects. + for (int i = 1; i <= 3; i++) { + String json = String.format(""" + {"slug":"a%d","displayName":"A%d"} + """, i, i); + ResponseEntity ok = restTemplate.exchange( + "/api/v1/environments/default/apps", HttpMethod.POST, + new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)), + String.class); + assertThat(ok.getStatusCode()) + .as("create #%d should succeed", i) + .isEqualTo(HttpStatus.CREATED); + } + + String fourth = """ + {"slug":"a4","displayName":"A4"} + """; + ResponseEntity response = restTemplate.exchange( + "/api/v1/environments/default/apps", HttpMethod.POST, + new HttpEntity<>(fourth, securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + JsonNode body = objectMapper.readTree(response.getBody()); + assertThat(body.path("error").asText()).isEqualTo("license cap reached"); + assertThat(body.path("limit").asText()).isEqualTo("max_apps"); + assertThat(body.path("cap").asInt()).isEqualTo(3); + assertThat(body.path("state").asText()).isEqualTo("ABSENT"); + assertThat(body.has("message")).isTrue(); + assertThat(body.path("message").asText()).isNotBlank(); + + // And the fourth app was not persisted. + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM apps WHERE slug = 'a4'", Integer.class); + assertThat(count).isZero(); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppRepository.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppRepository.java index ec4ea778..c972ceec 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppRepository.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppRepository.java @@ -8,6 +8,7 @@ import java.util.UUID; public interface AppRepository { List findByEnvironmentId(UUID environmentId); List findAll(); + long count(); Optional findById(UUID id); Optional findByEnvironmentIdAndSlug(UUID environmentId, String slug); Optional findBySlug(String slug); diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java index 9a4c5a9b..884b844a 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/AppService.java @@ -23,11 +23,18 @@ public class AppService { private final AppRepository appRepo; private final AppVersionRepository versionRepo; private final String jarStoragePath; + private final CreateGuard createGuard; public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath) { + this(appRepo, versionRepo, jarStoragePath, CreateGuard.NOOP); + } + + public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath, + CreateGuard createGuard) { this.appRepo = appRepo; this.versionRepo = versionRepo; this.jarStoragePath = jarStoragePath; + this.createGuard = createGuard; } public List listAll() { return appRepo.findAll(); } @@ -55,6 +62,7 @@ public class AppService { throw new IllegalArgumentException( "Invalid app slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)"); } + createGuard.check(appRepo.count()); if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) { throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment"); }