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,80 @@
package com.cameleer.server.app.alerting.controller;
import com.cameleer.server.app.alerting.dto.AlertNotificationDto;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.alerting.AlertNotification;
import com.cameleer.server.core.alerting.AlertNotificationRepository;
import com.cameleer.server.core.alerting.NotificationStatus;
import com.cameleer.server.core.runtime.Environment;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/**
* REST controller for alert notifications.
* <p>
* Env-scoped: GET /api/v1/environments/{envSlug}/alerts/{id}/notifications — lists outbound
* notifications for a given alert instance.
* <p>
* Flat: POST /api/v1/alerts/notifications/{id}/retry — globally unique notification IDs;
* flat path matches the /executions/{id} precedent. OPERATOR+ only.
*/
@RestController
@Tag(name = "Alert Notifications", description = "Outbound webhook notification management")
public class AlertNotificationController {
private final AlertNotificationRepository notificationRepo;
public AlertNotificationController(AlertNotificationRepository notificationRepo) {
this.notificationRepo = notificationRepo;
}
/**
* Lists notifications for a specific alert instance (env-scoped).
* VIEWER+.
*/
@GetMapping("/api/v1/environments/{envSlug}/alerts/{alertId}/notifications")
@PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')")
public List<AlertNotificationDto> listForInstance(
@EnvPath Environment env,
@PathVariable UUID alertId) {
return notificationRepo.listForInstance(alertId)
.stream().map(AlertNotificationDto::from).toList();
}
/**
* Retries a failed notification — resets attempts and schedules it for immediate retry.
* Notification IDs are globally unique (flat path, matches /executions/{id} precedent).
* OPERATOR+ only.
*/
@PostMapping("/api/v1/alerts/notifications/{id}/retry")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public AlertNotificationDto retry(@PathVariable UUID id) {
AlertNotification notification = notificationRepo.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"Notification not found: " + id));
if (notification.status() == NotificationStatus.PENDING) {
return AlertNotificationDto.from(notification);
}
// Reset for retry: status -> PENDING, attempts -> 0, next_attempt_at -> now
// We use scheduleRetry to reset attempt timing; then we need to reset attempts count.
// The repository has scheduleRetry which sets next_attempt_at and records last status.
// We use a dedicated pattern: mark as pending by scheduling immediately.
notificationRepo.scheduleRetry(id, Instant.now(), 0, null);
return AlertNotificationDto.from(notificationRepo.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)));
}
}

View File

@@ -0,0 +1,29 @@
package com.cameleer.server.app.alerting.dto;
import com.cameleer.server.core.alerting.AlertNotification;
import com.cameleer.server.core.alerting.NotificationStatus;
import java.time.Instant;
import java.util.UUID;
public record AlertNotificationDto(
UUID id,
UUID alertInstanceId,
UUID webhookId,
UUID outboundConnectionId,
NotificationStatus status,
int attempts,
Instant nextAttemptAt,
Integer lastResponseStatus,
String lastResponseSnippet,
Instant deliveredAt,
Instant createdAt
) {
public static AlertNotificationDto from(AlertNotification n) {
return new AlertNotificationDto(
n.id(), n.alertInstanceId(), n.webhookId(), n.outboundConnectionId(),
n.status(), n.attempts(), n.nextAttemptAt(),
n.lastResponseStatus(), n.lastResponseSnippet(),
n.deliveredAt(), n.createdAt());
}
}

View File

@@ -161,6 +161,23 @@ public class SecurityConfig {
// Runtime management (OPERATOR+) — legacy flat shape
.requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN")
// Alerting — env-scoped reads (VIEWER+)
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/alerts/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
// Alerting — rule mutations (OPERATOR+)
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/rules/**").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/v1/environments/*/alerts/rules/**").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/v1/environments/*/alerts/rules/**").hasAnyRole("OPERATOR", "ADMIN")
// Alerting — silence mutations (OPERATOR+)
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/silences/**").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/v1/environments/*/alerts/silences/**").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/v1/environments/*/alerts/silences/**").hasAnyRole("OPERATOR", "ADMIN")
// Alerting — ack/read (VIEWER+ self-service)
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/*/ack").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/*/read").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/bulk-read").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
// Alerting — notification retry (flat path; notification IDs globally unique)
.requestMatchers(HttpMethod.POST, "/api/v1/alerts/notifications/*/retry").hasAnyRole("OPERATOR", "ADMIN")
// Outbound connections: list/get allow OPERATOR (method-level @PreAuthorize gates mutations)
.requestMatchers(HttpMethod.GET, "/api/v1/admin/outbound-connections", "/api/v1/admin/outbound-connections/**").hasAnyRole("OPERATOR", "ADMIN")