feat(license): enforce max_jar_retention_count at PUT jar-retention
Returns 422 UNPROCESSABLE_ENTITY when jarRetentionCount exceeds license cap. Default tier cap = 3. The other three retention caps (execution/log/metric retention days) are deferred to T26+ where the corresponding fields are added to Environment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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")) {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<String> 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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user