diff --git a/.claude/rules/app-classes.md b/.claude/rules/app-classes.md index e04365e0..95cbd870 100644 --- a/.claude/rules/app-classes.md +++ b/.claude/rules/app-classes.md @@ -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`. diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java index 69c4fb32..2726cf88 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertController.java @@ -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 state, - @RequestParam(required = false) List severity) { + @RequestParam(required = false) List 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 filtered = req.instanceIds().stream() - .filter(instanceId -> instanceRepo.findById(instanceId) - .map(i -> i.environmentId().equals(env.id())) - .orElse(false)) - .toList(); + @Valid @RequestBody BulkIdsRequest req) { + List 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 filtered = inEnvLiveIds(req.instanceIds(), env.id()); + if (!filtered.isEmpty()) { + instanceRepo.bulkAck(filtered, currentUserId(), Instant.now()); + } + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") + public ResponseEntity 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 filtered = inEnvLiveIds(req.instanceIds(), env.id()); + if (!filtered.isEmpty()) { + instanceRepo.bulkSoftDelete(filtered, Instant.now()); + } + } + + @PostMapping("/{id}/restore") + @PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") + public ResponseEntity 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); - } - return instance; + 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; + } + + private List inEnvLiveIds(List 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() { diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java index 1ddfb514..a1dfc57f 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertDto.java @@ -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()); } } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkIdsRequest.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkIdsRequest.java new file mode 100644 index 00000000..280faaf7 --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkIdsRequest.java @@ -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 instanceIds) {} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkReadRequest.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkReadRequest.java deleted file mode 100644 index fa2dca1e..00000000 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/BulkReadRequest.java +++ /dev/null @@ -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 instanceIds) { - public BulkReadRequest { - instanceIds = instanceIds == null ? List.of() : List.copyOf(instanceIds); - } -} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java index c3d0b497..ba528fdf 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/notify/InAppInboxQuery.java @@ -49,28 +49,22 @@ public class InAppInboxQuery { } /** - * Returns the most recent {@code limit} alert instances visible to the given user. - *

- * 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 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 listInbox(UUID envId, String userId, List states, List severities, + Boolean acked, + Boolean read, int limit) { List groupIds = resolveGroupIds(userId); List 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); } /** diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java index c72f727d..afb2f453 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/security/SecurityConfig.java @@ -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") diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java index 5502d669..73925fc5 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertControllerIT.java @@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 // ------------------------------------------------------------------------- diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/InAppInboxQueryTest.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/InAppInboxQueryTest.java index e0189c80..166d74fe 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/InAppInboxQueryTest.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/notify/InAppInboxQueryTest.java @@ -80,7 +80,7 @@ class InAppInboxQueryTest { eq(USER_ID), eq(List.of("OPERATOR")), isNull(), isNull(), isNull(), isNull(), eq(20))) .thenReturn(List.of()); - List result = query.listInbox(ENV_ID, USER_ID, 20); + List 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); }