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:
hsiegeln
2026-04-26 16:00:50 +02:00
parent 1a307da6b2
commit 1e78439ddd

View File

@@ -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);
}
}
}