feat(alerting): AlertRuleController with attribute-key SQL injection validation (Task 32)

- POST/GET/PUT/DELETE /environments/{envSlug}/alerts/rules CRUD
- POST /{id}/enable, /{id}/disable, /{id}/render-preview, /{id}/test-evaluate
- Attribute-key validation: rejects keys not matching ^[a-zA-Z0-9._-]+$ at rule-save time
  (CRITICAL: ExchangeMatchCondition attribute keys are inlined into ClickHouse SQL)
- Webhook validation: verifies outboundConnectionId exists and is allowed in env
- Null-safe notification template defaults to "" for NOT NULL DB constraint
- Fixed misleading comment in ClickHouseSearchIndex to document validation contract
- OPERATOR+ for mutations, VIEWER+ for reads
- Audit: ALERT_RULE_CREATE/UPDATE/DELETE/ENABLE/DISABLE with AuditCategory.ALERT_RULE_CHANGE
- 11 IT tests covering RBAC, SQL-injection prevention, enable/disable, audit, render-preview

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 21:28:46 +02:00
parent d3dd8882bd
commit c1b34f592b
11 changed files with 812 additions and 1 deletions

View File

@@ -0,0 +1,369 @@
package com.cameleer.server.app.alerting.controller;
import com.cameleer.server.app.alerting.dto.AlertRuleRequest;
import com.cameleer.server.app.alerting.dto.AlertRuleResponse;
import com.cameleer.server.app.alerting.dto.RenderPreviewRequest;
import com.cameleer.server.app.alerting.dto.RenderPreviewResponse;
import com.cameleer.server.app.alerting.dto.TestEvaluateRequest;
import com.cameleer.server.app.alerting.dto.TestEvaluateResponse;
import com.cameleer.server.app.alerting.dto.WebhookBindingRequest;
import com.cameleer.server.app.alerting.eval.ConditionEvaluator;
import com.cameleer.server.app.alerting.eval.EvalContext;
import com.cameleer.server.app.alerting.eval.EvalResult;
import com.cameleer.server.app.alerting.eval.TickCache;
import com.cameleer.server.app.alerting.notify.MustacheRenderer;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.admin.AuditCategory;
import com.cameleer.server.core.admin.AuditResult;
import com.cameleer.server.core.admin.AuditService;
import com.cameleer.server.core.alerting.AlertCondition;
import com.cameleer.server.core.alerting.AlertRule;
import com.cameleer.server.core.alerting.AlertRuleRepository;
import com.cameleer.server.core.alerting.AlertRuleTarget;
import com.cameleer.server.core.alerting.ConditionKind;
import com.cameleer.server.core.alerting.ExchangeMatchCondition;
import com.cameleer.server.core.alerting.WebhookBinding;
import com.cameleer.server.core.outbound.OutboundConnection;
import com.cameleer.server.core.outbound.OutboundConnectionService;
import com.cameleer.server.core.runtime.Environment;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
/**
* REST controller for alert rules (env-scoped).
* <p>
* CRITICAL: {@link ExchangeMatchCondition#filter()} attribute KEYS are inlined into ClickHouse SQL.
* They are validated here at save time to match {@code ^[a-zA-Z0-9._-]+$} before any SQL is built.
*/
@RestController
@RequestMapping("/api/v1/environments/{envSlug}/alerts/rules")
@Tag(name = "Alert Rules", description = "Alert rule management (env-scoped)")
@PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')")
public class AlertRuleController {
/**
* Attribute KEY allowlist. Keys are inlined into ClickHouse SQL via
* {@code JSONExtractString(attributes, '<key>')}, so this pattern is a hard security gate.
* Values are always parameter-bound and safe.
*/
private static final Pattern ATTR_KEY = Pattern.compile("^[a-zA-Z0-9._-]+$");
private final AlertRuleRepository ruleRepo;
private final OutboundConnectionService connectionService;
private final AuditService auditService;
private final MustacheRenderer renderer;
private final Map<ConditionKind, ConditionEvaluator<?>> evaluators;
private final Clock clock;
private final String tenantId;
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
public AlertRuleController(AlertRuleRepository ruleRepo,
OutboundConnectionService connectionService,
AuditService auditService,
MustacheRenderer renderer,
List<ConditionEvaluator<?>> evaluatorList,
Clock alertingClock,
@Value("${cameleer.server.tenant.id:default}") String tenantId) {
this.ruleRepo = ruleRepo;
this.connectionService = connectionService;
this.auditService = auditService;
this.renderer = renderer;
this.evaluators = new java.util.EnumMap<>(ConditionKind.class);
for (ConditionEvaluator<?> e : evaluatorList) {
this.evaluators.put(e.kind(), e);
}
this.clock = alertingClock;
this.tenantId = tenantId;
}
// -------------------------------------------------------------------------
// List / Get
// -------------------------------------------------------------------------
@GetMapping
public List<AlertRuleResponse> list(@EnvPath Environment env) {
return ruleRepo.listByEnvironment(env.id())
.stream().map(AlertRuleResponse::from).toList();
}
@GetMapping("/{id}")
public AlertRuleResponse get(@EnvPath Environment env, @PathVariable UUID id) {
AlertRule rule = requireRule(id, env.id());
return AlertRuleResponse.from(rule);
}
// -------------------------------------------------------------------------
// Create / Update / Delete
// -------------------------------------------------------------------------
@PostMapping
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public ResponseEntity<AlertRuleResponse> create(
@EnvPath Environment env,
@Valid @RequestBody AlertRuleRequest req,
HttpServletRequest httpRequest) {
validateAttributeKeys(req.condition());
validateWebhooks(req.webhooks(), env.id());
AlertRule draft = buildRule(null, env.id(), req, currentUserId());
AlertRule saved = ruleRepo.save(draft);
auditService.log("ALERT_RULE_CREATE", AuditCategory.ALERT_RULE_CHANGE,
saved.id().toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(AlertRuleResponse.from(saved));
}
@PutMapping("/{id}")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public AlertRuleResponse update(
@EnvPath Environment env,
@PathVariable UUID id,
@Valid @RequestBody AlertRuleRequest req,
HttpServletRequest httpRequest) {
AlertRule existing = requireRule(id, env.id());
validateAttributeKeys(req.condition());
validateWebhooks(req.webhooks(), env.id());
AlertRule updated = buildRule(existing, env.id(), req, currentUserId());
AlertRule saved = ruleRepo.save(updated);
auditService.log("ALERT_RULE_UPDATE", AuditCategory.ALERT_RULE_CHANGE,
id.toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest);
return AlertRuleResponse.from(saved);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public ResponseEntity<Void> delete(
@EnvPath Environment env,
@PathVariable UUID id,
HttpServletRequest httpRequest) {
requireRule(id, env.id());
ruleRepo.delete(id);
auditService.log("ALERT_RULE_DELETE", AuditCategory.ALERT_RULE_CHANGE,
id.toString(), Map.of(), AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
// -------------------------------------------------------------------------
// Enable / Disable
// -------------------------------------------------------------------------
@PostMapping("/{id}/enable")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public AlertRuleResponse enable(
@EnvPath Environment env,
@PathVariable UUID id,
HttpServletRequest httpRequest) {
AlertRule rule = requireRule(id, env.id());
AlertRule updated = withEnabled(rule, true);
AlertRule saved = ruleRepo.save(updated);
auditService.log("ALERT_RULE_ENABLE", AuditCategory.ALERT_RULE_CHANGE,
id.toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest);
return AlertRuleResponse.from(saved);
}
@PostMapping("/{id}/disable")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public AlertRuleResponse disable(
@EnvPath Environment env,
@PathVariable UUID id,
HttpServletRequest httpRequest) {
AlertRule rule = requireRule(id, env.id());
AlertRule updated = withEnabled(rule, false);
AlertRule saved = ruleRepo.save(updated);
auditService.log("ALERT_RULE_DISABLE", AuditCategory.ALERT_RULE_CHANGE,
id.toString(), Map.of("name", saved.name()), AuditResult.SUCCESS, httpRequest);
return AlertRuleResponse.from(saved);
}
// -------------------------------------------------------------------------
// Render Preview
// -------------------------------------------------------------------------
@PostMapping("/{id}/render-preview")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public RenderPreviewResponse renderPreview(
@EnvPath Environment env,
@PathVariable UUID id,
@RequestBody RenderPreviewRequest req) {
AlertRule rule = requireRule(id, env.id());
Map<String, Object> ctx = req.context();
String title = renderer.render(rule.notificationTitleTmpl(), ctx);
String message = renderer.render(rule.notificationMessageTmpl(), ctx);
return new RenderPreviewResponse(title, message);
}
// -------------------------------------------------------------------------
// Test Evaluate
// -------------------------------------------------------------------------
@PostMapping("/{id}/test-evaluate")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
@SuppressWarnings({"rawtypes", "unchecked"})
public TestEvaluateResponse testEvaluate(
@EnvPath Environment env,
@PathVariable UUID id,
@RequestBody TestEvaluateRequest req) {
AlertRule rule = requireRule(id, env.id());
ConditionEvaluator evaluator = evaluators.get(rule.conditionKind());
if (evaluator == null) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY,
"No evaluator registered for condition kind: " + rule.conditionKind());
}
EvalContext ctx = new EvalContext(tenantId, Instant.now(clock), new TickCache());
EvalResult result = evaluator.evaluate(rule.condition(), rule, ctx);
return TestEvaluateResponse.from(result);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
/**
* Validates that all attribute keys in an {@link ExchangeMatchCondition} match
* {@code ^[a-zA-Z0-9._-]+$}. Keys are inlined into ClickHouse SQL, making this
* a mandatory SQL-injection prevention gate.
*/
private void validateAttributeKeys(AlertCondition condition) {
if (condition instanceof ExchangeMatchCondition emc && emc.filter() != null) {
for (String key : emc.filter().attributes().keySet()) {
if (!ATTR_KEY.matcher(key).matches()) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY,
"Invalid attribute key (must match [a-zA-Z0-9._-]+): " + key);
}
}
}
}
/**
* Validates that each webhook outboundConnectionId exists and is allowed in this environment.
*/
private void validateWebhooks(List<WebhookBindingRequest> webhooks, UUID envId) {
for (WebhookBindingRequest wb : webhooks) {
OutboundConnection conn;
try {
conn = connectionService.get(wb.outboundConnectionId());
} catch (org.springframework.web.server.ResponseStatusException ex) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY,
"outboundConnectionId not found: " + wb.outboundConnectionId());
} catch (Exception ex) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY,
"outboundConnectionId not found: " + wb.outboundConnectionId());
}
if (!conn.isAllowedInEnvironment(envId)) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY,
"outboundConnection " + wb.outboundConnectionId()
+ " is not allowed in this environment");
}
}
}
private AlertRule requireRule(UUID id, UUID envId) {
AlertRule rule = ruleRepo.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"Alert rule not found: " + id));
if (!rule.environmentId().equals(envId)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"Alert rule not found in this environment: " + id);
}
return rule;
}
private AlertRule buildRule(AlertRule existing, UUID envId, AlertRuleRequest req, String userId) {
UUID id = existing != null ? existing.id() : UUID.randomUUID();
Instant now = Instant.now(clock);
Instant createdAt = existing != null ? existing.createdAt() : now;
String createdBy = existing != null ? existing.createdBy() : userId;
boolean enabled = existing != null ? existing.enabled() : true;
List<WebhookBinding> webhooks = req.webhooks().stream()
.map(wb -> new WebhookBinding(
UUID.randomUUID(),
wb.outboundConnectionId(),
wb.bodyOverride(),
wb.headerOverrides()))
.toList();
List<AlertRuleTarget> targets = req.targets() == null ? List.of() : req.targets();
int evalInterval = req.evaluationIntervalSeconds() != null
? req.evaluationIntervalSeconds() : 60;
int forDuration = req.forDurationSeconds() != null
? req.forDurationSeconds() : 0;
int reNotify = req.reNotifyMinutes() != null
? req.reNotifyMinutes() : 0;
String titleTmpl = req.notificationTitleTmpl() != null ? req.notificationTitleTmpl() : "";
String messageTmpl = req.notificationMessageTmpl() != null ? req.notificationMessageTmpl() : "";
return new AlertRule(
id, envId, req.name(), req.description(),
req.severity(), enabled,
req.conditionKind(), req.condition(),
evalInterval, forDuration, reNotify,
titleTmpl, messageTmpl,
webhooks, targets,
now, null, null, Map.of(),
createdAt, createdBy, now, userId);
}
private AlertRule withEnabled(AlertRule r, boolean enabled) {
Instant now = Instant.now(clock);
return new AlertRule(
r.id(), r.environmentId(), r.name(), r.description(),
r.severity(), enabled, r.conditionKind(), r.condition(),
r.evaluationIntervalSeconds(), r.forDurationSeconds(), r.reNotifyMinutes(),
r.notificationTitleTmpl(), r.notificationMessageTmpl(),
r.webhooks(), r.targets(),
r.nextEvaluationAt(), r.claimedBy(), r.claimedUntil(), r.evalState(),
r.createdAt(), r.createdBy(), now, currentUserId());
}
private String currentUserId() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getName() == null) {
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "No authentication");
}
String name = auth.getName();
return name.startsWith("user:") ? name.substring(5) : name;
}
}

View File

@@ -0,0 +1,32 @@
package com.cameleer.server.app.alerting.dto;
import com.cameleer.server.core.alerting.AlertCondition;
import com.cameleer.server.core.alerting.AlertRuleTarget;
import com.cameleer.server.core.alerting.AlertSeverity;
import com.cameleer.server.core.alerting.ConditionKind;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.UUID;
public record AlertRuleRequest(
@NotBlank String name,
String description,
@NotNull AlertSeverity severity,
@NotNull ConditionKind conditionKind,
@NotNull @Valid AlertCondition condition,
Integer evaluationIntervalSeconds,
Integer forDurationSeconds,
Integer reNotifyMinutes,
String notificationTitleTmpl,
String notificationMessageTmpl,
List<WebhookBindingRequest> webhooks,
List<AlertRuleTarget> targets
) {
public AlertRuleRequest {
webhooks = webhooks == null ? List.of() : List.copyOf(webhooks);
targets = targets == null ? List.of() : List.copyOf(targets);
}
}

View File

@@ -0,0 +1,46 @@
package com.cameleer.server.app.alerting.dto;
import com.cameleer.server.core.alerting.AlertCondition;
import com.cameleer.server.core.alerting.AlertRule;
import com.cameleer.server.core.alerting.AlertRuleTarget;
import com.cameleer.server.core.alerting.AlertSeverity;
import com.cameleer.server.core.alerting.ConditionKind;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record AlertRuleResponse(
UUID id,
UUID environmentId,
String name,
String description,
AlertSeverity severity,
boolean enabled,
ConditionKind conditionKind,
AlertCondition condition,
int evaluationIntervalSeconds,
int forDurationSeconds,
int reNotifyMinutes,
String notificationTitleTmpl,
String notificationMessageTmpl,
List<WebhookBindingResponse> webhooks,
List<AlertRuleTarget> targets,
Instant createdAt,
String createdBy,
Instant updatedAt,
String updatedBy
) {
public static AlertRuleResponse from(AlertRule r) {
List<WebhookBindingResponse> webhooks = r.webhooks().stream()
.map(WebhookBindingResponse::from)
.toList();
return new AlertRuleResponse(
r.id(), r.environmentId(), r.name(), r.description(),
r.severity(), r.enabled(), r.conditionKind(), r.condition(),
r.evaluationIntervalSeconds(), r.forDurationSeconds(), r.reNotifyMinutes(),
r.notificationTitleTmpl(), r.notificationMessageTmpl(),
webhooks, r.targets(),
r.createdAt(), r.createdBy(), r.updatedAt(), r.updatedBy());
}
}

View File

@@ -0,0 +1,13 @@
package com.cameleer.server.app.alerting.dto;
import java.util.Map;
/**
* Canned context for rendering a Mustache template preview without firing a real alert.
* All fields are optional — missing context keys render as empty string.
*/
public record RenderPreviewRequest(Map<String, Object> context) {
public RenderPreviewRequest {
context = context == null ? Map.of() : Map.copyOf(context);
}
}

View File

@@ -0,0 +1,3 @@
package com.cameleer.server.app.alerting.dto;
public record RenderPreviewResponse(String title, String message) {}

View File

@@ -0,0 +1,8 @@
package com.cameleer.server.app.alerting.dto;
/**
* Request body for POST {id}/test-evaluate.
* Currently empty — the evaluator runs against live data using the saved rule definition.
* Reserved for future overrides (e.g., custom time window).
*/
public record TestEvaluateRequest() {}

View File

@@ -0,0 +1,24 @@
package com.cameleer.server.app.alerting.dto;
import com.cameleer.server.app.alerting.eval.EvalResult;
/**
* Result of a one-shot evaluator run against live data (does not persist any state).
*/
public record TestEvaluateResponse(String resultKind, String detail) {
public static TestEvaluateResponse from(EvalResult result) {
if (result instanceof EvalResult.Firing f) {
return new TestEvaluateResponse("FIRING",
"currentValue=" + f.currentValue() + " threshold=" + f.threshold());
} else if (result instanceof EvalResult.Clear) {
return new TestEvaluateResponse("CLEAR", null);
} else if (result instanceof EvalResult.Error e) {
return new TestEvaluateResponse("ERROR",
e.cause() != null ? e.cause().getMessage() : "unknown error");
} else if (result instanceof EvalResult.Batch b) {
return new TestEvaluateResponse("BATCH", b.firings().size() + " firing(s)");
}
return new TestEvaluateResponse("UNKNOWN", result.getClass().getSimpleName());
}
}

View File

@@ -0,0 +1,16 @@
package com.cameleer.server.app.alerting.dto;
import jakarta.validation.constraints.NotNull;
import java.util.Map;
import java.util.UUID;
public record WebhookBindingRequest(
@NotNull UUID outboundConnectionId,
String bodyOverride,
Map<String, String> headerOverrides
) {
public WebhookBindingRequest {
headerOverrides = headerOverrides == null ? Map.of() : Map.copyOf(headerOverrides);
}
}

View File

@@ -0,0 +1,18 @@
package com.cameleer.server.app.alerting.dto;
import com.cameleer.server.core.alerting.WebhookBinding;
import java.util.Map;
import java.util.UUID;
public record WebhookBindingResponse(
UUID id,
UUID outboundConnectionId,
String bodyOverride,
Map<String, String> headerOverrides
) {
public static WebhookBindingResponse from(WebhookBinding wb) {
return new WebhookBindingResponse(
wb.id(), wb.outboundConnectionId(), wb.bodyOverride(), wb.headerOverrides());
}
}

View File

@@ -354,7 +354,9 @@ public class ClickHouseSearchIndex implements SearchIndex {
// attributes is a JSON String column. JSONExtractString does not accept a ? placeholder for
// the key argument via ClickHouse JDBC — inline the key as a single-quoted literal.
// Keys originate from internal AlertMatchSpec (evaluator-constructed, not user HTTP input).
// Attribute KEYS originate from user-authored rule JSONB (via ExchangeMatchCondition.filter.attributes);
// they are validated at rule save time by AlertRuleController to match ^[a-zA-Z0-9._-]+$
// before ever reaching this point. Values are parameter-bound.
for (Map.Entry<String, String> entry : spec.attributes().entrySet()) {
String escapedKey = entry.getKey().replace("'", "\\'");
conditions.add("JSONExtractString(attributes, '" + escapedKey + "') = ?");

View File

@@ -0,0 +1,280 @@
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;
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.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
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;
@Autowired private ObjectMapper objectMapper;
@Autowired private TestSecurityHelper securityHelper;
@Autowired private AuditRepository auditRepository;
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");
// Create a test environment
envSlug = "test-env-" + UUID.randomUUID().toString().substring(0, 8);
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_rules 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')");
}
// --- Happy path: POST creates rule, returns 201 ---
@Test
void operatorCanCreateRule() throws Exception {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules",
HttpMethod.POST,
new HttpEntity<>(routeMetricRuleBody("test-rule"), securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
JsonNode body = objectMapper.readTree(resp.getBody());
assertThat(body.path("name").asText()).isEqualTo("test-rule");
assertThat(body.path("id").asText()).isNotBlank();
assertThat(body.path("enabled").asBoolean()).isTrue();
assertThat(body.path("severity").asText()).isEqualTo("WARNING");
}
@Test
void operatorCanListRules() {
// Create a rule first
restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules",
HttpMethod.POST,
new HttpEntity<>(routeMetricRuleBody("list-test"), securityHelper.authHeaders(operatorJwt)),
String.class);
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void viewerCanList() {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void viewerCannotCreate() {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules",
HttpMethod.POST,
new HttpEntity<>(routeMetricRuleBody("viewer-rule"), securityHelper.authHeaders(viewerJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
// --- Webhook validation ---
@Test
void unknownOutboundConnectionIdReturns422() {
String body = """
{"name":"bad-webhook","severity":"WARNING","conditionKind":"ROUTE_METRIC",
"condition":{"kind":"ROUTE_METRIC","scope":{},
"metric":"ERROR_RATE","comparator":"GT","threshold":0.05,"windowSeconds":60},
"webhooks":[{"outboundConnectionId":"%s"}]}
""".formatted(UUID.randomUUID());
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules",
HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
}
// --- Attribute key SQL injection prevention ---
@Test
void attributeKeyWithSqlMetaReturns422() {
String body = """
{"name":"sqli-test","severity":"WARNING","conditionKind":"EXCHANGE_MATCH",
"condition":{"kind":"EXCHANGE_MATCH","scope":{},
"filter":{"status":"FAILED","attributes":{"foo'; DROP TABLE executions; --":"x"}},
"fireMode":"PER_EXCHANGE","perExchangeLingerSeconds":60}}
""";
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules",
HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
assertThat(resp.getBody()).contains("Invalid attribute key");
}
@Test
void validAttributeKeyIsAccepted() throws Exception {
String body = """
{"name":"valid-attr","severity":"WARNING","conditionKind":"EXCHANGE_MATCH",
"condition":{"kind":"EXCHANGE_MATCH","scope":{},
"filter":{"status":"FAILED","attributes":{"order.type":"x"}},
"fireMode":"PER_EXCHANGE","perExchangeLingerSeconds":60}}
""";
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules",
HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
}
// --- Enable / Disable ---
@Test
void enableAndDisable() throws Exception {
ResponseEntity<String> create = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules",
HttpMethod.POST,
new HttpEntity<>(routeMetricRuleBody("toggle-rule"), securityHelper.authHeaders(operatorJwt)),
String.class);
String id = objectMapper.readTree(create.getBody()).path("id").asText();
ResponseEntity<String> disabled = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules/" + id + "/disable",
HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(disabled.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(objectMapper.readTree(disabled.getBody()).path("enabled").asBoolean()).isFalse();
ResponseEntity<String> enabled = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules/" + id + "/enable",
HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(enabled.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(objectMapper.readTree(enabled.getBody()).path("enabled").asBoolean()).isTrue();
}
// --- Delete emits audit event ---
@Test
void deleteEmitsAuditEvent() throws Exception {
ResponseEntity<String> create = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules",
HttpMethod.POST,
new HttpEntity<>(routeMetricRuleBody("audit-rule"), securityHelper.authHeaders(operatorJwt)),
String.class);
String id = objectMapper.readTree(create.getBody()).path("id").asText();
restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules/" + id,
HttpMethod.DELETE,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
int count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log WHERE action = 'ALERT_RULE_DELETE' AND target = ?",
Integer.class, id);
assertThat(count).isGreaterThanOrEqualTo(1);
}
// --- Render preview ---
@Test
void renderPreview() throws Exception {
ResponseEntity<String> create = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules",
HttpMethod.POST,
new HttpEntity<>(routeMetricRuleBody("preview-rule"), securityHelper.authHeaders(operatorJwt)),
String.class);
String id = objectMapper.readTree(create.getBody()).path("id").asText();
ResponseEntity<String> preview = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/rules/" + id + "/render-preview",
HttpMethod.POST,
new HttpEntity<>("{\"context\":{}}", securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(preview.getStatusCode()).isEqualTo(HttpStatus.OK);
}
// --- Unknown env returns 404 ---
@Test
void unknownEnvReturns404() {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/nonexistent-env-slug/alerts/rules",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
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);
}
private static String routeMetricRuleBody(String name) {
return """
{"name":"%s","severity":"WARNING","conditionKind":"ROUTE_METRIC",
"condition":{"kind":"ROUTE_METRIC","scope":{},
"metric":"ERROR_RATE","comparator":"GT","threshold":0.05,"windowSeconds":60}}
""".formatted(name);
}
}