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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user