feat(alerting): AlertNotificationController + SecurityConfig matchers + fix IT context (Task 35)

- GET /environments/{envSlug}/alerts/{alertId}/notifications — list notifications for instance (VIEWER+)
- POST /alerts/notifications/{id}/retry — manual retry of failed notification (OPERATOR+)
  Flat path because notification IDs are globally unique (no env routing needed)
- scheduleRetry resets attempts to 0 and sets nextAttemptAt = now
- Added 11 alerting path matchers to SecurityConfig before outbound-connections block
- Fixed context loading failure in 6 pre-existing alerting storage/migration ITs by adding
  @MockBean(clickHouseSearchIndex/clickHouseLogStore): ExchangeMatchEvaluator and
  LogPatternEvaluator inject the concrete classes directly (not interface beans), so the
  full Spring context fails without these mocks in tests that don't use the real CH container
- 5 IT tests: list, viewer-can-list, retry, viewer-cannot-retry, unknown-404

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 21:29:17 +02:00
parent 77d1718451
commit e334dfacd3
10 changed files with 338 additions and 0 deletions

View File

@@ -0,0 +1,176 @@
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;
import com.cameleer.server.core.alerting.AlertNotificationRepository;
import com.cameleer.server.core.alerting.AlertSeverity;
import com.cameleer.server.core.alerting.AlertState;
import com.cameleer.server.core.alerting.NotificationStatus;
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.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.time.Instant;
import java.util.List;
import java.util.UUID;
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;
@Autowired private ObjectMapper objectMapper;
@Autowired private TestSecurityHelper securityHelper;
@Autowired private AlertInstanceRepository instanceRepo;
@Autowired private AlertNotificationRepository notificationRepo;
private String operatorJwt;
private String viewerJwt;
private String envSlug;
private UUID envId;
@BeforeEach
void setUp() {
operatorJwt = securityHelper.operatorToken();
viewerJwt = securityHelper.viewerToken();
seedUser("test-operator");
seedUser("test-viewer");
envSlug = "notif-env-" + UUID.randomUUID().toString().substring(0, 6);
envId = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING",
envId, envSlug, envSlug);
}
@AfterEach
void cleanUp() {
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 environments WHERE id = ?", envId);
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')");
}
@Test
void listNotificationsForInstance() throws Exception {
AlertInstance instance = seedInstance();
AlertNotification notification = seedNotification(instance.id());
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/" + instance.id() + "/notifications",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(resp.getBody());
assertThat(body.isArray()).isTrue();
assertThat(body.size()).isGreaterThanOrEqualTo(1);
}
@Test
void viewerCanListNotifications() throws Exception {
AlertInstance instance = seedInstance();
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/" + instance.id() + "/notifications",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void retryNotification() throws Exception {
AlertInstance instance = seedInstance();
AlertNotification notification = seedNotification(instance.id());
// Mark as failed first
notificationRepo.markFailed(notification.id(), 500, "Internal Server Error");
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/alerts/notifications/" + notification.id() + "/retry",
HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void viewerCannotRetry() throws Exception {
AlertInstance instance = seedInstance();
AlertNotification notification = seedNotification(instance.id());
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/alerts/notifications/" + notification.id() + "/retry",
HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeaders(viewerJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void retryUnknownNotificationReturns404() {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/alerts/notifications/" + UUID.randomUUID() + "/retry",
HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private AlertInstance seedInstance() {
AlertInstance instance = new AlertInstance(
UUID.randomUUID(), null, null, envId,
AlertState.FIRING, AlertSeverity.WARNING,
Instant.now(), null, null, null, null, false,
42.0, 1000.0, null, "Test alert", "Something happened",
List.of(), List.of(), List.of("OPERATOR"));
return instanceRepo.save(instance);
}
private AlertNotification seedNotification(UUID instanceId) {
// webhookId is a local UUID (not FK-constrained), outboundConnectionId is null
// (FK to outbound_connections ON DELETE SET NULL - null is valid)
AlertNotification notification = new AlertNotification(
UUID.randomUUID(), instanceId,
UUID.randomUUID(), null,
NotificationStatus.PENDING,
0, Instant.now(),
null, null,
null, null,
null, null, Instant.now());
return notificationRepo.save(notification);
}
private void seedUser(String userId) {
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING",
userId, userId + "@example.com", userId);
}
}

View File

@@ -1,11 +1,14 @@
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;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.time.Instant;
import java.util.List;
@@ -16,6 +19,9 @@ 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;
private UUID envId;
private UUID ruleId;

View File

@@ -1,11 +1,14 @@
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;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.time.Instant;
import java.util.List;
@@ -16,6 +19,9 @@ 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;
private UUID envId;
private UUID instanceId;

View File

@@ -1,9 +1,12 @@
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;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.util.List;
import java.util.UUID;
@@ -13,6 +16,9 @@ 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;
private UUID envId;
private UUID instanceId1;

View File

@@ -1,11 +1,14 @@
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;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.time.Instant;
import java.util.List;
@@ -16,6 +19,9 @@ 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;
private UUID envId;

View File

@@ -1,12 +1,15 @@
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;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
@@ -16,6 +19,9 @@ 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;
private UUID envId;

View File

@@ -1,12 +1,18 @@
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;
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;
private String testUserId;