diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcementIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcementIT.java
new file mode 100644
index 00000000..4fd0c739
--- /dev/null
+++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/license/LicenseEnforcementIT.java
@@ -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).
+ *
+ *
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.
+ *
+ * Each {@link Nested} test:
+ *
+ * - Pushes the endpoint up to the cap (the outer {@link BeforeEach} pre-cleans state).
+ * - 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}.
+ * - Verifies {@code audit_log} has at least one row with {@code category='LICENSE'},
+ * {@code action='cap_exceeded'}, {@code result='FAILURE'}, {@code target=}.
+ *
+ *
+ * Out of scope (already covered by per-limit ITs):
+ *
+ * - Agent registration cap — see {@code AgentCapEnforcementIT}.
+ * - Compute caps (cpu/memory/replicas) — see {@code ComputeCapEnforcementIT}; the deploy
+ * endpoint requires a real artifact and runtime orchestration.
+ * - JAR retention cap — see {@code RetentionCapEnforcementIT}; that is a 422 not a 403,
+ * shaped differently from the cap envelope.
+ *
+ */
+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 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 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 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 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 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 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 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 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 first = createUser("alice");
+ assertThat(first.getStatusCode()).isEqualTo(HttpStatus.OK);
+
+ ResponseEntity second = createUser("bob");
+ assert403CapEnvelope(second, "max_users", 2);
+ assertThat(auditCount("max_users")).isGreaterThanOrEqualTo(1);
+ }
+
+ private ResponseEntity 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);
+ }
+ }
+}