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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.cameleer.server.app.alerting.dto;
|
||||||
|
|
||||||
|
public record RenderPreviewResponse(String title, String message) {}
|
||||||
@@ -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() {}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -354,7 +354,9 @@ public class ClickHouseSearchIndex implements SearchIndex {
|
|||||||
|
|
||||||
// attributes is a JSON String column. JSONExtractString does not accept a ? placeholder for
|
// 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.
|
// 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()) {
|
for (Map.Entry<String, String> entry : spec.attributes().entrySet()) {
|
||||||
String escapedKey = entry.getKey().replace("'", "\\'");
|
String escapedKey = entry.getKey().replace("'", "\\'");
|
||||||
conditions.add("JSONExtractString(attributes, '" + escapedKey + "') = ?");
|
conditions.add("JSONExtractString(attributes, '" + escapedKey + "') = ?");
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user