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 response = restTemplate.exchange( + "/api/v1/admin/environments/default/jar-retention", HttpMethod.PUT, + new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY); + // ResponseStatusException renders into Spring Boot's default error JSON; the reason + // appears in the "message" field (or the body somewhere). Don't pin to a specific shape; + // just verify the diagnostic text reached the wire. + String responseBody = response.getBody() == null ? "" : response.getBody(); + assertThat(responseBody) + .containsAnyOf("max_jar_retention_count", "license cap"); + } + + @Test + void putJarRetention_atCap_returns200() throws Exception { + // Default tier cap = 3; request exactly 3. + String body = """ + {"jarRetentionCount": 3} + """; + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/environments/default/jar-retention", HttpMethod.PUT, + new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode envBody = objectMapper.readTree(response.getBody()); + assertThat(envBody.path("jarRetentionCount").asInt()).isEqualTo(3); + } + + @Test + void putJarRetention_nullValue_returns200_unlimited() throws Exception { + // null = unlimited (no cap check fires). Important regression: ensure the cap-check + // guard remains a `requested != null` short-circuit and doesn't reject unlimited mode. + String body = """ + {"jarRetentionCount": null} + """; + ResponseEntity response = restTemplate.exchange( + "/api/v1/admin/environments/default/jar-retention", HttpMethod.PUT, + new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)), + String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } +}