feat(alerting): AlertSilenceController CRUD with time-range validation + audit (Task 34)

- POST/GET/DELETE /environments/{envSlug}/alerts/silences
- 422 when endsAt <= startsAt ("endsAt must be after startsAt")
- OPERATOR+ for create/delete, VIEWER+ for list
- Audit: ALERT_SILENCE_CREATE/DELETE with AuditCategory.ALERT_SILENCE_CHANGE
- 6 IT tests: create, viewer-list, viewer-cannot-create, bad time-range, delete, audit event

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

View File

@@ -0,0 +1,167 @@
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;
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.time.temporal.ChronoUnit;
import java.util.UUID;
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;
@Autowired private ObjectMapper objectMapper;
@Autowired private TestSecurityHelper securityHelper;
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 = "silence-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_silences 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 operatorCanCreate() throws Exception {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/silences",
HttpMethod.POST,
new HttpEntity<>(silenceBody(), securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.CREATED);
JsonNode body = objectMapper.readTree(resp.getBody());
assertThat(body.path("id").asText()).isNotBlank();
assertThat(body.path("reason").asText()).isEqualTo("planned-maintenance");
}
@Test
void viewerCanList() {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/silences",
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/silences",
HttpMethod.POST,
new HttpEntity<>(silenceBody(), securityHelper.authHeaders(viewerJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void endsAtBeforeStartsAtReturns422() {
Instant now = Instant.now();
String body = """
{"matcher":{},"reason":"bad","startsAt":"%s","endsAt":"%s"}
""".formatted(now.plus(1, ChronoUnit.HOURS), now); // endsAt before startsAt
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/silences",
HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_ENTITY);
assertThat(resp.getBody()).contains("endsAt must be after startsAt");
}
@Test
void deleteRemovesSilence() throws Exception {
ResponseEntity<String> create = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/silences",
HttpMethod.POST,
new HttpEntity<>(silenceBody(), securityHelper.authHeaders(operatorJwt)),
String.class);
String id = objectMapper.readTree(create.getBody()).path("id").asText();
ResponseEntity<String> del = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/silences/" + id,
HttpMethod.DELETE,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(del.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
}
@Test
void deleteEmitsAuditEvent() throws Exception {
ResponseEntity<String> create = restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/silences",
HttpMethod.POST,
new HttpEntity<>(silenceBody(), securityHelper.authHeaders(operatorJwt)),
String.class);
String id = objectMapper.readTree(create.getBody()).path("id").asText();
restTemplate.exchange(
"/api/v1/environments/" + envSlug + "/alerts/silences/" + id,
HttpMethod.DELETE,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
int count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM audit_log WHERE action = 'ALERT_SILENCE_DELETE' AND target = ?",
Integer.class, id);
assertThat(count).isGreaterThanOrEqualTo(1);
}
// -------------------------------------------------------------------------
// 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 silenceBody() {
Instant start = Instant.now();
Instant end = start.plus(2, ChronoUnit.HOURS);
return """
{"matcher":{},"reason":"planned-maintenance","startsAt":"%s","endsAt":"%s"}
""".formatted(start, end);
}
}