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 ca78caba..385dd625 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 @@ -50,8 +50,10 @@ public class RuntimeBeanConfig { } @Bean - public EnvironmentService environmentService(EnvironmentRepository repo) { - return new EnvironmentService(repo); + public EnvironmentService environmentService(EnvironmentRepository repo, + com.cameleer.server.app.license.LicenseEnforcer enforcer) { + return new EnvironmentService(repo, current -> + enforcer.assertWithinCap("max_environments", current, 1)); } @Bean diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java index 32376fcf..8d8e550d 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresEnvironmentRepository.java @@ -35,6 +35,11 @@ public class PostgresEnvironmentRepository implements EnvironmentRepository { (rs, rowNum) -> mapRow(rs)); } + @Override + public long count() { + return jdbc.queryForObject("SELECT COUNT(*) FROM environments", Long.class); + } + @Override public Optional findById(UUID id) { var results = jdbc.query( diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java index d24d5945..fc3ac197 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/TestSecurityHelper.java @@ -1,13 +1,19 @@ package com.cameleer.server.app; import com.cameleer.server.core.agent.AgentRegistryService; +import com.cameleer.server.core.license.LicenseGate; +import com.cameleer.server.core.license.LicenseInfo; import com.cameleer.server.core.security.JwtService; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; +import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; +import java.util.UUID; /** * Test utility for creating JWT-authenticated requests in integration tests. @@ -20,10 +26,39 @@ public class TestSecurityHelper { private final JwtService jwtService; private final AgentRegistryService agentRegistryService; + private final LicenseGate licenseGate; - public TestSecurityHelper(JwtService jwtService, AgentRegistryService agentRegistryService) { + @Autowired + public TestSecurityHelper(JwtService jwtService, + AgentRegistryService agentRegistryService, + LicenseGate licenseGate) { this.jwtService = jwtService; this.agentRegistryService = agentRegistryService; + this.licenseGate = licenseGate; + } + + /** + * Loads a synthetic, signature-bypassing license into {@link LicenseGate} so the test can + * exercise paths that would otherwise be rejected by default-tier caps. The license is + * always-ACTIVE (1 day from now, no grace) and limits are merged over defaults — only + * supply the keys you want to lift. Use this from {@code @BeforeEach} in ITs that need to + * create more than the default-tier allowance of envs/apps/users/etc. + */ + public void installTestLicenseWithCaps(Map caps) { + LicenseInfo info = new LicenseInfo( + UUID.randomUUID(), + "default", + "test-license", + Map.copyOf(caps), + Instant.now(), + Instant.now().plus(1, ChronoUnit.DAYS), + 0); + licenseGate.load(info); + } + + /** Clears any test license previously installed via {@link #installTestLicenseWithCaps}. */ + public void clearTestLicense() { + licenseGate.clear(); } /** diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java index 16298486..a488e269 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/DeploymentControllerAuditIT.java @@ -46,6 +46,11 @@ class DeploymentControllerAuditIT extends AbstractPostgresIT { aliceJwt = securityHelper.createToken("user:alice", "user", List.of("OPERATOR")); adminJwt = securityHelper.adminToken(); + // Lift default-tier caps so the promote-target env + apps can be created via the API. + securityHelper.installTestLicenseWithCaps(Map.of( + "max_environments", 100, + "max_apps", 100)); + // Clean up deployment-related tables and test-created environments jdbcTemplate.update("DELETE FROM deployments"); jdbcTemplate.update("DELETE FROM app_versions"); @@ -90,6 +95,11 @@ class DeploymentControllerAuditIT extends AbstractPostgresIT { versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText(); } + @org.junit.jupiter.api.AfterEach + void tearDown() { + securityHelper.clearTestLicense(); + } + @Test void deploy_writes_audit_row_with_DEPLOYMENT_category_and_alice_actor() throws Exception { String json = String.format(""" diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java index b20eb8ab..527bf90d 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/controller/EnvironmentAdminControllerIT.java @@ -35,8 +35,21 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT { adminJwt = securityHelper.adminToken(); viewerJwt = securityHelper.viewerToken(); operatorJwt = securityHelper.operatorToken(); - // Clean up test environments (keep default) + // Clean up test environments (keep default). Strip dependents first — sibling ITs + // (e.g., DeploymentControllerAuditIT) may have left deployments/apps that FK back to + // their non-default envs when the testcontainer is reused across runs. + jdbcTemplate.update("DELETE FROM deployments"); + jdbcTemplate.update("DELETE FROM app_versions"); + jdbcTemplate.update("DELETE FROM apps"); jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'"); + // Lift max_environments cap so existing IT scenarios that POST envs through the + // controller succeed; the cap itself is exercised by EnvironmentCapEnforcementIT. + securityHelper.installTestLicenseWithCaps(java.util.Map.of("max_environments", 100)); + } + + @org.junit.jupiter.api.AfterEach + void tearDown() { + securityHelper.clearTestLicense(); } @Test diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/EnvironmentCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/EnvironmentCapEnforcementIT.java new file mode 100644 index 00000000..8965c3ea --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/EnvironmentCapEnforcementIT.java @@ -0,0 +1,79 @@ +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.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_environments} cap from the default tier is enforced at + * {@code POST /api/v1/admin/environments}. Default tier {@code max_environments = 1}; the V1 + * baseline migration seeds a single {@code default} environment, so the very next create + * attempt must be rejected with the structured 403 envelope produced by + * {@link LicenseExceptionAdvice}. + */ +class EnvironmentCapEnforcementIT extends AbstractPostgresIT { + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private TestSecurityHelper securityHelper; + + private String adminJwt; + + @BeforeEach + void setUp() { + adminJwt = securityHelper.adminToken(); + // Defensive: clear any license a previous IT may have left installed (each IT gets its own + // Spring context only on first @SpringBootTest reuse boundary; LicenseGate is a singleton). + securityHelper.clearTestLicense(); + // Ensure starting state: only the seeded "default" env (count = 1, equals the cap). + // Strip dependents first — sibling ITs may have left deployments/apps that FK back to + // non-default envs when the testcontainer is reused across runs. + jdbcTemplate.update("DELETE FROM deployments"); + jdbcTemplate.update("DELETE FROM app_versions"); + jdbcTemplate.update("DELETE FROM apps"); + jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'"); + } + + @Test + void createBeyondCap_returns403WithStateAndMessage() throws Exception { + // Default tier: max_environments = 1; V1 seeds the default env, so the next create rejects. + String json = """ + {"slug":"prod","displayName":"Prod","production":true} + """; + + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/environments", HttpMethod.POST, + new HttpEntity<>(json, 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_environments"); + assertThat(body.path("cap").asInt()).isEqualTo(1); + assertThat(body.path("state").asText()).isEqualTo("ABSENT"); + assertThat(body.has("message")).isTrue(); + assertThat(body.path("message").asText()).isNotBlank(); + + // And the env was not created. + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM environments WHERE slug = 'prod'", Integer.class); + assertThat(count).isZero(); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java index 896c8a84..c6131c13 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseUsageReaderIT.java @@ -1,6 +1,7 @@ package com.cameleer.server.app.license; import com.cameleer.server.app.AbstractPostgresIT; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -10,6 +11,16 @@ class LicenseUsageReaderIT extends AbstractPostgresIT { @Autowired LicenseUsageReader reader; + @BeforeEach + void cleanDb() { + // Defensive cleanup so the test is order-independent under Testcontainer reuse — sibling + // ITs may have left envs/apps that would otherwise inflate the snapshot counts. + jdbcTemplate.update("DELETE FROM deployments"); + jdbcTemplate.update("DELETE FROM app_versions"); + jdbcTemplate.update("DELETE FROM apps"); + jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'"); + } + @Test void emptyDb_returnsZeros() { var snap = reader.snapshot(); diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/CreateGuard.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/CreateGuard.java new file mode 100644 index 00000000..470d51a8 --- /dev/null +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/CreateGuard.java @@ -0,0 +1,19 @@ +package com.cameleer.server.core.runtime; + +/** + * Hook called by domain services before creating a new entity. Implementations enforce + * environment-level policy (e.g., license caps) without dragging Spring or app-module types + * into core. + * + *

The guard is consulted with the current count; implementations throw to abort creation. + * The {@link #NOOP} singleton is the default for tests and for boot configurations that haven't + * wired enforcement yet.

+ */ +@FunctionalInterface +public interface CreateGuard { + + /** Throw to reject creation; otherwise return normally. */ + void check(long current); + + CreateGuard NOOP = c -> { }; +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentRepository.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentRepository.java index e1157005..54ee6fe7 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentRepository.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentRepository.java @@ -7,6 +7,7 @@ import java.util.UUID; public interface EnvironmentRepository { List findAll(); + long count(); Optional findById(UUID id); Optional findBySlug(String slug); UUID create(String slug, String displayName, boolean production); diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java index 4941ab70..650e1d26 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/runtime/EnvironmentService.java @@ -17,9 +17,15 @@ public class EnvironmentService { private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$"); private final EnvironmentRepository repo; + private final CreateGuard createGuard; public EnvironmentService(EnvironmentRepository repo) { + this(repo, CreateGuard.NOOP); + } + + public EnvironmentService(EnvironmentRepository repo, CreateGuard createGuard) { this.repo = repo; + this.createGuard = createGuard; } public List listAll() { return repo.findAll(); } @@ -37,6 +43,7 @@ public class EnvironmentService { throw new IllegalArgumentException( "Invalid slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)"); } + createGuard.check(repo.count()); if (repo.findBySlug(slug).isPresent()) { throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists"); }