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:
@@ -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.
|
||||
- `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`.
|
||||
- `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`.
|
||||
- `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`.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package com.cameleer.server.app.alerting.controller;
|
||||
|
||||
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.notify.InAppInboxQuery;
|
||||
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 jakarta.validation.Valid;
|
||||
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;
|
||||
@@ -30,7 +32,7 @@ import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 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
|
||||
@RequestMapping("/api/v1/environments/{envSlug}/alerts")
|
||||
@@ -38,8 +40,6 @@ import java.util.UUID;
|
||||
@PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')")
|
||||
public class AlertController {
|
||||
|
||||
private static final int DEFAULT_LIMIT = 50;
|
||||
|
||||
private final InAppInboxQuery inboxQuery;
|
||||
private final AlertInstanceRepository instanceRepo;
|
||||
|
||||
@@ -54,10 +54,12 @@ public class AlertController {
|
||||
@EnvPath Environment env,
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
@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();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -68,13 +70,13 @@ public class AlertController {
|
||||
|
||||
@GetMapping("/{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);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/ack")
|
||||
public AlertDto ack(@EnvPath Environment env, @PathVariable UUID id) {
|
||||
AlertInstance instance = requireInstance(id, env.id());
|
||||
AlertInstance instance = requireLiveInstance(id, env.id());
|
||||
String userId = currentUserId();
|
||||
instanceRepo.ack(id, userId, Instant.now());
|
||||
// Re-fetch to return fresh state
|
||||
@@ -84,37 +86,76 @@ public class AlertController {
|
||||
|
||||
@PostMapping("/{id}/read")
|
||||
public void read(@EnvPath Environment env, @PathVariable UUID id) {
|
||||
requireInstance(id, env.id());
|
||||
requireLiveInstance(id, env.id());
|
||||
instanceRepo.markRead(id, Instant.now());
|
||||
}
|
||||
|
||||
@PostMapping("/bulk-read")
|
||||
public void bulkRead(@EnvPath Environment env,
|
||||
@Valid @RequestBody BulkReadRequest req) {
|
||||
// filter to only instances in this env
|
||||
List<UUID> filtered = req.instanceIds().stream()
|
||||
.filter(instanceId -> instanceRepo.findById(instanceId)
|
||||
.map(i -> i.environmentId().equals(env.id()))
|
||||
.orElse(false))
|
||||
.toList();
|
||||
@Valid @RequestBody BulkIdsRequest req) {
|
||||
List<UUID> filtered = inEnvLiveIds(req.instanceIds(), env.id());
|
||||
if (!filtered.isEmpty()) {
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private AlertInstance requireInstance(UUID id, UUID envId) {
|
||||
AlertInstance instance = instanceRepo.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"Alert not found: " + id));
|
||||
if (!instance.environmentId().equals(envId)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
|
||||
"Alert not found in this environment: " + id);
|
||||
private AlertInstance requireLiveInstance(UUID id, UUID envId) {
|
||||
AlertInstance i = instanceRepo.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found"));
|
||||
if (!i.environmentId().equals(envId) || i.deletedAt() != null)
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Alert not found in env");
|
||||
return i;
|
||||
}
|
||||
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() {
|
||||
|
||||
@@ -20,6 +20,7 @@ public record AlertDto(
|
||||
Instant ackedAt,
|
||||
String ackedBy,
|
||||
Instant resolvedAt,
|
||||
Instant readAt, // global "has anyone read this"
|
||||
boolean silenced,
|
||||
Double currentValue,
|
||||
Double threshold,
|
||||
@@ -29,6 +30,7 @@ public record AlertDto(
|
||||
return new AlertDto(
|
||||
i.id(), i.ruleId(), i.environmentId(), i.state(), i.severity(),
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -49,28 +49,22 @@ public class InAppInboxQuery {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the most recent {@code limit} alert instances visible to the given user.
|
||||
* <p>
|
||||
* Visibility: the instance must target this user directly, or target a group the user belongs to,
|
||||
* or target a role the user holds. Empty target lists mean "broadcast to all".
|
||||
*/
|
||||
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".
|
||||
* Full filtered variant: optional {@code states}, {@code severities}, {@code acked},
|
||||
* and {@code read} narrow the result set. {@code null} or empty lists mean
|
||||
* "no filter on that dimension". {@code acked}/{@code read} are tri-state:
|
||||
* {@code null} = no filter, {@code TRUE} = only acked/read, {@code FALSE} = only unacked/unread.
|
||||
*/
|
||||
public List<AlertInstance> listInbox(UUID envId,
|
||||
String userId,
|
||||
List<AlertState> states,
|
||||
List<AlertSeverity> severities,
|
||||
Boolean acked,
|
||||
Boolean read,
|
||||
int limit) {
|
||||
List<String> groupIds = resolveGroupIds(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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -171,10 +171,15 @@ public class SecurityConfig {
|
||||
.requestMatchers(HttpMethod.POST, "/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")
|
||||
// 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/*/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)
|
||||
.requestMatchers(HttpMethod.POST, "/api/v1/alerts/notifications/*/retry").hasAnyRole("OPERATOR", "ADMIN")
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Existing tests (baseline)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void listReturnsAlertsForEnv() throws Exception {
|
||||
AlertInstance instance = seedInstance(envIdA);
|
||||
@@ -82,7 +86,6 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
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())) {
|
||||
@@ -95,10 +98,8 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
|
||||
@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,
|
||||
@@ -150,6 +151,10 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
String.class);
|
||||
|
||||
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
|
||||
@@ -168,6 +173,12 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
String.class);
|
||||
|
||||
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
|
||||
@@ -180,6 +191,257 @@ class AlertControllerIT extends AbstractPostgresIT {
|
||||
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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -80,7 +80,7 @@ class InAppInboxQueryTest {
|
||||
eq(USER_ID), eq(List.of("OPERATOR")), isNull(), isNull(), isNull(), isNull(), eq(20)))
|
||||
.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();
|
||||
verify(instanceRepo).listForInbox(ENV_ID, List.of(groupId.toString()),
|
||||
USER_ID, List.of("OPERATOR"), null, null, null, null, 20);
|
||||
@@ -99,7 +99,7 @@ class InAppInboxQueryTest {
|
||||
eq(states), eq(severities), isNull(), isNull(), eq(25)))
|
||||
.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(),
|
||||
states, severities, null, null, 25);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user