feat(alerts): controller — DELETE/bulk-delete/bulk-ack/restore + acked/read filters + readAt on DTO

- GET /alerts gains tri-state acked + read query params
- new endpoints: DELETE /{id} (soft-delete), POST /bulk-delete, POST /bulk-ack, POST /{id}/restore
- requireLiveInstance 404s on soft-deleted rows; restore() reads the row regardless
- BulkReadRequest → BulkIdsRequest (shared body for bulk read/ack/delete)
- AlertDto gains readAt; deletedAt stays off the wire
- InAppInboxQuery.listInbox threads acked/read through to the repo (7-arg, no more null placeholders)
- SecurityConfig: new matchers for bulk-ack (VIEWER+), DELETE/bulk-delete/restore (OPERATOR+)
- AlertControllerIT: persistence assertions on /read + /bulk-read; full coverage for new endpoints
- InAppInboxQueryTest: updated to 7-arg listInbox signature

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-21 18:15:16 +02:00
parent dd2a5536ab
commit efd8396045
9 changed files with 361 additions and 59 deletions

View File

@@ -66,7 +66,7 @@ Env-scoped read-path controllers (`AlertController`, `AlertRuleController`, `Ale
- `AgentMetricsController` — GET `/api/v1/environments/{envSlug}/agents/{agentId}/metrics` (JVM/Camel metrics). Rejects cross-env agents (404) as defence-in-depth. - `AgentMetricsController` — GET `/api/v1/environments/{envSlug}/agents/{agentId}/metrics` (JVM/Camel metrics). Rejects cross-env agents (404) as defence-in-depth.
- `DiagramRenderController` — GET `/api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram` (env-scoped lookup). Also GET `/api/v1/diagrams/{contentHash}/render` (flat — content hashes are globally unique). - `DiagramRenderController` — GET `/api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram` (env-scoped lookup). Also GET `/api/v1/diagrams/{contentHash}/render` (flat — content hashes are globally unique).
- `AlertRuleController``/api/v1/environments/{envSlug}/alerts/rules`. GET list / POST create / GET `{id}` / PUT `{id}` / DELETE `{id}` / POST `{id}/enable` / POST `{id}/disable` / POST `{id}/render-preview` / POST `{id}/test-evaluate`. OPERATOR+ for mutations, VIEWER+ for reads. CRITICAL: attribute keys in `ExchangeMatchCondition.filter.attributes` are validated at rule-save time against `^[a-zA-Z0-9._-]+$` — they are later inlined into ClickHouse SQL. `AgentLifecycleCondition` is allowlist-only — the `AgentLifecycleEventType` enum (REGISTERED / RE_REGISTERED / DEREGISTERED / WENT_STALE / WENT_DEAD / RECOVERED) plus the record compact ctor (non-empty `eventTypes`, `withinSeconds ≥ 1`) do the validation; custom agent-emitted event types are tracked in backlog issue #145. Webhook validation: verifies `outboundConnectionId` exists and `isAllowedInEnvironment`. Null notification templates default to `""` (NOT NULL constraint). Audit: `ALERT_RULE_CHANGE`. - `AlertRuleController``/api/v1/environments/{envSlug}/alerts/rules`. GET list / POST create / GET `{id}` / PUT `{id}` / DELETE `{id}` / POST `{id}/enable` / POST `{id}/disable` / POST `{id}/render-preview` / POST `{id}/test-evaluate`. OPERATOR+ for mutations, VIEWER+ for reads. CRITICAL: attribute keys in `ExchangeMatchCondition.filter.attributes` are validated at rule-save time against `^[a-zA-Z0-9._-]+$` — they are later inlined into ClickHouse SQL. `AgentLifecycleCondition` is allowlist-only — the `AgentLifecycleEventType` enum (REGISTERED / RE_REGISTERED / DEREGISTERED / WENT_STALE / WENT_DEAD / RECOVERED) plus the record compact ctor (non-empty `eventTypes`, `withinSeconds ≥ 1`) do the validation; custom agent-emitted event types are tracked in backlog issue #145. Webhook validation: verifies `outboundConnectionId` exists and `isAllowedInEnvironment`. Null notification templates default to `""` (NOT NULL constraint). Audit: `ALERT_RULE_CHANGE`.
- `AlertController``/api/v1/environments/{envSlug}/alerts`. GET list (inbox filtered by userId/groupIds/roleNames via `InAppInboxQuery`; optional multi-value `state` + `severity` query params push filtering into PostgreSQL via `listForInbox` with `state::text = ANY(?)` / `severity::text = ANY(?)`) / GET `/unread-count` / GET `{id}` / POST `{id}/ack` / POST `{id}/read` / POST `/bulk-read`. VIEWER+ for all. Inbox SQL: `? = ANY(target_user_ids) OR target_group_ids && ? OR target_role_names && ?` — requires at least one matching target (no broadcast concept). - `AlertController``/api/v1/environments/{envSlug}/alerts`. GET list (inbox filtered by userId/groupIds/roleNames via `InAppInboxQuery`; optional multi-value `state`, `severity`, tri-state `acked`, tri-state `read` query params; soft-deleted rows always excluded) / GET `/unread-count` / GET `{id}` / POST `{id}/ack` / POST `{id}/read` / POST `/bulk-read` / POST `/bulk-ack` (VIEWER+) / DELETE `{id}` (OPERATOR+, soft-delete) / POST `/bulk-delete` (OPERATOR+) / POST `{id}/restore` (OPERATOR+, clears `deleted_at`). `requireLiveInstance` helper returns 404 on soft-deleted rows; `restore` explicitly fetches regardless of `deleted_at`. `BulkIdsRequest` is the shared body for bulk-read/ack/delete (`{ instanceIds }`). `AlertDto` includes `readAt`; `deletedAt` is intentionally NOT on the wire. Inbox SQL: `? = ANY(target_user_ids) OR target_group_ids && ? OR target_role_names && ?` — requires at least one matching target (no broadcast concept).
- `AlertSilenceController``/api/v1/environments/{envSlug}/alerts/silences`. GET list / POST create / DELETE `{id}`. 422 if `endsAt <= startsAt`. OPERATOR+ for mutations, VIEWER+ for list. Audit: `ALERT_SILENCE_CHANGE`. - `AlertSilenceController``/api/v1/environments/{envSlug}/alerts/silences`. GET list / POST create / DELETE `{id}`. 422 if `endsAt <= startsAt`. OPERATOR+ for mutations, VIEWER+ for list. Audit: `ALERT_SILENCE_CHANGE`.
- `AlertNotificationController` — Dual-path (no class-level prefix). GET `/api/v1/environments/{envSlug}/alerts/{alertId}/notifications` (VIEWER+); POST `/api/v1/alerts/notifications/{id}/retry` (OPERATOR+, flat — notification IDs globally unique). Retry resets attempts to 0 and sets `nextAttemptAt = now`. - `AlertNotificationController` — Dual-path (no class-level prefix). GET `/api/v1/environments/{envSlug}/alerts/{alertId}/notifications` (VIEWER+); POST `/api/v1/alerts/notifications/{id}/retry` (OPERATOR+, flat — notification IDs globally unique). Retry resets attempts to 0 and sets `nextAttemptAt = now`.

View File

@@ -1,7 +1,7 @@
package com.cameleer.server.app.alerting.controller; package com.cameleer.server.app.alerting.controller;
import com.cameleer.server.app.alerting.dto.AlertDto; import com.cameleer.server.app.alerting.dto.AlertDto;
import com.cameleer.server.app.alerting.dto.BulkReadRequest; import com.cameleer.server.app.alerting.dto.BulkIdsRequest;
import com.cameleer.server.app.alerting.dto.UnreadCountResponse; import com.cameleer.server.app.alerting.dto.UnreadCountResponse;
import com.cameleer.server.app.alerting.notify.InAppInboxQuery; import com.cameleer.server.app.alerting.notify.InAppInboxQuery;
import com.cameleer.server.app.web.EnvPath; import com.cameleer.server.app.web.EnvPath;
@@ -13,8 +13,10 @@ import com.cameleer.server.core.runtime.Environment;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.context.SecurityContextHolder; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -30,7 +32,7 @@ import java.util.UUID;
/** /**
* REST controller for the in-app alert inbox (env-scoped). * REST controller for the in-app alert inbox (env-scoped).
* VIEWER+ can read their own inbox; OPERATOR+ can ack any alert. * VIEWER+ can read their own inbox; OPERATOR+ can soft-delete and restore alerts.
*/ */
@RestController @RestController
@RequestMapping("/api/v1/environments/{envSlug}/alerts") @RequestMapping("/api/v1/environments/{envSlug}/alerts")
@@ -38,8 +40,6 @@ import java.util.UUID;
@PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')") @PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')")
public class AlertController { public class AlertController {
private static final int DEFAULT_LIMIT = 50;
private final InAppInboxQuery inboxQuery; private final InAppInboxQuery inboxQuery;
private final AlertInstanceRepository instanceRepo; private final AlertInstanceRepository instanceRepo;
@@ -54,10 +54,12 @@ public class AlertController {
@EnvPath Environment env, @EnvPath Environment env,
@RequestParam(defaultValue = "50") int limit, @RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) List<AlertState> state, @RequestParam(required = false) List<AlertState> state,
@RequestParam(required = false) List<AlertSeverity> severity) { @RequestParam(required = false) List<AlertSeverity> severity,
@RequestParam(required = false) Boolean acked,
@RequestParam(required = false) Boolean read) {
String userId = currentUserId(); String userId = currentUserId();
int effectiveLimit = Math.min(limit, 200); int effectiveLimit = Math.min(limit, 200);
return inboxQuery.listInbox(env.id(), userId, state, severity, effectiveLimit) return inboxQuery.listInbox(env.id(), userId, state, severity, acked, read, effectiveLimit)
.stream().map(AlertDto::from).toList(); .stream().map(AlertDto::from).toList();
} }
@@ -68,13 +70,13 @@ public class AlertController {
@GetMapping("/{id}") @GetMapping("/{id}")
public AlertDto get(@EnvPath Environment env, @PathVariable UUID id) { public AlertDto get(@EnvPath Environment env, @PathVariable UUID id) {
AlertInstance instance = requireInstance(id, env.id()); AlertInstance instance = requireLiveInstance(id, env.id());
return AlertDto.from(instance); return AlertDto.from(instance);
} }
@PostMapping("/{id}/ack") @PostMapping("/{id}/ack")
public AlertDto ack(@EnvPath Environment env, @PathVariable UUID id) { public AlertDto ack(@EnvPath Environment env, @PathVariable UUID id) {
AlertInstance instance = requireInstance(id, env.id()); AlertInstance instance = requireLiveInstance(id, env.id());
String userId = currentUserId(); String userId = currentUserId();
instanceRepo.ack(id, userId, Instant.now()); instanceRepo.ack(id, userId, Instant.now());
// Re-fetch to return fresh state // Re-fetch to return fresh state
@@ -84,37 +86,76 @@ public class AlertController {
@PostMapping("/{id}/read") @PostMapping("/{id}/read")
public void read(@EnvPath Environment env, @PathVariable UUID id) { public void read(@EnvPath Environment env, @PathVariable UUID id) {
requireInstance(id, env.id()); requireLiveInstance(id, env.id());
instanceRepo.markRead(id, Instant.now()); instanceRepo.markRead(id, Instant.now());
} }
@PostMapping("/bulk-read") @PostMapping("/bulk-read")
public void bulkRead(@EnvPath Environment env, public void bulkRead(@EnvPath Environment env,
@Valid @RequestBody BulkReadRequest req) { @Valid @RequestBody BulkIdsRequest req) {
// filter to only instances in this env List<UUID> filtered = inEnvLiveIds(req.instanceIds(), env.id());
List<UUID> filtered = req.instanceIds().stream()
.filter(instanceId -> instanceRepo.findById(instanceId)
.map(i -> i.environmentId().equals(env.id()))
.orElse(false))
.toList();
if (!filtered.isEmpty()) { if (!filtered.isEmpty()) {
instanceRepo.bulkMarkRead(filtered, Instant.now()); instanceRepo.bulkMarkRead(filtered, Instant.now());
} }
} }
@PostMapping("/bulk-ack")
public void bulkAck(@EnvPath Environment env,
@Valid @RequestBody BulkIdsRequest req) {
List<UUID> filtered = inEnvLiveIds(req.instanceIds(), env.id());
if (!filtered.isEmpty()) {
instanceRepo.bulkAck(filtered, currentUserId(), Instant.now());
}
}
@DeleteMapping("/{id}")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public ResponseEntity<Void> delete(@EnvPath Environment env, @PathVariable UUID id) {
requireLiveInstance(id, env.id());
instanceRepo.softDelete(id, Instant.now());
return ResponseEntity.noContent().build();
}
@PostMapping("/bulk-delete")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public void bulkDelete(@EnvPath Environment env,
@Valid @RequestBody BulkIdsRequest req) {
List<UUID> filtered = inEnvLiveIds(req.instanceIds(), env.id());
if (!filtered.isEmpty()) {
instanceRepo.bulkSoftDelete(filtered, Instant.now());
}
}
@PostMapping("/{id}/restore")
@PreAuthorize("hasAnyRole('OPERATOR','ADMIN')")
public ResponseEntity<Void> restore(@EnvPath Environment env, @PathVariable UUID id) {
// Unlike requireLiveInstance, restore explicitly targets soft-deleted rows
AlertInstance inst = instanceRepo.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found"));
if (!inst.environmentId().equals(env.id()))
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found in env");
instanceRepo.restore(id);
return ResponseEntity.noContent().build();
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Helpers // Helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private AlertInstance requireInstance(UUID id, UUID envId) { private AlertInstance requireLiveInstance(UUID id, UUID envId) {
AlertInstance instance = instanceRepo.findById(id) AlertInstance i = instanceRepo.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found"));
"Alert not found: " + id)); if (!i.environmentId().equals(envId) || i.deletedAt() != null)
if (!instance.environmentId().equals(envId)) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found in env");
throw new ResponseStatusException(HttpStatus.NOT_FOUND, return i;
"Alert not found in this environment: " + id); }
}
return instance; private List<UUID> inEnvLiveIds(List<UUID> ids, UUID envId) {
return ids.stream()
.filter(id -> instanceRepo.findById(id)
.map(i -> i.environmentId().equals(envId) && i.deletedAt() == null)
.orElse(false))
.toList();
} }
private String currentUserId() { private String currentUserId() {

View File

@@ -20,6 +20,7 @@ public record AlertDto(
Instant ackedAt, Instant ackedAt,
String ackedBy, String ackedBy,
Instant resolvedAt, Instant resolvedAt,
Instant readAt, // global "has anyone read this"
boolean silenced, boolean silenced,
Double currentValue, Double currentValue,
Double threshold, Double threshold,
@@ -29,6 +30,7 @@ public record AlertDto(
return new AlertDto( return new AlertDto(
i.id(), i.ruleId(), i.environmentId(), i.state(), i.severity(), i.id(), i.ruleId(), i.environmentId(), i.state(), i.severity(),
i.title(), i.message(), i.firedAt(), i.ackedAt(), i.ackedBy(), i.title(), i.message(), i.firedAt(), i.ackedAt(), i.ackedBy(),
i.resolvedAt(), i.silenced(), i.currentValue(), i.threshold(), i.context()); i.resolvedAt(), i.readAt(), i.silenced(),
i.currentValue(), i.threshold(), i.context());
} }
} }

View File

@@ -0,0 +1,10 @@
package com.cameleer.server.app.alerting.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
import java.util.UUID;
/** Shared body for bulk-read / bulk-ack / bulk-delete requests. */
public record BulkIdsRequest(@NotNull @Size(min = 1, max = 500) List<UUID> instanceIds) {}

View File

@@ -1,12 +0,0 @@
package com.cameleer.server.app.alerting.dto;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.UUID;
public record BulkReadRequest(@NotNull List<UUID> instanceIds) {
public BulkReadRequest {
instanceIds = instanceIds == null ? List.of() : List.copyOf(instanceIds);
}
}

View File

@@ -49,28 +49,22 @@ public class InAppInboxQuery {
} }
/** /**
* Returns the most recent {@code limit} alert instances visible to the given user. * Full filtered variant: optional {@code states}, {@code severities}, {@code acked},
* <p> * and {@code read} narrow the result set. {@code null} or empty lists mean
* Visibility: the instance must target this user directly, or target a group the user belongs to, * "no filter on that dimension". {@code acked}/{@code read} are tri-state:
* or target a role the user holds. Empty target lists mean "broadcast to all". * {@code null} = no filter, {@code TRUE} = only acked/read, {@code FALSE} = only unacked/unread.
*/
public List<AlertInstance> listInbox(UUID envId, String userId, int limit) {
return listInbox(envId, userId, null, null, limit);
}
/**
* Filtered variant of {@link #listInbox(UUID, String, int)}: optional {@code states}
* and {@code severities} narrow the result set. {@code null} or empty lists mean
* "no filter on that dimension".
*/ */
public List<AlertInstance> listInbox(UUID envId, public List<AlertInstance> listInbox(UUID envId,
String userId, String userId,
List<AlertState> states, List<AlertState> states,
List<AlertSeverity> severities, List<AlertSeverity> severities,
Boolean acked,
Boolean read,
int limit) { int limit) {
List<String> groupIds = resolveGroupIds(userId); List<String> groupIds = resolveGroupIds(userId);
List<String> roleNames = resolveRoleNames(userId); List<String> roleNames = resolveRoleNames(userId);
return instanceRepo.listForInbox(envId, groupIds, userId, roleNames, states, severities, null, null, limit); return instanceRepo.listForInbox(envId, groupIds, userId, roleNames,
states, severities, acked, read, limit);
} }
/** /**

View File

@@ -171,10 +171,15 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/silences/**").hasAnyRole("OPERATOR", "ADMIN") .requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/silences/**").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.PUT, "/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") .requestMatchers(HttpMethod.DELETE, "/api/v1/environments/*/alerts/silences/**").hasAnyRole("OPERATOR", "ADMIN")
// Alerting — ack/read (VIEWER+ self-service) // Alerting — ack/read/bulk-ack (VIEWER+ self-service)
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/*/ack").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .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/*/read").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/bulk-read").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/bulk-read").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/bulk-ack").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
// Alerting — soft-delete / restore (OPERATOR+)
.requestMatchers(HttpMethod.DELETE, "/api/v1/environments/*/alerts/*").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/bulk-delete").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/*/restore").hasAnyRole("OPERATOR", "ADMIN")
// Alerting — notification retry (flat path; notification IDs globally unique) // Alerting — notification retry (flat path; notification IDs globally unique)
.requestMatchers(HttpMethod.POST, "/api/v1/alerts/notifications/*/retry").hasAnyRole("OPERATOR", "ADMIN") .requestMatchers(HttpMethod.POST, "/api/v1/alerts/notifications/*/retry").hasAnyRole("OPERATOR", "ADMIN")

View File

@@ -69,6 +69,10 @@ class AlertControllerIT extends AbstractPostgresIT {
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')"); jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')");
} }
// -------------------------------------------------------------------------
// Existing tests (baseline)
// -------------------------------------------------------------------------
@Test @Test
void listReturnsAlertsForEnv() throws Exception { void listReturnsAlertsForEnv() throws Exception {
AlertInstance instance = seedInstance(envIdA); AlertInstance instance = seedInstance(envIdA);
@@ -82,7 +86,6 @@ class AlertControllerIT extends AbstractPostgresIT {
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(resp.getBody()); JsonNode body = objectMapper.readTree(resp.getBody());
assertThat(body.isArray()).isTrue(); assertThat(body.isArray()).isTrue();
// The alert we seeded should be present
boolean found = false; boolean found = false;
for (JsonNode node : body) { for (JsonNode node : body) {
if (node.path("id").asText().equals(instance.id().toString())) { if (node.path("id").asText().equals(instance.id().toString())) {
@@ -95,10 +98,8 @@ class AlertControllerIT extends AbstractPostgresIT {
@Test @Test
void envIsolation() throws Exception { void envIsolation() throws Exception {
// Seed an alert in env-A
AlertInstance instanceA = seedInstance(envIdA); AlertInstance instanceA = seedInstance(envIdA);
// env-B inbox should NOT see env-A's alert
ResponseEntity<String> resp = restTemplate.exchange( ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlugB + "/alerts", "/api/v1/environments/" + envSlugB + "/alerts",
HttpMethod.GET, HttpMethod.GET,
@@ -150,6 +151,10 @@ class AlertControllerIT extends AbstractPostgresIT {
String.class); String.class);
assertThat(read.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(read.getStatusCode()).isEqualTo(HttpStatus.OK);
// Verify persistence — readAt must now be set
AlertInstance updated = instanceRepo.findById(instance.id()).orElseThrow();
assertThat(updated.readAt()).as("readAt must be set after /read").isNotNull();
} }
@Test @Test
@@ -168,6 +173,12 @@ class AlertControllerIT extends AbstractPostgresIT {
String.class); String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
// Verify persistence — both must have readAt set
assertThat(instanceRepo.findById(i1.id()).orElseThrow().readAt())
.as("i1 readAt must be set after bulk-read").isNotNull();
assertThat(instanceRepo.findById(i2.id()).orElseThrow().readAt())
.as("i2 readAt must be set after bulk-read").isNotNull();
} }
@Test @Test
@@ -180,6 +191,257 @@ class AlertControllerIT extends AbstractPostgresIT {
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
} }
// -------------------------------------------------------------------------
// New endpoint tests
// -------------------------------------------------------------------------
@Test
void delete_softDeletes_and_subsequent_get_returns_404() {
AlertInstance instance = seedInstance(envIdA);
// OPERATOR deletes
ResponseEntity<String> del = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id(),
HttpMethod.DELETE,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(del.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
// Subsequent GET returns 404
ResponseEntity<String> get = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id(),
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(get.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void delete_non_operator_returns_403() {
AlertInstance instance = seedInstance(envIdA);
ResponseEntity<String> del = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id(),
HttpMethod.DELETE,
new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)),
String.class);
assertThat(del.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
@Test
void bulkDelete_only_affects_matching_env() {
AlertInstance inEnvA = seedInstance(envIdA);
AlertInstance inEnvA2 = seedInstance(envIdA);
AlertInstance inEnvB = seedInstance(envIdB);
String body = """
{"instanceIds":["%s","%s","%s"]}
""".formatted(inEnvA.id(), inEnvA2.id(), inEnvB.id());
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts/bulk-delete",
HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
// env-A alerts should be soft-deleted
assertThat(instanceRepo.findById(inEnvA.id()).orElseThrow().deletedAt())
.as("inEnvA should be soft-deleted").isNotNull();
assertThat(instanceRepo.findById(inEnvA2.id()).orElseThrow().deletedAt())
.as("inEnvA2 should be soft-deleted").isNotNull();
// env-B alert must NOT be soft-deleted
assertThat(instanceRepo.findById(inEnvB.id()).orElseThrow().deletedAt())
.as("inEnvB must not be soft-deleted via env-A bulk-delete").isNull();
}
@Test
void bulkAck_only_touches_unacked_rows() {
AlertInstance i1 = seedInstance(envIdA);
AlertInstance i2 = seedInstance(envIdA);
// Pre-ack i1 with an existing user (must be in users table due to FK)
instanceRepo.ack(i1.id(), "test-viewer", Instant.now().minusSeconds(60));
String body = """
{"instanceIds":["%s","%s"]}
""".formatted(i1.id(), i2.id());
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts/bulk-ack",
HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK);
// i1's ackedBy must remain "test-viewer" (bulk-ack skips already-acked rows)
AlertInstance refreshed1 = instanceRepo.findById(i1.id()).orElseThrow();
assertThat(refreshed1.ackedBy()).as("previously-acked row must keep original ackedBy").isEqualTo("test-viewer");
// i2 must now be acked
AlertInstance refreshed2 = instanceRepo.findById(i2.id()).orElseThrow();
assertThat(refreshed2.ackedAt()).as("i2 must be acked after bulk-ack").isNotNull();
}
@Test
void restore_clears_deleted_at_and_reappears_in_inbox() throws Exception {
AlertInstance instance = seedInstance(envIdA);
// Soft-delete first
restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id(),
HttpMethod.DELETE,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(instanceRepo.findById(instance.id()).orElseThrow().deletedAt()).isNotNull();
// Restore
ResponseEntity<String> restoreResp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts/" + instance.id() + "/restore",
HttpMethod.POST,
new HttpEntity<>(securityHelper.authHeaders(operatorJwt)),
String.class);
assertThat(restoreResp.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT);
// deletedAt must be cleared
assertThat(instanceRepo.findById(instance.id()).orElseThrow().deletedAt())
.as("deletedAt must be null after restore").isNull();
// Alert reappears in inbox list
ResponseEntity<String> listResp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(listResp.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(listResp.getBody());
boolean found = false;
for (JsonNode node : body) {
if (node.path("id").asText().equals(instance.id().toString())) {
found = true;
break;
}
}
assertThat(found).as("restored alert must reappear in inbox").isTrue();
}
@Test
void list_respects_acked_filter_tristate() throws Exception {
AlertInstance unacked = seedInstance(envIdA);
AlertInstance acked = seedInstance(envIdA);
instanceRepo.ack(acked.id(), "test-operator", Instant.now());
// ?acked=false — only unacked
ResponseEntity<String> falseResp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts?acked=false",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(falseResp.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode falseBody = objectMapper.readTree(falseResp.getBody());
boolean unackedFound = false, ackedFoundInFalse = false;
for (JsonNode node : falseBody) {
String id = node.path("id").asText();
if (id.equals(unacked.id().toString())) unackedFound = true;
if (id.equals(acked.id().toString())) ackedFoundInFalse = true;
}
assertThat(unackedFound).as("unacked alert must appear with ?acked=false").isTrue();
assertThat(ackedFoundInFalse).as("acked alert must NOT appear with ?acked=false").isFalse();
// ?acked=true — only acked
ResponseEntity<String> trueResp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts?acked=true",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(trueResp.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode trueBody = objectMapper.readTree(trueResp.getBody());
boolean ackedFound = false, unackedFoundInTrue = false;
for (JsonNode node : trueBody) {
String id = node.path("id").asText();
if (id.equals(acked.id().toString())) ackedFound = true;
if (id.equals(unacked.id().toString())) unackedFoundInTrue = true;
}
assertThat(ackedFound).as("acked alert must appear with ?acked=true").isTrue();
assertThat(unackedFoundInTrue).as("unacked alert must NOT appear with ?acked=true").isFalse();
// no param — both visible
ResponseEntity<String> allResp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(allResp.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode allBody = objectMapper.readTree(allResp.getBody());
boolean bothUnacked = false, bothAcked = false;
for (JsonNode node : allBody) {
String id = node.path("id").asText();
if (id.equals(unacked.id().toString())) bothUnacked = true;
if (id.equals(acked.id().toString())) bothAcked = true;
}
assertThat(bothUnacked).as("unacked must appear with no acked filter").isTrue();
assertThat(bothAcked).as("acked must appear with no acked filter").isTrue();
}
@Test
void list_respects_read_filter_tristate() throws Exception {
AlertInstance unread = seedInstance(envIdA);
AlertInstance read = seedInstance(envIdA);
instanceRepo.markRead(read.id(), Instant.now());
// ?read=false — only unread
ResponseEntity<String> falseResp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts?read=false",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(falseResp.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode falseBody = objectMapper.readTree(falseResp.getBody());
boolean unreadFound = false, readFoundInFalse = false;
for (JsonNode node : falseBody) {
String id = node.path("id").asText();
if (id.equals(unread.id().toString())) unreadFound = true;
if (id.equals(read.id().toString())) readFoundInFalse = true;
}
assertThat(unreadFound).as("unread alert must appear with ?read=false").isTrue();
assertThat(readFoundInFalse).as("read alert must NOT appear with ?read=false").isFalse();
// ?read=true — only read
ResponseEntity<String> trueResp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts?read=true",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(trueResp.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode trueBody = objectMapper.readTree(trueResp.getBody());
boolean readFound = false, unreadFoundInTrue = false;
for (JsonNode node : trueBody) {
String id = node.path("id").asText();
if (id.equals(read.id().toString())) readFound = true;
if (id.equals(unread.id().toString())) unreadFoundInTrue = true;
}
assertThat(readFound).as("read alert must appear with ?read=true").isTrue();
assertThat(unreadFoundInTrue).as("unread alert must NOT appear with ?read=true").isFalse();
// no param — both visible
ResponseEntity<String> allResp = restTemplate.exchange(
"/api/v1/environments/" + envSlugA + "/alerts",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
assertThat(allResp.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode allBody = objectMapper.readTree(allResp.getBody());
boolean bothUnread = false, bothRead = false;
for (JsonNode node : allBody) {
String id = node.path("id").asText();
if (id.equals(unread.id().toString())) bothUnread = true;
if (id.equals(read.id().toString())) bothRead = true;
}
assertThat(bothUnread).as("unread must appear with no read filter").isTrue();
assertThat(bothRead).as("read must appear with no read filter").isTrue();
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Helpers // Helpers
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------

View File

@@ -80,7 +80,7 @@ class InAppInboxQueryTest {
eq(USER_ID), eq(List.of("OPERATOR")), isNull(), isNull(), isNull(), isNull(), eq(20))) eq(USER_ID), eq(List.of("OPERATOR")), isNull(), isNull(), isNull(), isNull(), eq(20)))
.thenReturn(List.of()); .thenReturn(List.of());
List<AlertInstance> result = query.listInbox(ENV_ID, USER_ID, 20); List<AlertInstance> result = query.listInbox(ENV_ID, USER_ID, null, null, null, null, 20);
assertThat(result).isEmpty(); assertThat(result).isEmpty();
verify(instanceRepo).listForInbox(ENV_ID, List.of(groupId.toString()), verify(instanceRepo).listForInbox(ENV_ID, List.of(groupId.toString()),
USER_ID, List.of("OPERATOR"), null, null, null, null, 20); USER_ID, List.of("OPERATOR"), null, null, null, null, 20);
@@ -99,7 +99,7 @@ class InAppInboxQueryTest {
eq(states), eq(severities), isNull(), isNull(), eq(25))) eq(states), eq(severities), isNull(), isNull(), eq(25)))
.thenReturn(List.of()); .thenReturn(List.of());
query.listInbox(ENV_ID, USER_ID, states, severities, 25); query.listInbox(ENV_ID, USER_ID, states, severities, null, null, 25);
verify(instanceRepo).listForInbox(ENV_ID, List.of(), USER_ID, List.of(), verify(instanceRepo).listForInbox(ENV_ID, List.of(), USER_ID, List.of(),
states, severities, null, null, 25); states, severities, null, null, 25);
} }