test(alerting): fix duplicate @MockBean after AbstractPostgresIT centralised mocks + Plan 02 verification report

AbstractPostgresIT gained clickHouseSearchIndex and agentRegistryService mocks in Phase 9.
All 14 alerting IT subclasses that re-declared the same @MockBean fields now fail with
"Duplicate mock definition". Removed the redundant declarations; per-class clickHouseLogStore
mock kept where needed. 120 alerting tests now pass (0 failures).

Also adds docs/alerting-02-verification.md (Task 43).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 23:27:19 +02:00
parent 63669bd1d7
commit c79a6234af
17 changed files with 819 additions and 57 deletions

View File

@@ -0,0 +1,136 @@
package com.cameleer.server.app.alerting;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.core.alerting.*;
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.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
/**
* Verifies that alert instances from env-A are invisible from env-B's inbox endpoint.
*/
class AlertingEnvIsolationIT extends AbstractPostgresIT {
// AbstractPostgresIT already declares clickHouseSearchIndex + agentRegistryService mocks.
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
@Autowired private TestRestTemplate restTemplate;
@Autowired private TestSecurityHelper securityHelper;
@Autowired private ObjectMapper objectMapper;
@Autowired private AlertInstanceRepository instanceRepo;
@Value("${cameleer.server.tenant.id:default}")
private String tenantId;
private String operatorJwt;
private UUID envIdA;
private UUID envIdB;
private String envSlugA;
private String envSlugB;
@BeforeEach
void setUp() {
when(agentRegistryService.findAll()).thenReturn(List.of());
operatorJwt = securityHelper.operatorToken();
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email) VALUES ('test-operator', 'test', 'op@test.lc') ON CONFLICT (user_id) DO NOTHING");
envSlugA = "iso-env-a-" + UUID.randomUUID().toString().substring(0, 6);
envSlugB = "iso-env-b-" + UUID.randomUUID().toString().substring(0, 6);
envIdA = UUID.randomUUID();
envIdB = UUID.randomUUID();
jdbcTemplate.update("INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)", envIdA, envSlugA, "ISO A");
jdbcTemplate.update("INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)", envIdB, envSlugB, "ISO B");
}
@AfterEach
void cleanUp() {
jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN (SELECT id FROM alert_instances WHERE environment_id IN (?, ?))", envIdA, envIdB);
jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id IN (?, ?)", envIdA, envIdB);
jdbcTemplate.update("DELETE FROM environments WHERE id IN (?, ?)", envIdA, envIdB);
jdbcTemplate.update("DELETE FROM users WHERE user_id = 'test-operator'");
}
@Test
void alertInEnvA_isInvisibleFromEnvB() throws Exception {
// Seed a FIRING instance in env-A targeting the operator user
UUID instanceA = seedFiringInstance(envIdA, "test-operator");
// GET inbox for env-A — should see it
ResponseEntity<String> respA = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(respA.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode bodyA = objectMapper.readTree(respA.getBody());
boolean foundInA = false;
for (JsonNode node : bodyA) {
if (instanceA.toString().equals(node.path("id").asText())) {
foundInA = true;
}
}
assertThat(foundInA).as("instance from env-A should appear in env-A inbox").isTrue();
// GET inbox for env-B — should NOT see env-A's instance
ResponseEntity<String> respB = restTemplate.exchange(
"/api/v1/environments/" + envSlugB + "/alerts",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(respB.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode bodyB = objectMapper.readTree(respB.getBody());
for (JsonNode node : bodyB) {
assertThat(node.path("id").asText())
.as("env-A instance must not appear in env-B inbox")
.isNotEqualTo(instanceA.toString());
}
}
// ─────────────────────────────────────────────────────────────────────────
private UUID seedFiringInstance(UUID envId, String userId) {
UUID ruleId = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email) VALUES (?, 'test', ?) ON CONFLICT (user_id) DO NOTHING",
userId, userId + "@test.lc");
jdbcTemplate.update("""
INSERT INTO alert_rules
(id, environment_id, name, severity, condition_kind, condition,
notification_title_tmpl, notification_message_tmpl, created_by, updated_by)
VALUES (?, ?, 'iso-rule', 'WARNING', 'AGENT_STATE', '{}'::jsonb, 't', 'm', ?, ?)
""", ruleId, envId, userId, userId);
UUID instanceId = UUID.randomUUID();
jdbcTemplate.update("""
INSERT INTO alert_instances
(id, rule_id, rule_snapshot, environment_id, state, severity,
fired_at, silenced, context, title, message,
target_user_ids, target_group_ids, target_role_names)
VALUES (?, ?, ?::jsonb, ?, 'FIRING'::alert_state_enum, 'WARNING'::severity_enum,
now(), false, '{}'::jsonb, 'T', 'M',
ARRAY[?]::text[], '{}'::uuid[], '{}'::text[])
""",
instanceId, ruleId,
"{\"name\":\"iso-rule\",\"id\":\"" + ruleId + "\"}",
envId, userId);
return instanceId;
}
}

View File

@@ -0,0 +1,323 @@
package com.cameleer.server.app.alerting;
import com.cameleer.common.model.LogEntry;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.alerting.eval.AlertEvaluatorJob;
import com.cameleer.server.app.alerting.notify.NotificationDispatchJob;
import com.cameleer.server.app.outbound.crypto.SecretCipher;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.core.alerting.*;
import com.cameleer.server.core.ingestion.BufferedLogEntry;
import com.cameleer.server.core.outbound.OutboundConnectionRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.core.WireMockConfiguration;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.TestInstance.Lifecycle;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Canary integration test — exercises the full alerting lifecycle end-to-end:
* fire → notify → ack → silence → re-fire (suppressed) → resolve → rule delete.
*
* Uses real Postgres (Testcontainers) and real ClickHouse for log seeding.
* WireMock provides the webhook target.
*/
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@TestInstance(Lifecycle.PER_CLASS)
class AlertingFullLifecycleIT extends AbstractPostgresIT {
// AbstractPostgresIT already declares clickHouseSearchIndex + agentRegistryService mocks.
// ── Spring beans ──────────────────────────────────────────────────────────
@Autowired private AlertEvaluatorJob evaluatorJob;
@Autowired private NotificationDispatchJob dispatchJob;
@Autowired private AlertRuleRepository ruleRepo;
@Autowired private AlertInstanceRepository instanceRepo;
@Autowired private AlertNotificationRepository notificationRepo;
@Autowired private AlertSilenceRepository silenceRepo;
@Autowired private OutboundConnectionRepository outboundRepo;
@Autowired private ClickHouseLogStore logStore;
@Autowired private SecretCipher secretCipher;
@Autowired private TestRestTemplate restTemplate;
@Autowired private TestSecurityHelper securityHelper;
@Autowired private ObjectMapper objectMapper;
@Value("${cameleer.server.tenant.id:default}")
private String tenantId;
// ── Test state shared across @Test methods ─────────────────────────────────
private WireMockServer wm;
private String operatorJwt;
private String envSlug;
private UUID envId;
private UUID ruleId;
private UUID connId;
private UUID instanceId; // filled after first FIRING
// ── Setup / teardown ──────────────────────────────────────────────────────
@BeforeAll
void seedFixtures() throws Exception {
wm = new WireMockServer(WireMockConfiguration.options()
.httpDisabled(true)
.dynamicHttpsPort());
wm.start();
// ClickHouse schema is auto-initialized by ClickHouseSchemaInitializer on Spring context startup.
operatorJwt = securityHelper.operatorToken();
// Seed operator user in Postgres
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email, display_name) VALUES ('test-operator', 'test', 'op@lc.test', 'Op') ON CONFLICT (user_id) DO NOTHING");
// Seed environment
envSlug = "lc-env-" + UUID.randomUUID().toString().substring(0, 6);
envId = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)",
envId, envSlug, "LC Env");
// Seed outbound connection (WireMock HTTPS, TRUST_ALL, with HMAC secret)
connId = UUID.randomUUID();
String hmacCiphertext = secretCipher.encrypt("test-hmac-secret");
String webhookUrl = "https://localhost:" + wm.httpsPort() + "/webhook";
jdbcTemplate.update(
"INSERT INTO outbound_connections" +
" (id, tenant_id, name, url, method, tls_trust_mode, tls_ca_pem_paths," +
" hmac_secret_ciphertext, auth_kind, auth_config, default_headers," +
" allowed_environment_ids, created_by, updated_by)" +
" VALUES (?, ?, 'lc-webhook', ?," +
" 'POST'::outbound_method_enum," +
" 'TRUST_ALL'::trust_mode_enum," +
" '[]'::jsonb," +
" ?, 'NONE'::outbound_auth_kind_enum, '{}'::jsonb, '{}'::jsonb," +
" '{}'," +
" 'test-operator', 'test-operator')",
connId, tenantId, webhookUrl, hmacCiphertext);
// Seed alert rule (LOG_PATTERN, forDurationSeconds=0, threshold=0 so >=1 log fires immediately)
ruleId = UUID.randomUUID();
UUID webhookBindingId = UUID.randomUUID();
String webhooksJson = objectMapper.writeValueAsString(List.of(
Map.of("id", webhookBindingId.toString(),
"outboundConnectionId", connId.toString())));
String conditionJson = objectMapper.writeValueAsString(Map.of(
"kind", "LOG_PATTERN",
"scope", Map.of("appSlug", "lc-app"),
"level", "ERROR",
"pattern", "TimeoutException",
"threshold", 0,
"windowSeconds", 300));
jdbcTemplate.update("""
INSERT INTO alert_rules
(id, environment_id, name, severity, enabled,
condition_kind, condition,
evaluation_interval_seconds, for_duration_seconds,
notification_title_tmpl, notification_message_tmpl,
webhooks, next_evaluation_at,
created_by, updated_by)
VALUES (?, ?, 'lc-timeout-rule', 'WARNING'::severity_enum, true,
'LOG_PATTERN'::condition_kind_enum, ?::jsonb,
60, 0,
'Alert: {{rule.name}}', 'Instance {{alert.id}} fired',
?::jsonb, now() - interval '1 second',
'test-operator', 'test-operator')
""",
ruleId, envId, conditionJson, webhooksJson);
// Seed alert_rule_targets so the instance shows up in inbox
jdbcTemplate.update(
"INSERT INTO alert_rule_targets (id, rule_id, target_kind, target_id) VALUES (gen_random_uuid(), ?, 'USER'::target_kind_enum, 'test-operator') ON CONFLICT (rule_id, target_kind, target_id) DO NOTHING",
ruleId);
}
@AfterAll
void cleanupFixtures() {
if (wm != null) wm.stop();
jdbcTemplate.update("DELETE FROM alert_silences WHERE environment_id = ?", envId);
jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN (SELECT id FROM alert_instances WHERE environment_id = ?)", envId);
jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", envId);
jdbcTemplate.update("DELETE FROM alert_rule_targets WHERE rule_id = ?", ruleId);
jdbcTemplate.update("DELETE FROM alert_rules WHERE id = ?", ruleId);
jdbcTemplate.update("DELETE FROM outbound_connections WHERE id = ?", connId);
jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId);
jdbcTemplate.update("DELETE FROM users WHERE user_id = 'test-operator'");
}
// ── Test methods (ordered) ────────────────────────────────────────────────
@Test
@Order(1)
void step1_seedLogAndEvaluate_createsFireInstance() throws Exception {
// Stub WireMock to return 200
wm.stubFor(post("/webhook").willReturn(aResponse().withStatus(200).withBody("accepted")));
// Seed a matching log into ClickHouse
seedMatchingLog();
// Tick evaluator
evaluatorJob.tick();
// Assert FIRING instance created
List<AlertInstance> instances = instanceRepo.listForInbox(
envId, List.of(), "test-operator", List.of("OPERATOR"), 10);
assertThat(instances).hasSize(1);
assertThat(instances.get(0).state()).isEqualTo(AlertState.FIRING);
assertThat(instances.get(0).ruleId()).isEqualTo(ruleId);
instanceId = instances.get(0).id();
}
@Test
@Order(2)
void step2_dispatchJob_deliversWebhook() throws Exception {
assertThat(instanceId).isNotNull();
// Tick dispatcher
dispatchJob.tick();
// Assert DELIVERED notification
List<AlertNotification> notifs = notificationRepo.listForInstance(instanceId);
assertThat(notifs).hasSize(1);
assertThat(notifs.get(0).status()).isEqualTo(NotificationStatus.DELIVERED);
assertThat(notifs.get(0).lastResponseStatus()).isEqualTo(200);
// WireMock received exactly one POST with HMAC header
wm.verify(1, postRequestedFor(urlEqualTo("/webhook"))
.withHeader("X-Cameleer-Signature", matching("sha256=[0-9a-f]{64}")));
// Body should contain rule name
wm.verify(postRequestedFor(urlEqualTo("/webhook"))
.withRequestBody(containing("lc-timeout-rule")));
}
@Test
@Order(3)
void step3_ack_transitionsToAcknowledged() throws Exception {
assertThat(instanceId).isNotNull();
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/" + instanceId + "/ack",
HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(resp.getBody());
assertThat(body.path("state").asText()).isEqualTo("ACKNOWLEDGED");
// DB state
AlertInstance updated = instanceRepo.findById(instanceId).orElseThrow();
assertThat(updated.state()).isEqualTo(AlertState.ACKNOWLEDGED);
}
@Test
@Order(4)
void step4_silence_suppressesSubsequentNotification() throws Exception {
// Create a silence matching this rule
String silenceBody = objectMapper.writeValueAsString(Map.of(
"matcher", Map.of("ruleId", ruleId.toString()),
"reason", "lifecycle-test-silence",
"startsAt", Instant.now().minusSeconds(10).toString(),
"endsAt", Instant.now().plusSeconds(3600).toString()
));
ResponseEntity<String> silenceResp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/silences",
HttpMethod.POST,
new HttpEntity<>(silenceBody, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(silenceResp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Reset WireMock counter
wm.resetRequests();
// Inject a fresh PENDING notification for the existing instance — simulates a re-notification
// attempt that the dispatcher should silently suppress.
UUID newNotifId = UUID.randomUUID();
// Look up the webhook_id from the existing notification for this instance
UUID existingWebhookId = jdbcTemplate.queryForObject(
"SELECT webhook_id FROM alert_notifications WHERE alert_instance_id = ? LIMIT 1",
UUID.class, instanceId);
jdbcTemplate.update(
"INSERT INTO alert_notifications" +
" (id, alert_instance_id, outbound_connection_id, webhook_id," +
" status, attempts, next_attempt_at, payload, created_at)" +
" VALUES (?, ?, ?, ?," +
" 'PENDING'::notification_status_enum, 0, now() - interval '1 second'," +
" '{}'::jsonb, now())",
newNotifId, instanceId, connId, existingWebhookId);
// Tick dispatcher — the silence should suppress the notification
dispatchJob.tick();
// The injected notification should now be FAILED with snippet "silenced"
List<AlertNotification> notifs = notificationRepo.listForInstance(instanceId);
boolean foundSilenced = notifs.stream()
.anyMatch(n -> NotificationStatus.FAILED.equals(n.status())
&& n.lastResponseSnippet() != null
&& n.lastResponseSnippet().contains("silenced"));
assertThat(foundSilenced).as("At least one notification should be silenced").isTrue();
// WireMock should NOT have received a new POST
wm.verify(0, postRequestedFor(urlEqualTo("/webhook")));
}
@Test
@Order(5)
void step5_deleteRule_nullifiesRuleIdButPreservesSnapshot() throws Exception {
// Delete the rule via DELETE endpoint
ResponseEntity<String> deleteResp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules/" + ruleId,
HttpMethod.DELETE,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(deleteResp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
// Rule should be gone from DB
assertThat(ruleRepo.findById(ruleId)).isEmpty();
// Existing alert instances should have rule_id = NULL but rule_snapshot still contains name
List<AlertInstance> remaining = instanceRepo.listForInbox(
envId, List.of(), "test-operator", List.of("OPERATOR"), 10);
assertThat(remaining).isNotEmpty();
for (AlertInstance inst : remaining) {
// rule_id should now be null (FK ON DELETE SET NULL)
assertThat(inst.ruleId()).isNull();
// rule_snapshot should still contain the rule name
assertThat(inst.ruleSnapshot()).containsKey("name");
assertThat(inst.ruleSnapshot().get("name").toString()).contains("lc-timeout-rule");
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
private void seedMatchingLog() {
LogEntry entry = new LogEntry(
Instant.now(),
"ERROR",
"com.example.OrderService",
"java.net.SocketTimeoutException: TimeoutException after 5000ms",
"main",
null,
Map.of()
);
logStore.insertBufferedBatch(List.of(
new BufferedLogEntry(tenantId, envSlug, "lc-agent-01", "lc-app", entry)));
}
}

View File

@@ -0,0 +1,166 @@
package com.cameleer.server.app.alerting;
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.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.*;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
/**
* Verifies the outbound connection allowed-environment guard end-to-end:
* <ol>
* <li>Rule in env-B referencing a connection restricted to env-A → 422.</li>
* <li>Rule in env-A referencing the same connection → 201.</li>
* <li>Narrowing the connection's allowed envs to env-C (removing env-A) while
* a rule in env-A still references it → 409 via PUT /admin/outbound-connections/{id}.</li>
* </ol>
*/
class OutboundConnectionAllowedEnvIT extends AbstractPostgresIT {
// AbstractPostgresIT already declares clickHouseSearchIndex + agentRegistryService mocks.
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
@Autowired private TestRestTemplate restTemplate;
@Autowired private TestSecurityHelper securityHelper;
@Autowired private ObjectMapper objectMapper;
@Value("${cameleer.server.tenant.id:default}")
private String tenantId;
private String adminJwt;
private String operatorJwt;
private UUID envIdA;
private UUID envIdB;
private UUID envIdC;
private String envSlugA;
private String envSlugB;
private UUID connId;
@BeforeEach
void setUp() throws Exception {
when(agentRegistryService.findAll()).thenReturn(List.of());
adminJwt = securityHelper.adminToken();
operatorJwt = securityHelper.operatorToken();
jdbcTemplate.update("INSERT INTO users (user_id, provider, email) VALUES ('test-admin', 'test', 'adm@test.lc') ON CONFLICT (user_id) DO NOTHING");
jdbcTemplate.update("INSERT INTO users (user_id, provider, email) VALUES ('test-operator', 'test', 'op@test.lc') ON CONFLICT (user_id) DO NOTHING");
envSlugA = "conn-env-a-" + UUID.randomUUID().toString().substring(0, 6);
envSlugB = "conn-env-b-" + UUID.randomUUID().toString().substring(0, 6);
String envSlugC = "conn-env-c-" + UUID.randomUUID().toString().substring(0, 6);
envIdA = UUID.randomUUID();
envIdB = UUID.randomUUID();
envIdC = UUID.randomUUID();
jdbcTemplate.update("INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)", envIdA, envSlugA, "A");
jdbcTemplate.update("INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)", envIdB, envSlugB, "B");
jdbcTemplate.update("INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?)", envIdC, envSlugC, "C");
// Create outbound connection restricted to env-A
String connBody = objectMapper.writeValueAsString(java.util.Map.of(
"name", "env-a-only-conn-" + UUID.randomUUID().toString().substring(0, 6),
"url", "https://httpbin.org/post",
"method", "POST",
"tlsTrustMode", "SYSTEM_DEFAULT",
"auth", java.util.Map.of(),
"allowedEnvironmentIds", List.of(envIdA.toString())
));
ResponseEntity<String> connResp = restTemplate.exchange(
"/api/v1/admin/outbound-connections",
HttpMethod.POST,
new HttpEntity<>(connBody, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(connResp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
connId = UUID.fromString(objectMapper.readTree(connResp.getBody()).path("id").asText());
}
@AfterEach
void cleanUp() {
jdbcTemplate.update("DELETE FROM alert_rules WHERE environment_id IN (?, ?, ?)", envIdA, envIdB, envIdC);
jdbcTemplate.update("DELETE FROM outbound_connections WHERE id = ?", connId);
jdbcTemplate.update("DELETE FROM environments WHERE id IN (?, ?, ?)", envIdA, envIdB, envIdC);
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-admin', 'test-operator')");
}
@Test
void ruleInEnvB_referencingEnvAOnlyConnection_returns422() {
String body = ruleBodyWithConnection("envb-rule", connId);
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlugB + "/alerts/rules",
HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
}
@Test
void ruleInEnvA_referencingEnvAOnlyConnection_returns201() throws Exception {
String body = ruleBodyWithConnection("enva-rule", connId);
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts/rules",
HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
@Test
void narrowingConnectionToEnvC_whileRuleInEnvA_references_returns409() throws Exception {
// First create a rule in env-A that references the connection
String ruleBody = ruleBodyWithConnection("narrowing-guard-rule", connId);
ResponseEntity<String> ruleResp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts/rules",
HttpMethod.POST,
new HttpEntity<>(ruleBody, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(ruleResp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Now update the connection to only allow env-C (removing env-A)
String updateBody = objectMapper.writeValueAsString(java.util.Map.of(
"name", "env-a-only-conn-narrowed",
"url", "https://httpbin.org/post",
"method", "POST",
"tlsTrustMode", "SYSTEM_DEFAULT",
"auth", java.util.Map.of(),
"allowedEnvironmentIds", List.of(envIdC.toString()) // removed env-A
));
ResponseEntity<String> updateResp = restTemplate.exchange(
"/api/v1/admin/outbound-connections/" + connId,
HttpMethod.PUT,
new HttpEntity<>(updateBody, securityHelper.authHeaders(adminJwt)),
String.class);
// The guard should fire: env-A was removed but a rule in env-A still references it
assertThat(updateResp.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
}
// ─────────────────────────────────────────────────────────────────────────
private static String ruleBodyWithConnection(String name, UUID connectionId) {
return """
{"name":"%s","severity":"WARNING","conditionKind":"ROUTE_METRIC",
"condition":{"kind":"ROUTE_METRIC","scope":{},
"metric":"ERROR_RATE","comparator":"GT","threshold":0.05,"windowSeconds":60},
"webhooks":[{"outboundConnectionId":"%s"}]}
""".formatted(name, connectionId);
}
}

View File

@@ -3,7 +3,6 @@ package com.cameleer.server.app.alerting.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.core.alerting.AlertInstance;
import com.cameleer.server.core.alerting.AlertInstanceRepository;
import com.cameleer.server.core.alerting.AlertReadRepository;
@@ -30,7 +29,6 @@ import static org.assertj.core.api.Assertions.assertThat;
class AlertControllerIT extends AbstractPostgresIT {
@MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex;
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
@Autowired private TestRestTemplate restTemplate;

View File

@@ -3,7 +3,6 @@ package com.cameleer.server.app.alerting.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.core.alerting.AlertInstance;
import com.cameleer.server.core.alerting.AlertInstanceRepository;
import com.cameleer.server.core.alerting.AlertNotification;
@@ -32,7 +31,6 @@ import static org.assertj.core.api.Assertions.assertThat;
class AlertNotificationControllerIT extends AbstractPostgresIT {
@MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex;
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
@Autowired private TestRestTemplate restTemplate;

View File

@@ -3,7 +3,6 @@ package com.cameleer.server.app.alerting.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.core.admin.AuditRepository;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -26,7 +25,6 @@ class AlertRuleControllerIT extends AbstractPostgresIT {
// ExchangeMatchEvaluator and LogPatternEvaluator depend on these concrete beans
// (not the SearchIndex/LogIndex interfaces). Mock them so the context wires up.
@MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex;
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
@Autowired private TestRestTemplate restTemplate;

View File

@@ -3,7 +3,6 @@ package com.cameleer.server.app.alerting.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
@@ -25,7 +24,6 @@ import static org.assertj.core.api.Assertions.assertThat;
class AlertSilenceControllerIT extends AbstractPostgresIT {
@MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex;
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
@Autowired private TestRestTemplate restTemplate;

View File

@@ -2,9 +2,7 @@ package com.cameleer.server.app.alerting.eval;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.AgentState;
import com.cameleer.server.core.alerting.*;
import org.junit.jupiter.api.AfterEach;
@@ -35,11 +33,9 @@ class AlertEvaluatorJobIT extends AbstractPostgresIT {
// Replace the named beans so ExchangeMatchEvaluator / LogPatternEvaluator can wire their
// concrete-type constructor args without duplicating the SearchIndex / LogIndex beans.
@MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex;
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
// Control agent state per test without timing sensitivity
@MockBean AgentRegistryService agentRegistryService;
@Autowired private AlertEvaluatorJob job;
@Autowired private AlertRuleRepository ruleRepo;

View File

@@ -2,8 +2,6 @@ package com.cameleer.server.app.alerting.notify;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.alerting.*;
import com.cameleer.server.core.http.TrustMode;
import com.cameleer.server.core.outbound.OutboundAuth;
@@ -36,9 +34,7 @@ import static org.mockito.Mockito.*;
*/
class NotificationDispatchJobIT extends AbstractPostgresIT {
@MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex;
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
@MockBean AgentRegistryService agentRegistryService;
/** Mock the dispatcher — we control outcomes per test. */
@MockBean WebhookDispatcher webhookDispatcher;

View File

@@ -2,21 +2,17 @@ package com.cameleer.server.app.alerting.retention;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.alerting.*;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.sql.Timestamp;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
@@ -34,9 +30,9 @@ import static org.assertj.core.api.Assertions.assertThat;
*/
class AlertingRetentionJobIT extends AbstractPostgresIT {
@MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex;
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
@MockBean AgentRegistryService agentRegistryService;
// AbstractPostgresIT already declares clickHouseSearchIndex + agentRegistryService mocks.
// Declare only the additional mock needed by this test.
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
@Autowired private AlertingRetentionJob job;
@Autowired private AlertInstanceRepository instanceRepo;
@@ -182,42 +178,43 @@ class AlertingRetentionJobIT extends AbstractPostgresIT {
private UUID seedResolvedInstance(Instant resolvedAt) {
UUID id = UUID.randomUUID();
jdbcTemplate.update("""
INSERT INTO alert_instances
(id, rule_id, rule_snapshot, environment_id, state, severity,
fired_at, resolved_at, silenced, context, title, message,
target_user_ids, target_group_ids, target_role_names)
VALUES (?, ?, '{}'::jsonb, ?, 'RESOLVED'::alert_state_enum, 'WARNING'::severity_enum,
?, ?, false, '{}'::jsonb, 'T', 'M',
'{}', '{}', '{}')
""",
id, ruleId, envId, resolvedAt, resolvedAt);
Timestamp ts = Timestamp.from(resolvedAt);
jdbcTemplate.update(
"INSERT INTO alert_instances" +
" (id, rule_id, rule_snapshot, environment_id, state, severity," +
" fired_at, resolved_at, silenced, context, title, message," +
" target_user_ids, target_group_ids, target_role_names)" +
" VALUES (?, ?, '{}'::jsonb, ?, 'RESOLVED'::alert_state_enum, 'WARNING'::severity_enum," +
" ?, ?, false, '{}'::jsonb, 'T', 'M'," +
" '{}'::text[], '{}'::uuid[], '{}'::text[])",
id, ruleId, envId, ts, ts);
return id;
}
private UUID seedFiringInstance(Instant firedAt) {
UUID id = UUID.randomUUID();
jdbcTemplate.update("""
INSERT INTO alert_instances
(id, rule_id, rule_snapshot, environment_id, state, severity,
fired_at, silenced, context, title, message,
target_user_ids, target_group_ids, target_role_names)
VALUES (?, ?, '{}'::jsonb, ?, 'FIRING'::alert_state_enum, 'WARNING'::severity_enum,
?, false, '{}'::jsonb, 'T', 'M',
'{}', '{}', '{}')
""",
id, ruleId, envId, firedAt);
Timestamp ts = Timestamp.from(firedAt);
jdbcTemplate.update(
"INSERT INTO alert_instances" +
" (id, rule_id, rule_snapshot, environment_id, state, severity," +
" fired_at, silenced, context, title, message," +
" target_user_ids, target_group_ids, target_role_names)" +
" VALUES (?, ?, '{}'::jsonb, ?, 'FIRING'::alert_state_enum, 'WARNING'::severity_enum," +
" ?, false, '{}'::jsonb, 'T', 'M'," +
" '{}'::text[], '{}'::uuid[], '{}'::text[])",
id, ruleId, envId, ts);
return id;
}
private UUID seedNotification(UUID alertInstanceId, NotificationStatus status, Instant createdAt) {
UUID id = UUID.randomUUID();
Timestamp ts = Timestamp.from(createdAt);
jdbcTemplate.update("""
INSERT INTO alert_notifications
(id, alert_instance_id, status, attempts, next_attempt_at, payload, created_at)
VALUES (?, ?, ?::notification_status_enum, 0, ?, '{}'::jsonb, ?)
""",
id, alertInstanceId, status.name(), createdAt, createdAt);
id, alertInstanceId, status.name(), ts, ts);
return id;
}

View File

@@ -2,7 +2,6 @@ package com.cameleer.server.app.alerting.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.core.alerting.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
@@ -19,7 +18,6 @@ import static org.assertj.core.api.Assertions.assertThat;
class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT {
@MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex;
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
private PostgresAlertInstanceRepository repo;

View File

@@ -2,7 +2,6 @@ package com.cameleer.server.app.alerting.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.core.alerting.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
@@ -19,7 +18,6 @@ import static org.assertj.core.api.Assertions.assertThat;
class PostgresAlertNotificationRepositoryIT extends AbstractPostgresIT {
@MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex;
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
private PostgresAlertNotificationRepository repo;

View File

@@ -2,7 +2,6 @@ package com.cameleer.server.app.alerting.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -16,7 +15,6 @@ import static org.assertj.core.api.Assertions.assertThatCode;
class PostgresAlertReadRepositoryIT extends AbstractPostgresIT {
@MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex;
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
private PostgresAlertReadRepository repo;

View File

@@ -2,7 +2,6 @@ package com.cameleer.server.app.alerting.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.core.alerting.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
@@ -19,7 +18,6 @@ import static org.assertj.core.api.Assertions.assertThat;
class PostgresAlertRuleRepositoryIT extends AbstractPostgresIT {
@MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex;
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
private PostgresAlertRuleRepository repo;

View File

@@ -2,7 +2,6 @@ package com.cameleer.server.app.alerting.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import com.cameleer.server.core.alerting.AlertSilence;
import com.cameleer.server.core.alerting.SilenceMatcher;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -19,7 +18,6 @@ import static org.assertj.core.api.Assertions.assertThat;
class PostgresAlertSilenceRepositoryIT extends AbstractPostgresIT {
@MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex;
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
private PostgresAlertSilenceRepository repo;

View File

@@ -2,7 +2,6 @@ package com.cameleer.server.app.alerting.storage;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.search.ClickHouseLogStore;
import com.cameleer.server.app.search.ClickHouseSearchIndex;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
@@ -10,7 +9,6 @@ import static org.assertj.core.api.Assertions.assertThat;
class V12MigrationIT extends AbstractPostgresIT {
@MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex;
@MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore;
private java.util.UUID testEnvId;