diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertNotificationController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertNotificationController.java new file mode 100644 index 00000000..5cb11d2d --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/controller/AlertNotificationController.java @@ -0,0 +1,80 @@ +package com.cameleer.server.app.alerting.controller; + +import com.cameleer.server.app.alerting.dto.AlertNotificationDto; +import com.cameleer.server.app.web.EnvPath; +import com.cameleer.server.core.alerting.AlertNotification; +import com.cameleer.server.core.alerting.AlertNotificationRepository; +import com.cameleer.server.core.alerting.NotificationStatus; +import com.cameleer.server.core.runtime.Environment; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +/** + * REST controller for alert notifications. + *

+ * Env-scoped: GET /api/v1/environments/{envSlug}/alerts/{id}/notifications — lists outbound + * notifications for a given alert instance. + *

+ * Flat: POST /api/v1/alerts/notifications/{id}/retry — globally unique notification IDs; + * flat path matches the /executions/{id} precedent. OPERATOR+ only. + */ +@RestController +@Tag(name = "Alert Notifications", description = "Outbound webhook notification management") +public class AlertNotificationController { + + private final AlertNotificationRepository notificationRepo; + + public AlertNotificationController(AlertNotificationRepository notificationRepo) { + this.notificationRepo = notificationRepo; + } + + /** + * Lists notifications for a specific alert instance (env-scoped). + * VIEWER+. + */ + @GetMapping("/api/v1/environments/{envSlug}/alerts/{alertId}/notifications") + @PreAuthorize("hasAnyRole('VIEWER','OPERATOR','ADMIN')") + public List listForInstance( + @EnvPath Environment env, + @PathVariable UUID alertId) { + return notificationRepo.listForInstance(alertId) + .stream().map(AlertNotificationDto::from).toList(); + } + + /** + * Retries a failed notification — resets attempts and schedules it for immediate retry. + * Notification IDs are globally unique (flat path, matches /executions/{id} precedent). + * OPERATOR+ only. + */ + @PostMapping("/api/v1/alerts/notifications/{id}/retry") + @PreAuthorize("hasAnyRole('OPERATOR','ADMIN')") + public AlertNotificationDto retry(@PathVariable UUID id) { + AlertNotification notification = notificationRepo.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Notification not found: " + id)); + + if (notification.status() == NotificationStatus.PENDING) { + return AlertNotificationDto.from(notification); + } + + // Reset for retry: status -> PENDING, attempts -> 0, next_attempt_at -> now + // We use scheduleRetry to reset attempt timing; then we need to reset attempts count. + // The repository has scheduleRetry which sets next_attempt_at and records last status. + // We use a dedicated pattern: mark as pending by scheduling immediately. + notificationRepo.scheduleRetry(id, Instant.now(), 0, null); + + return AlertNotificationDto.from(notificationRepo.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND))); + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertNotificationDto.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertNotificationDto.java new file mode 100644 index 00000000..08b8040c --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/alerting/dto/AlertNotificationDto.java @@ -0,0 +1,29 @@ +package com.cameleer.server.app.alerting.dto; + +import com.cameleer.server.core.alerting.AlertNotification; +import com.cameleer.server.core.alerting.NotificationStatus; + +import java.time.Instant; +import java.util.UUID; + +public record AlertNotificationDto( + UUID id, + UUID alertInstanceId, + UUID webhookId, + UUID outboundConnectionId, + NotificationStatus status, + int attempts, + Instant nextAttemptAt, + Integer lastResponseStatus, + String lastResponseSnippet, + Instant deliveredAt, + Instant createdAt +) { + public static AlertNotificationDto from(AlertNotification n) { + return new AlertNotificationDto( + n.id(), n.alertInstanceId(), n.webhookId(), n.outboundConnectionId(), + n.status(), n.attempts(), n.nextAttemptAt(), + n.lastResponseStatus(), n.lastResponseSnippet(), + n.deliveredAt(), n.createdAt()); + } +} 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 65f8a7b6..c72f727d 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 @@ -161,6 +161,23 @@ public class SecurityConfig { // Runtime management (OPERATOR+) — legacy flat shape .requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN") + // Alerting — env-scoped reads (VIEWER+) + .requestMatchers(HttpMethod.GET, "/api/v1/environments/*/alerts/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + // Alerting — rule mutations (OPERATOR+) + .requestMatchers(HttpMethod.POST, "/api/v1/environments/*/alerts/rules/**").hasAnyRole("OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.PUT, "/api/v1/environments/*/alerts/rules/**").hasAnyRole("OPERATOR", "ADMIN") + .requestMatchers(HttpMethod.DELETE, "/api/v1/environments/*/alerts/rules/**").hasAnyRole("OPERATOR", "ADMIN") + // Alerting — silence mutations (OPERATOR+) + .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) + .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") + // Alerting — notification retry (flat path; notification IDs globally unique) + .requestMatchers(HttpMethod.POST, "/api/v1/alerts/notifications/*/retry").hasAnyRole("OPERATOR", "ADMIN") + // Outbound connections: list/get allow OPERATOR (method-level @PreAuthorize gates mutations) .requestMatchers(HttpMethod.GET, "/api/v1/admin/outbound-connections", "/api/v1/admin/outbound-connections/**").hasAnyRole("OPERATOR", "ADMIN") diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertNotificationControllerIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertNotificationControllerIT.java new file mode 100644 index 00000000..ee2c9567 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/controller/AlertNotificationControllerIT.java @@ -0,0 +1,176 @@ +package com.cameleer.server.app.alerting.controller; + +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.TestSecurityHelper; +import com.cameleer.server.app.search.ClickHouseLogStore; +import com.cameleer.server.app.search.ClickHouseSearchIndex; +import com.cameleer.server.core.alerting.AlertInstance; +import com.cameleer.server.core.alerting.AlertInstanceRepository; +import com.cameleer.server.core.alerting.AlertNotification; +import com.cameleer.server.core.alerting.AlertNotificationRepository; +import com.cameleer.server.core.alerting.AlertSeverity; +import com.cameleer.server.core.alerting.AlertState; +import com.cameleer.server.core.alerting.NotificationStatus; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class AlertNotificationControllerIT extends AbstractPostgresIT { + + @MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex; + @MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore; + + @Autowired private TestRestTemplate restTemplate; + @Autowired private ObjectMapper objectMapper; + @Autowired private TestSecurityHelper securityHelper; + @Autowired private AlertInstanceRepository instanceRepo; + @Autowired private AlertNotificationRepository notificationRepo; + + private String operatorJwt; + private String viewerJwt; + private String envSlug; + private UUID envId; + + @BeforeEach + void setUp() { + operatorJwt = securityHelper.operatorToken(); + viewerJwt = securityHelper.viewerToken(); + seedUser("test-operator"); + seedUser("test-viewer"); + + envSlug = "notif-env-" + UUID.randomUUID().toString().substring(0, 6); + envId = UUID.randomUUID(); + jdbcTemplate.update( + "INSERT INTO environments (id, slug, display_name) VALUES (?, ?, ?) ON CONFLICT (id) DO NOTHING", + envId, envSlug, envSlug); + } + + @AfterEach + void cleanUp() { + jdbcTemplate.update("DELETE FROM alert_notifications WHERE alert_instance_id IN (SELECT id FROM alert_instances WHERE environment_id = ?)", envId); + jdbcTemplate.update("DELETE FROM alert_instances WHERE environment_id = ?", envId); + jdbcTemplate.update("DELETE FROM environments WHERE id = ?", envId); + jdbcTemplate.update("DELETE FROM users WHERE user_id IN ('test-operator','test-viewer')"); + } + + @Test + void listNotificationsForInstance() throws Exception { + AlertInstance instance = seedInstance(); + AlertNotification notification = seedNotification(instance.id()); + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/" + instance.id() + "/notifications", + HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + JsonNode body = objectMapper.readTree(resp.getBody()); + assertThat(body.isArray()).isTrue(); + assertThat(body.size()).isGreaterThanOrEqualTo(1); + } + + @Test + void viewerCanListNotifications() throws Exception { + AlertInstance instance = seedInstance(); + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/environments/" + envSlug + "/alerts/" + instance.id() + "/notifications", + HttpMethod.GET, + new HttpEntity<>(securityHelper.authHeadersNoBody(viewerJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void retryNotification() throws Exception { + AlertInstance instance = seedInstance(); + AlertNotification notification = seedNotification(instance.id()); + + // Mark as failed first + notificationRepo.markFailed(notification.id(), 500, "Internal Server Error"); + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/alerts/notifications/" + notification.id() + "/retry", + HttpMethod.POST, + new HttpEntity<>(securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.OK); + } + + @Test + void viewerCannotRetry() throws Exception { + AlertInstance instance = seedInstance(); + AlertNotification notification = seedNotification(instance.id()); + + ResponseEntity resp = restTemplate.exchange( + "/api/v1/alerts/notifications/" + notification.id() + "/retry", + HttpMethod.POST, + new HttpEntity<>(securityHelper.authHeaders(viewerJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + void retryUnknownNotificationReturns404() { + ResponseEntity resp = restTemplate.exchange( + "/api/v1/alerts/notifications/" + UUID.randomUUID() + "/retry", + HttpMethod.POST, + new HttpEntity<>(securityHelper.authHeaders(operatorJwt)), + String.class); + + assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private AlertInstance seedInstance() { + AlertInstance instance = new AlertInstance( + UUID.randomUUID(), null, null, envId, + AlertState.FIRING, AlertSeverity.WARNING, + Instant.now(), null, null, null, null, false, + 42.0, 1000.0, null, "Test alert", "Something happened", + List.of(), List.of(), List.of("OPERATOR")); + return instanceRepo.save(instance); + } + + private AlertNotification seedNotification(UUID instanceId) { + // webhookId is a local UUID (not FK-constrained), outboundConnectionId is null + // (FK to outbound_connections ON DELETE SET NULL - null is valid) + AlertNotification notification = new AlertNotification( + UUID.randomUUID(), instanceId, + UUID.randomUUID(), null, + NotificationStatus.PENDING, + 0, Instant.now(), + null, null, + null, null, + null, null, Instant.now()); + return notificationRepo.save(notification); + } + + private void seedUser(String userId) { + jdbcTemplate.update( + "INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING", + userId, userId + "@example.com", userId); + } +} diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepositoryIT.java index 11434a27..23f579b3 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepositoryIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertInstanceRepositoryIT.java @@ -1,11 +1,14 @@ package com.cameleer.server.app.alerting.storage; import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.search.ClickHouseLogStore; +import com.cameleer.server.app.search.ClickHouseSearchIndex; import com.cameleer.server.core.alerting.*; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; import java.time.Instant; import java.util.List; @@ -16,6 +19,9 @@ import static org.assertj.core.api.Assertions.assertThat; class PostgresAlertInstanceRepositoryIT extends AbstractPostgresIT { + @MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex; + @MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore; + private PostgresAlertInstanceRepository repo; private UUID envId; private UUID ruleId; diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertNotificationRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertNotificationRepositoryIT.java index b28ade89..41a744b3 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertNotificationRepositoryIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertNotificationRepositoryIT.java @@ -1,11 +1,14 @@ package com.cameleer.server.app.alerting.storage; import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.search.ClickHouseLogStore; +import com.cameleer.server.app.search.ClickHouseSearchIndex; import com.cameleer.server.core.alerting.*; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; import java.time.Instant; import java.util.List; @@ -16,6 +19,9 @@ import static org.assertj.core.api.Assertions.assertThat; class PostgresAlertNotificationRepositoryIT extends AbstractPostgresIT { + @MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex; + @MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore; + private PostgresAlertNotificationRepository repo; private UUID envId; private UUID instanceId; diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepositoryIT.java index 6cd829eb..e4fc74f0 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepositoryIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertReadRepositoryIT.java @@ -1,9 +1,12 @@ package com.cameleer.server.app.alerting.storage; import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.search.ClickHouseLogStore; +import com.cameleer.server.app.search.ClickHouseSearchIndex; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; import java.util.List; import java.util.UUID; @@ -13,6 +16,9 @@ import static org.assertj.core.api.Assertions.assertThatCode; class PostgresAlertReadRepositoryIT extends AbstractPostgresIT { + @MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex; + @MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore; + private PostgresAlertReadRepository repo; private UUID envId; private UUID instanceId1; diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertRuleRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertRuleRepositoryIT.java index 64d8f76d..6728daf7 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertRuleRepositoryIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertRuleRepositoryIT.java @@ -1,11 +1,14 @@ package com.cameleer.server.app.alerting.storage; import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.search.ClickHouseLogStore; +import com.cameleer.server.app.search.ClickHouseSearchIndex; import com.cameleer.server.core.alerting.*; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; import java.time.Instant; import java.util.List; @@ -16,6 +19,9 @@ import static org.assertj.core.api.Assertions.assertThat; class PostgresAlertRuleRepositoryIT extends AbstractPostgresIT { + @MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex; + @MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore; + private PostgresAlertRuleRepository repo; private UUID envId; diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertSilenceRepositoryIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertSilenceRepositoryIT.java index 1af01376..e2fa741f 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertSilenceRepositoryIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/PostgresAlertSilenceRepositoryIT.java @@ -1,12 +1,15 @@ package com.cameleer.server.app.alerting.storage; import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.search.ClickHouseLogStore; +import com.cameleer.server.app.search.ClickHouseSearchIndex; import com.cameleer.server.core.alerting.AlertSilence; import com.cameleer.server.core.alerting.SilenceMatcher; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -16,6 +19,9 @@ import static org.assertj.core.api.Assertions.assertThat; class PostgresAlertSilenceRepositoryIT extends AbstractPostgresIT { + @MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex; + @MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore; + private PostgresAlertSilenceRepository repo; private UUID envId; diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V12MigrationIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V12MigrationIT.java index babcebe7..d1fa4e45 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V12MigrationIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/alerting/storage/V12MigrationIT.java @@ -1,12 +1,18 @@ package com.cameleer.server.app.alerting.storage; import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.app.search.ClickHouseLogStore; +import com.cameleer.server.app.search.ClickHouseSearchIndex; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; import static org.assertj.core.api.Assertions.assertThat; class V12MigrationIT extends AbstractPostgresIT { + @MockBean(name = "clickHouseSearchIndex") ClickHouseSearchIndex clickHouseSearchIndex; + @MockBean(name = "clickHouseLogStore") ClickHouseLogStore clickHouseLogStore; + private java.util.UUID testEnvId; private String testUserId;