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:
hsiegeln
2026-04-26 15:16:04 +02:00
parent 56bddcc747
commit 046f08fe87
2 changed files with 132 additions and 2 deletions

View File

@@ -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")) {

View File

@@ -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);
}
}