feat(license): enforce max_environments at EnvironmentService.create
Adds CreateGuard functional interface to core (preserves the no-Spring boundary between core and app) and wires LicenseEnforcer into the EnvironmentService bean in RuntimeBeanConfig so POST /api/v1/admin/environments rejects with the structured 403 envelope (error/limit/cap/state/message) once the cap is reached. Default tier max_environments=1; the V1 baseline seeds the default env, so the very next create through the API is rejected unless a license lifts the cap. Also adds EnvironmentRepository.count() (with PostgresEnvironmentRepository impl), TestSecurityHelper.installTestLicenseWithCaps(...) so existing ITs that POST envs keep working, and a defensive cleanup in LicenseUsageReaderIT/EnvironmentAdminControllerIT to stay order-independent under Testcontainer reuse (deletes deployments+apps before envs to avoid FK violations). Test: EnvironmentCapEnforcementIT (new) drives the rejection path end-to-end and asserts the 403 body shape produced by LicenseExceptionAdvice. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Environment> findById(UUID id) {
|
||||
var results = jdbc.query(
|
||||
|
||||
@@ -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<String, Integer> 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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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("""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user