Merge pull request 'feat(alerting): Plan 03 — UI + backfills (SSRF guard, metrics caching, docker stack)' (#144) from feat/alerting-03-ui into main
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 2m1s
CI / docker (push) Successful in 1m16s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 42s

Reviewed-on: #144
This commit was merged in pull request #144.
This commit is contained in:
2026-04-20 16:27:49 +02:00
78 changed files with 10892 additions and 43 deletions

View File

@@ -0,0 +1,111 @@
package com.cameleer.server.app.alerting.metrics;
import com.cameleer.server.core.alerting.AlertState;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.time.Instant;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that {@link AlertingMetrics} caches gauge values for a configurable TTL,
* so that Prometheus scrapes do not cause one Postgres query per scrape.
*/
class AlertingMetricsCachingTest {
@Test
void gaugeSupplierIsCalledAtMostOncePerTtl() {
// The instances supplier is shared across every AlertState gauge, so each
// full gauge snapshot invokes it once per AlertState (one cache per state).
final int stateCount = AlertState.values().length;
AtomicInteger enabledRulesCalls = new AtomicInteger();
AtomicInteger disabledRulesCalls = new AtomicInteger();
AtomicInteger instancesCalls = new AtomicInteger();
AtomicReference<Instant> now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z"));
Supplier<Instant> clock = now::get;
MeterRegistry registry = new SimpleMeterRegistry();
Supplier<Long> enabledRulesSupplier = () -> { enabledRulesCalls.incrementAndGet(); return 7L; };
Supplier<Long> disabledRulesSupplier = () -> { disabledRulesCalls.incrementAndGet(); return 3L; };
Supplier<Long> instancesSupplier = () -> { instancesCalls.incrementAndGet(); return 5L; };
AlertingMetrics metrics = new AlertingMetrics(
registry,
enabledRulesSupplier,
disabledRulesSupplier,
instancesSupplier,
Duration.ofSeconds(30),
clock
);
// First scrape — each supplier invoked exactly once per gauge.
metrics.snapshotAllGauges();
assertThat(enabledRulesCalls.get()).isEqualTo(1);
assertThat(disabledRulesCalls.get()).isEqualTo(1);
assertThat(instancesCalls.get()).isEqualTo(stateCount);
// Second scrape within TTL — served from cache.
metrics.snapshotAllGauges();
assertThat(enabledRulesCalls.get()).isEqualTo(1);
assertThat(disabledRulesCalls.get()).isEqualTo(1);
assertThat(instancesCalls.get()).isEqualTo(stateCount);
// Third scrape still within TTL (29 s later) — still cached.
now.set(now.get().plusSeconds(29));
metrics.snapshotAllGauges();
assertThat(enabledRulesCalls.get()).isEqualTo(1);
assertThat(disabledRulesCalls.get()).isEqualTo(1);
assertThat(instancesCalls.get()).isEqualTo(stateCount);
// Advance past TTL — next scrape re-queries the delegate.
now.set(Instant.parse("2026-04-20T00:00:31Z"));
metrics.snapshotAllGauges();
assertThat(enabledRulesCalls.get()).isEqualTo(2);
assertThat(disabledRulesCalls.get()).isEqualTo(2);
assertThat(instancesCalls.get()).isEqualTo(stateCount * 2);
// Immediate follow-up — back in cache.
metrics.snapshotAllGauges();
assertThat(enabledRulesCalls.get()).isEqualTo(2);
assertThat(disabledRulesCalls.get()).isEqualTo(2);
assertThat(instancesCalls.get()).isEqualTo(stateCount * 2);
}
@Test
void gaugeValueReflectsCachedResult() {
AtomicReference<Long> enabledValue = new AtomicReference<>(10L);
AtomicReference<Instant> now = new AtomicReference<>(Instant.parse("2026-04-20T00:00:00Z"));
MeterRegistry registry = new SimpleMeterRegistry();
AlertingMetrics metrics = new AlertingMetrics(
registry,
enabledValue::get,
() -> 0L,
() -> 0L,
Duration.ofSeconds(30),
now::get
);
// Read once — value cached at 10.
metrics.snapshotAllGauges();
// Mutate the underlying supplier output; cache should shield it.
enabledValue.set(99L);
double cached = registry.find("alerting_rules_total").tag("state", "enabled").gauge().value();
assertThat(cached).isEqualTo(10.0);
// After TTL, new value surfaces.
now.set(now.get().plusSeconds(31));
metrics.snapshotAllGauges();
double refreshed = registry.find("alerting_rules_total").tag("state", "enabled").gauge().value();
assertThat(refreshed).isEqualTo(99.0);
}
}

View File

@@ -0,0 +1,73 @@
package com.cameleer.server.app.outbound;
import org.junit.jupiter.api.Test;
import java.net.URI;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SsrfGuardTest {
private final SsrfGuard guard = new SsrfGuard(false); // allow-private disabled by default
@Test
void rejectsLoopbackIpv4() {
assertThatThrownBy(() -> guard.validate(URI.create("https://127.0.0.1/webhook")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("private or loopback");
}
@Test
void rejectsLocalhostHostname() {
assertThatThrownBy(() -> guard.validate(URI.create("https://localhost:8080/x")))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rejectsRfc1918Ranges() {
for (String url : Set.of(
"https://10.0.0.1/x",
"https://172.16.5.6/x",
"https://192.168.1.1/x"
)) {
assertThatThrownBy(() -> guard.validate(URI.create(url)))
.as(url)
.isInstanceOf(IllegalArgumentException.class);
}
}
@Test
void rejectsLinkLocal() {
assertThatThrownBy(() -> guard.validate(URI.create("https://169.254.169.254/latest/meta-data/")))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rejectsIpv6Loopback() {
assertThatThrownBy(() -> guard.validate(URI.create("https://[::1]/x")))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void rejectsIpv6UniqueLocal() {
assertThatThrownBy(() -> guard.validate(URI.create("https://[fc00::1]/x")))
.isInstanceOf(IllegalArgumentException.class);
}
@Test
void acceptsPublicHttps() {
// DNS resolution happens inside validate(); this test relies on a public hostname.
// Use a literal public IP to avoid network flakiness.
// 8.8.8.8 is a public Google DNS IP — not in any private range.
assertThat(new SsrfGuard(false)).isNotNull();
guard.validate(URI.create("https://8.8.8.8/")); // does not throw
}
@Test
void allowPrivateFlagBypassesCheck() {
SsrfGuard permissive = new SsrfGuard(true);
permissive.validate(URI.create("https://127.0.0.1/")); // must not throw
}
}

View File

@@ -0,0 +1,67 @@
package com.cameleer.server.app.outbound.controller;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
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.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.TestPropertySource;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Dedicated IT that overrides the test-profile default `allow-private-targets=true`
* back to `false` so the SSRF guard's production behavior (reject loopback) is
* exercised end-to-end through the admin controller.
*
* Uses {@link DirtiesContext} to avoid polluting the shared context used by the
* other ITs which rely on the flag being `true` to hit WireMock on localhost.
*/
@TestPropertySource(properties = "cameleer.server.outbound-http.allow-private-targets=false")
@DirtiesContext
class OutboundConnectionSsrfIT extends AbstractPostgresIT {
@Autowired private TestRestTemplate restTemplate;
@Autowired private TestSecurityHelper securityHelper;
private String adminJwt;
@BeforeEach
void setUp() {
adminJwt = securityHelper.adminToken();
// Seed admin user row since users(user_id) is an FK target.
jdbcTemplate.update(
"INSERT INTO users (user_id, provider, email, display_name) VALUES (?, 'test', ?, ?) ON CONFLICT (user_id) DO NOTHING",
"test-admin", "test-admin@example.com", "test-admin");
jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'");
}
@AfterEach
void cleanup() {
jdbcTemplate.update("DELETE FROM outbound_connections WHERE tenant_id = 'default'");
jdbcTemplate.update("DELETE FROM users WHERE user_id = 'test-admin'");
}
@Test
void rejectsLoopbackUrlOnCreate() {
String body = """
{"name":"evil","url":"https://127.0.0.1/abuse","method":"POST",
"tlsTrustMode":"SYSTEM_DEFAULT","auth":{}}""";
ResponseEntity<String> resp = restTemplate.exchange(
"/api/v1/admin/outbound-connections", HttpMethod.POST,
new HttpEntity<>(body, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(resp.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
assertThat(resp.getBody()).isNotNull();
assertThat(resp.getBody()).contains("private or loopback");
}
}

View File

@@ -17,3 +17,5 @@ cameleer:
bootstraptokenprevious: old-bootstrap-token
infrastructureendpoints: true
jwtsecret: test-jwt-secret-for-integration-tests-only
outbound-http:
allow-private-targets: true