feat(alerting): AlertController in-app inbox with ack/read/bulk-read (Task 33)

- GET /environments/{envSlug}/alerts — inbox filtered by userId/groupIds/roleNames via InAppInboxQuery
- GET /unread-count — memoized unread count (5s TTL)
- GET /{id}, POST /{id}/ack, POST /{id}/read, POST /bulk-read
- bulkRead filters instanceIds to env before delegating to AlertReadRepository
- VIEWER+ for all endpoints; env isolation enforced by requireInstance
- 7 IT tests: list, env isolation, unread-count, ack flow, read, bulk-read, viewer access

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

View File

@@ -0,0 +1,208 @@
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.AlertReadRepository;
import com.cameleer.server.core.alerting.AlertSeverity;
import com.cameleer.server.core.alerting.AlertState;
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 AlertControllerIT 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 AlertReadRepository readRepo;
private String operatorJwt;
private String viewerJwt;
private String envSlugA;
private String envSlugB;
private UUID envIdA;
private UUID envIdB;
@BeforeEach
void setUp() {
operatorJwt = securityHelper.operatorToken();
viewerJwt = securityHelper.viewerToken();
seedUser("test-operator");
seedUser("test-viewer");
envSlugA = "alert-env-a-" + UUID.randomUUID().toString().substring(0, 6);
envSlugB = "alert-env-b-" + UUID.randomUUID().toString().substring(0, 6);
envIdA = UUID.randomUUID();
envIdB = UUID.randomUUID();
jdbcTemplate.update(
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING",
envIdA, envSlugA, envSlugA);
jdbcTemplate.update(
"INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING",
envIdB, envSlugB, envSlugB);
}
@AfterEach
void cleanUp() {
jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN (SELECT id FROM alert_instances WHERE environment_id IN (?, ?))", envIdA, envIdB);
jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id IN (?, ?)", envIdA, envIdB);
jdbcTemplate.update("DELETE FROM environments WHERE id IN (?, ?)", envIdA, envIdB);
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')");
}
@Test
void listReturnsAlertsForEnv() throws Exception {
AlertInstance instance = seedInstance(envIdA);
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts",
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();
// The alert we seeded should be present
boolean found = false;
for (JsonNode node : body) {
if (node.path("id").asText().equals(instance.id().toString())) {
found = true;
break;
}
}
assertThat(found).as("seeded alert must appear in env-A inbox").isTrue();
}
@Test
void envIsolation() throws Exception {
// Seed an alert in env-A
AlertInstance instanceA = seedInstance(envIdA);
// env-B inbox should NOT see env-A's alert
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlugB + "/alerts",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(resp.getBody());
for (JsonNode node : body) {
assertThat(node.path("id").asText())
.as("env-A alert must not appear in env-B inbox")
.isNotEqualTo(instanceA.id().toString());
}
}
@Test
void unreadCountReturnsNumber() {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts/unread-count",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void ackFlow() throws Exception {
AlertInstance instance = seedInstance(envIdA);
ResponseEntity<String> ack = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id() + "/ack",
HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(ack.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(ack.getBody());
assertThat(body.path("state").asText()).isEqualTo("ACKNOWLEDGED");
}
@Test
void readMarksSingleAlert() throws Exception {
AlertInstance instance = seedInstance(envIdA);
ResponseEntity<String> read = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id() + "/read",
HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(read.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void bulkRead() throws Exception {
AlertInstance i1 = seedInstance(envIdA);
AlertInstance i2 = seedInstance(envIdA);
String body = """
{"instanceIds":["%s","%s"]}
""".formatted(i1.id(), i2.id());
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts/bulk-read",
HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void viewerCanRead() {
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
}
// -------------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------------
private AlertInstance seedInstance(UUID envId) {
// target by userId so the inbox SQL (? = ANY(target_user_ids)) matches the test-operator JWT
// (JWT subject is "user:test-operator", stripped to "test-operator" by currentUserId())
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("test-operator"), List.of(), List.of());
return instanceRepo.save(instance);
}
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);
}
}