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:
@@ -0,0 +1,19 @@
|
||||
package com.cameleer.server.core.runtime;
|
||||
|
||||
/**
|
||||
* Hook called by domain services before creating a new entity. Implementations enforce
|
||||
* environment-level policy (e.g., license caps) without dragging Spring or app-module types
|
||||
* into core.
|
||||
*
|
||||
* <p>The guard is consulted with the current count; implementations throw to abort creation.
|
||||
* The {@link #NOOP} singleton is the default for tests and for boot configurations that haven't
|
||||
* wired enforcement yet.</p>
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface CreateGuard {
|
||||
|
||||
/** Throw to reject creation; otherwise return normally. */
|
||||
void check(long current);
|
||||
|
||||
CreateGuard NOOP = c -> { };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import java.util.UUID;
|
||||
|
||||
public interface EnvironmentRepository {
|
||||
List<Environment> findAll();
|
||||
long count();
|
||||
Optional<Environment> findById(UUID id);
|
||||
Optional<Environment> findBySlug(String slug);
|
||||
UUID create(String slug, String displayName, boolean production);
|
||||
|
||||
@@ -17,9 +17,15 @@ public class EnvironmentService {
|
||||
private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$");
|
||||
|
||||
private final EnvironmentRepository repo;
|
||||
private final CreateGuard createGuard;
|
||||
|
||||
public EnvironmentService(EnvironmentRepository repo) {
|
||||
this(repo, CreateGuard.NOOP);
|
||||
}
|
||||
|
||||
public EnvironmentService(EnvironmentRepository repo, CreateGuard createGuard) {
|
||||
this.repo = repo;
|
||||
this.createGuard = createGuard;
|
||||
}
|
||||
|
||||
public List<Environment> listAll() { return repo.findAll(); }
|
||||
@@ -37,6 +43,7 @@ public class EnvironmentService {
|
||||
throw new IllegalArgumentException(
|
||||
"Invalid slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)");
|
||||
}
|
||||
createGuard.check(repo.count());
|
||||
if (repo.findBySlug(slug).isPresent()) {
|
||||
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user