test(license): LicenseEnforcementIT — cross-cap smoke regression net
Five @Nested cap surfaces (envs, apps, outbound, alert rules, users) share a single synthetic license with cap=1 each. Each test pushes just past the cap and verifies the standard 403 envelope plus a cap_exceeded audit row. Per-limit ITs cover full per-cap behavior; this IT catches accidental wire-rip regressions across all caps. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
package com.cameleer.server.app.license;
|
||||
|
||||
import com.cameleer.server.app.AbstractPostgresIT;
|
||||
import com.cameleer.server.app.TestSecurityHelper;
|
||||
import com.cameleer.server.app.search.ClickHouseLogStore;
|
||||
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.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
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 java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Cross-cutting smoke regression for the license cap framework (spec §10).
|
||||
*
|
||||
* <p>This IT installs a SINGLE synthetic license with all caps lowered to the smallest useful
|
||||
* value (1 each, except {@code max_users = 2} so the seeded admin write doesn't immediately
|
||||
* exhaust the cap) and verifies that five different cap surfaces fire under the SAME license.
|
||||
* The point is not to exhaustively test each cap — per-limit ITs already do that — but to catch
|
||||
* the regression where one of the wiring tasks is accidentally backed out without a per-limit
|
||||
* test failing. If the consolidated tripwire here fires, we know the framework is uniformly
|
||||
* wired across all surfaces.</p>
|
||||
*
|
||||
* <p>Each {@link Nested} test:</p>
|
||||
* <ol>
|
||||
* <li>Pushes the endpoint up to the cap (the outer {@link BeforeEach} pre-cleans state).</li>
|
||||
* <li>Pushes once more — expects 403 with the standard envelope produced by
|
||||
* {@link LicenseExceptionAdvice}: {@code error="license cap reached"}, {@code limit},
|
||||
* {@code current}, {@code cap}, {@code state="ACTIVE"}, non-blank {@code message}.</li>
|
||||
* <li>Verifies {@code audit_log} has at least one row with {@code category='LICENSE'},
|
||||
* {@code action='cap_exceeded'}, {@code result='FAILURE'}, {@code target=<key>}.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Out of scope (already covered by per-limit ITs):</p>
|
||||
* <ul>
|
||||
* <li>Agent registration cap — see {@code AgentCapEnforcementIT}.</li>
|
||||
* <li>Compute caps (cpu/memory/replicas) — see {@code ComputeCapEnforcementIT}; the deploy
|
||||
* endpoint requires a real artifact and runtime orchestration.</li>
|
||||
* <li>JAR retention cap — see {@code RetentionCapEnforcementIT}; that is a 422 not a 403,
|
||||
* shaped differently from the cap envelope.</li>
|
||||
* </ul>
|
||||
*/
|
||||
class LicenseEnforcementIT extends AbstractPostgresIT {
|
||||
|
||||
// The alert-rule controller wires evaluators that touch the CH log store bean. Mocking it
|
||||
// mirrors {@code AlertRuleCapEnforcementIT}'s pattern and avoids requiring real CH log
|
||||
// behaviour for a smoke regression that never evaluates rules.
|
||||
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
|
||||
|
||||
@Autowired
|
||||
private TestRestTemplate restTemplate;
|
||||
|
||||
@Autowired
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@Autowired
|
||||
private TestSecurityHelper securityHelper;
|
||||
|
||||
private String adminJwt;
|
||||
|
||||
@BeforeEach
|
||||
void installLicense() {
|
||||
adminJwt = securityHelper.adminToken();
|
||||
// Defensive: clear any license a previous IT may have left installed (LicenseGate is a
|
||||
// singleton across @SpringBootTest context reuse).
|
||||
securityHelper.clearTestLicense();
|
||||
// Strip dependents in FK order before the parent rows.
|
||||
jdbcTemplate.update("DELETE FROM alert_notifications");
|
||||
jdbcTemplate.update("DELETE FROM alert_instances");
|
||||
jdbcTemplate.update("DELETE FROM alert_silences");
|
||||
jdbcTemplate.update("DELETE FROM alert_rule_targets");
|
||||
jdbcTemplate.update("DELETE FROM alert_rules");
|
||||
jdbcTemplate.update("DELETE FROM deployments");
|
||||
jdbcTemplate.update("DELETE FROM app_versions");
|
||||
jdbcTemplate.update("DELETE FROM apps");
|
||||
jdbcTemplate.update("DELETE FROM environments WHERE slug != 'default'");
|
||||
jdbcTemplate.update("DELETE FROM outbound_connections");
|
||||
jdbcTemplate.update("DELETE FROM user_roles");
|
||||
jdbcTemplate.update("DELETE FROM user_groups");
|
||||
jdbcTemplate.update("DELETE FROM users");
|
||||
jdbcTemplate.update("DELETE FROM audit_log");
|
||||
// Seed user row for the JWT subject — alert_rules.created_by and
|
||||
// outbound_connections.created_by FK to users(user_id).
|
||||
jdbcTemplate.update(
|
||||
"INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING",
|
||||
"test-admin", "test-admin@example.com", "test-admin");
|
||||
// Single synthetic license used by all @Nested tests. Caps set to the minimum useful
|
||||
// values so the cap rejection lands on a small number of HTTP calls.
|
||||
// NB: max_users = 2 because the seeded test-admin row already counts toward the cap;
|
||||
// creating one more user succeeds, the second additional create rejects.
|
||||
securityHelper.installSyntheticUnsignedLicense(Map.of(
|
||||
"max_environments", 1,
|
||||
"max_apps", 1,
|
||||
"max_outbound_connections", 1,
|
||||
"max_alert_rules", 1,
|
||||
"max_users", 2));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void clearLicense() {
|
||||
securityHelper.clearTestLicense();
|
||||
}
|
||||
|
||||
// ---------- shared helpers ----------
|
||||
|
||||
private void assert403CapEnvelope(ResponseEntity<String> response, String expectedLimit, int expectedCap) throws Exception {
|
||||
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(expectedLimit);
|
||||
assertThat(body.path("cap").asInt()).isEqualTo(expectedCap);
|
||||
assertThat(body.path("state").asText()).isEqualTo("ACTIVE");
|
||||
assertThat(body.has("message")).isTrue();
|
||||
assertThat(body.path("message").asText()).isNotBlank();
|
||||
}
|
||||
|
||||
private long auditCount(String target) {
|
||||
Long count = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM audit_log
|
||||
WHERE category = 'LICENSE'
|
||||
AND action = 'cap_exceeded'
|
||||
AND result = 'FAILURE'
|
||||
AND target = ?
|
||||
""", Long.class, target);
|
||||
return count == null ? 0L : count;
|
||||
}
|
||||
|
||||
// ---------- nested cap tests ----------
|
||||
|
||||
@Nested
|
||||
class EnvironmentCap {
|
||||
@Test
|
||||
void secondCreate_rejectedWith403AndAuditRow() throws Exception {
|
||||
// V1 seeds the "default" env, so even a single create exceeds max_environments=1.
|
||||
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);
|
||||
|
||||
assert403CapEnvelope(response, "max_environments", 1);
|
||||
assertThat(auditCount("max_environments")).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class AppCap {
|
||||
@Test
|
||||
void secondCreate_rejectedWith403AndAuditRow() throws Exception {
|
||||
// First create succeeds (count: 0 -> 1, cap=1).
|
||||
ResponseEntity<String> first = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps", HttpMethod.POST,
|
||||
new HttpEntity<>("""
|
||||
{"slug":"a1","displayName":"A1"}
|
||||
""", securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
assertThat(first.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
|
||||
// Second create rejects.
|
||||
ResponseEntity<String> second = restTemplate.exchange(
|
||||
"/api/v1/environments/default/apps", HttpMethod.POST,
|
||||
new HttpEntity<>("""
|
||||
{"slug":"a2","displayName":"A2"}
|
||||
""", securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assert403CapEnvelope(second, "max_apps", 1);
|
||||
assertThat(auditCount("max_apps")).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class OutboundConnectionCap {
|
||||
@Test
|
||||
void secondCreate_rejectedWith403AndAuditRow() throws Exception {
|
||||
ResponseEntity<String> first = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.POST,
|
||||
new HttpEntity<>("""
|
||||
{"name":"hook-1","url":"https://hooks.example.com/1","method":"POST",
|
||||
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""",
|
||||
securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
assertThat(first.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
|
||||
ResponseEntity<String> second = restTemplate.exchange(
|
||||
"/api/v1/admin/outbound-connections", HttpMethod.POST,
|
||||
new HttpEntity<>("""
|
||||
{"name":"hook-2","url":"https://hooks.example.com/2","method":"POST",
|
||||
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""",
|
||||
securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assert403CapEnvelope(second, "max_outbound_connections", 1);
|
||||
assertThat(auditCount("max_outbound_connections")).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class AlertRuleCap {
|
||||
@Test
|
||||
void secondCreate_rejectedWith403AndAuditRow() throws Exception {
|
||||
ResponseEntity<String> first = restTemplate.exchange(
|
||||
"/api/v1/environments/default/alerts/rules", HttpMethod.POST,
|
||||
new HttpEntity<>(ruleBody("rule-1"), securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
assertThat(first.getStatusCode()).isEqualTo(HttpStatus.CREATED);
|
||||
|
||||
ResponseEntity<String> second = restTemplate.exchange(
|
||||
"/api/v1/environments/default/alerts/rules", HttpMethod.POST,
|
||||
new HttpEntity<>(ruleBody("rule-2"), securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
|
||||
assert403CapEnvelope(second, "max_alert_rules", 1);
|
||||
assertThat(auditCount("max_alert_rules")).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
private String ruleBody(String name) {
|
||||
// Mirrors AlertRuleCapEnforcementIT — minimal valid body that passes the
|
||||
// controller's "at least one webhook or target" guard.
|
||||
return """
|
||||
{"name":"%s","severity":"WARNING","conditionKind":"ROUTE_METRIC",
|
||||
"condition":{"kind":"ROUTE_METRIC","scope":{},
|
||||
"metric":"ERROR_RATE","comparator":"GT","threshold":0.05,"windowSeconds":60},
|
||||
"targets":[{"kind":"USER","targetId":"test-admin"}]}
|
||||
""".formatted(name);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class UserCap {
|
||||
@Test
|
||||
void thirdCreate_rejectedWith403AndAuditRow() throws Exception {
|
||||
// The outer @BeforeEach truncates users and seeds only "test-admin" (count = 1).
|
||||
// max_users = 2, so the FIRST create succeeds (count: 1 -> 2) and the SECOND rejects.
|
||||
ResponseEntity<String> first = createUser("alice");
|
||||
assertThat(first.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
|
||||
ResponseEntity<String> second = createUser("bob");
|
||||
assert403CapEnvelope(second, "max_users", 2);
|
||||
assertThat(auditCount("max_users")).isGreaterThanOrEqualTo(1);
|
||||
}
|
||||
|
||||
private ResponseEntity<String> createUser(String username) {
|
||||
// Password meets the policy: 12+ chars, 3-of-4 char classes, doesn't match username.
|
||||
String body = """
|
||||
{
|
||||
"username": "%s",
|
||||
"displayName": "User %s",
|
||||
"email": "%s@example.com",
|
||||
"password": "Sup3rSecret-Pass!"
|
||||
}
|
||||
""".formatted(username, username, username);
|
||||
return restTemplate.exchange(
|
||||
"/api/v1/admin/users", HttpMethod.POST,
|
||||
new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
|
||||
String.class);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user