feat(license): enforce max_apps at AppService.createApp

Adds CreateGuard hook to AppService.createApp using the same pattern
as T18 (EnvironmentService). AppRepository.count() added; the bean
wires LicenseEnforcer.assertWithinCap("max_apps", current, 1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 13:36:34 +02:00
parent 198811b752
commit 80dafe685b
5 changed files with 117 additions and 2 deletions

View File

@@ -58,8 +58,10 @@ public class RuntimeBeanConfig {
@Bean
public AppService appService(AppRepository appRepo, AppVersionRepository versionRepo,
@Value("${cameleer.server.runtime.jarstoragepath:/data/jars}") String jarStoragePath) {
return new AppService(appRepo, versionRepo, jarStoragePath);
@Value("${cameleer.server.runtime.jarstoragepath:/data/jars}") String jarStoragePath,
com.cameleer.server.app.license.LicenseEnforcer enforcer) {
return new AppService(appRepo, versionRepo, jarStoragePath,
current -> enforcer.assertWithinCap("max_apps", current, 1));
}
@Bean

View File

@@ -70,6 +70,12 @@ public class PostgresAppRepository implements AppRepository {
(rs, rowNum) -> mapRow(rs));
}
@Override
public long count() {
Long n = jdbc.queryForObject("SELECT COUNT(*) FROM apps", Long.class);
return n == null ? 0L : n;
}
@Override
public void updateContainerConfig(UUID id, Map<String, Object> containerConfig) {
try {

View File

@@ -0,0 +1,98 @@
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_apps} cap from the default tier is enforced at
* {@code POST /api/v1/environments/{envSlug}/apps}. Default tier {@code max_apps = 3}; with no
* license installed the gate is in {@link com.cameleer.server.core.license.LicenseState#ABSENT}
* and the defaults are authoritative. The fourth create attempt must be rejected with the
* structured 403 envelope produced by {@link LicenseExceptionAdvice}.
*/
class AppCapEnforcementIT 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 (LicenseGate is a singleton
// per Spring context; @SpringBootTest reuses contexts across ITs).
securityHelper.clearTestLicense();
// Strip dependents first, then the apps themselves. Keep the seeded "default" environment.
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
}
@AfterEach
void tearDown() {
// Defensive cleanup — we never installed a license, but make sure later ITs see ABSENT.
securityHelper.clearTestLicense();
jdbcTemplate.update("DELETE FROM deployments");
jdbcTemplate.update("DELETE FROM app_versions");
jdbcTemplate.update("DELETE FROM apps");
}
@Test
void createBeyondCap_returns403WithStateAndMessage() throws Exception {
// Default tier: max_apps = 3. Three creates succeed; the fourth rejects.
for (int i = 1; i <= 3; i++) {
String json = String.format("""
{"slug":"a%d","displayName":"A%d"}
""", i, i);
ResponseEntity<String> ok = restTemplate.exchange(
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(ok.getStatusCode())
.as("create #%d should succeed", i)
.isEqualTo(HttpStatus.CREATED);
}
String fourth = """
{"slug":"a4","displayName":"A4"}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>(fourth, 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_apps");
assertThat(body.path("cap").asInt()).isEqualTo(3);
assertThat(body.path("state").asText()).isEqualTo("ABSENT");
assertThat(body.has("message")).isTrue();
assertThat(body.path("message").asText()).isNotBlank();
// And the fourth app was not persisted.
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM apps WHERE slug = 'a4'", Integer.class);
assertThat(count).isZero();
}
}

View File

@@ -8,6 +8,7 @@ import java.util.UUID;
public interface AppRepository {
List<App> findByEnvironmentId(UUID environmentId);
List<App> findAll();
long count();
Optional<App> findById(UUID id);
Optional<App> findByEnvironmentIdAndSlug(UUID environmentId, String slug);
Optional<App> findBySlug(String slug);

View File

@@ -23,11 +23,18 @@ public class AppService {
private final AppRepository appRepo;
private final AppVersionRepository versionRepo;
private final String jarStoragePath;
private final CreateGuard createGuard;
public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath) {
this(appRepo, versionRepo, jarStoragePath, CreateGuard.NOOP);
}
public AppService(AppRepository appRepo, AppVersionRepository versionRepo, String jarStoragePath,
CreateGuard createGuard) {
this.appRepo = appRepo;
this.versionRepo = versionRepo;
this.jarStoragePath = jarStoragePath;
this.createGuard = createGuard;
}
public List<App> listAll() { return appRepo.findAll(); }
@@ -55,6 +62,7 @@ public class AppService {
throw new IllegalArgumentException(
"Invalid app slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)");
}
createGuard.check(appRepo.count());
if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) {
throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment");
}