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:

+ *
    + *
  1. Pushes the endpoint up to the cap (the outer {@link BeforeEach} pre-cleans state).
  2. + *
  3. 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}.
  4. + *
  5. Verifies {@code audit_log} has at least one row with {@code category='LICENSE'}, + * {@code action='cap_exceeded'}, {@code result='FAILURE'}, {@code target=}.
  6. + *
+ * + *

Out of scope (already covered by per-limit ITs):

+ * + */ +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); + } + } +}