diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java index 1740df5d..e409d954 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/EnvironmentAdminController.java @@ -1,5 +1,6 @@ package com.cameleer.server.app.controller; +import com.cameleer.server.core.license.LicenseGate; import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.runtime.EnvironmentColor; import com.cameleer.server.core.runtime.EnvironmentService; @@ -7,9 +8,11 @@ import com.cameleer.server.core.runtime.RuntimeType; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import java.util.List; import java.util.Map; @@ -21,9 +24,11 @@ import java.util.Map; public class EnvironmentAdminController { private final EnvironmentService environmentService; + private final LicenseGate licenseGate; - public EnvironmentAdminController(EnvironmentService environmentService) { + public EnvironmentAdminController(EnvironmentService environmentService, LicenseGate licenseGate) { this.environmentService = environmentService; + this.licenseGate = licenseGate; } @GetMapping @@ -141,11 +146,24 @@ public class EnvironmentAdminController { @Operation(summary = "Update JAR retention policy for an environment") @ApiResponse(responseCode = "200", description = "Retention policy updated") @ApiResponse(responseCode = "404", description = "Environment not found") + @ApiResponse(responseCode = "422", description = "jarRetentionCount exceeds license cap") public ResponseEntity> updateJarRetention(@PathVariable String envSlug, @RequestBody JarRetentionRequest request) { try { Environment current = environmentService.getBySlug(envSlug); - environmentService.updateJarRetentionCount(current.id(), request.jarRetentionCount()); + // License cap check: only fires when a non-null value is supplied (null = unlimited). + // 422 (not 403) because this is a value-out-of-range, not a creation-quota rejection; + // therefore we do NOT route through LicenseEnforcer / LicenseExceptionAdvice. + Integer requested = request.jarRetentionCount(); + if (requested != null) { + int cap = licenseGate.getEffectiveLimits().get("max_jar_retention_count"); + if (requested > cap) { + throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, + "jarRetentionCount " + requested + " exceeds license cap " + + cap + " (max_jar_retention_count)"); + } + } + environmentService.updateJarRetentionCount(current.id(), requested); return ResponseEntity.ok(environmentService.getBySlug(envSlug)); } catch (IllegalArgumentException e) { if (e.getMessage().contains("not found")) { diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionCapEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionCapEnforcementIT.java new file mode 100644 index 00000000..ab9e95d5 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/RetentionCapEnforcementIT.java @@ -0,0 +1,112 @@ +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_jar_retention_count} cap is enforced at + * {@code PUT /api/v1/admin/environments/{envSlug}/jar-retention}. The default tier cap is 3, + * so no synthetic license is installed — the rejection is exercised against the baseline. + * + *
Returns 422 UNPROCESSABLE_ENTITY (not 403) because retention is a value-out-of-range
+ * rejection, not a creation-quota rejection — so {@code LicenseExceptionAdvice} is intentionally
+ * bypassed in favour of {@link org.springframework.web.server.ResponseStatusException}.
+ */
+class RetentionCapEnforcementIT 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.
+ securityHelper.clearTestLicense();
+ // Strip non-default envs (and FK dependents) so we land on a clean baseline.
+ jdbcTemplate.update("DELETE FROM deployments");
+ jdbcTemplate.update("DELETE FROM app_versions");
+ jdbcTemplate.update("DELETE FROM apps");
+ jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
+ }
+
+ @AfterEach
+ void tearDown() {
+ securityHelper.clearTestLicense();
+ 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 putJarRetention_aboveCap_returns422() throws Exception {
+ // Default tier cap = 3; request 30.
+ String body = """
+ {"jarRetentionCount": 30}
+ """;
+ ResponseEntity