Files
terdut-server/internal/prom/prom_test.go
T
Niklas Ye f344bb707b Graph the metric behind each alert on the incident page
Each alert carries a generatorURL pointing at the Prometheus rule that fired
it. The server now parses that link, queries the same Prometheus for the
rule's expression and draws it under the alert, with the current value
beside it. The browser never talks to Prometheus, so the CSP stays at
connect-src 'self'.

The URL comes out of a webhook payload, so whoever holds an integration key
chooses it. Querying it is therefore opt-in and bounded by an allowlist,
TERDUT_PROMETHEUS_ALLOWED_HOSTS (names, "*" as one label, a leading "**" as
several, IPs, CIDRs). The check runs on the address the dialer is about to
use, a name that resolves to loopback or link-local is refused even when it
matches, and redirects and proxy variables are ignored.

A worker takes a snapshot when an incident opens and another when it
closes, so the graph outlives Prometheus' retention. It finds its work in
the database rather than being triggered from the webhook, so ingestion is
untouched and a restart loses nothing. While an incident is open the
metrics endpoint also queries live, cached for 15 seconds.

The rule expression keeps its threshold, so Prometheus returns data only
while the alert condition holds; "no data" for the current value means the
condition cleared. Stripping the threshold needs a PromQL parser and is left
out.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
2026-10-22 14:10:34 +02:00

238 lines
8.2 KiB
Go

package prom
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"testing"
"time"
)
func TestParseAllowlistRejectsAllowAll(t *testing.T) {
for _, e := range []string{"*", "**", "*.*", "**.*", "a.**", "pro**.example.com", "ex ample.com", "host:abc", "10.0.0.0/33"} {
if _, err := ParseAllowlist([]string{e}); err == nil {
t.Errorf("%q was accepted", e)
}
}
}
func TestAllowlistNames(t *testing.T) {
a, err := ParseAllowlist([]string{
"prometheus.monitoring.svc",
"prom-*.monitoring.svc",
"*.example.com",
"**.corp.example",
"fixed.example.org:9090",
})
if err != nil {
t.Fatal(err)
}
pub := netip.MustParseAddr("10.1.2.3")
cases := []struct {
host, port string
want bool
}{
{"prometheus.monitoring.svc", "9090", true},
{"PROMETHEUS.monitoring.svc", "80", true},
{"prom-0.monitoring.svc", "9090", true},
{"prom-0.other.svc", "9090", false},
{"a.example.com", "443", true},
{"example.com", "443", false},
{"a.b.example.com", "443", false},
{"a.corp.example", "80", true},
{"a.b.c.corp.example", "80", true},
{"corp.example", "80", false},
{"fixed.example.org", "9090", true},
{"fixed.example.org", "80", false},
{"evilexample.com", "80", false},
{"a.example.com.evil.net", "80", false},
}
for _, c := range cases {
if got := a.permits(c.host, c.port, pub); got != c.want {
t.Errorf("permits(%s:%s) = %v, want %v", c.host, c.port, got, c.want)
}
}
}
func TestAllowlistForbiddenAddressesAndCIDR(t *testing.T) {
a, _ := ParseAllowlist([]string{"*.example.com", "192.168.0.0/16", "[fd00::]:9090", "127.0.0.9"})
cases := []struct {
name, host, port, ip string
want bool
}{
{"wildcard to private is fine", "a.example.com", "80", "10.0.0.5", true},
{"wildcard rebinding to loopback", "a.example.com", "80", "127.0.0.1", false},
{"wildcard to metadata", "a.example.com", "80", "169.254.169.254", false},
{"wildcard to unspecified", "a.example.com", "80", "0.0.0.0", false},
{"wildcard to v4-mapped loopback", "a.example.com", "80", "::ffff:127.0.0.1", false},
{"cidr literal", "192.168.4.5", "80", "192.168.4.5", true},
{"outside cidr", "10.9.9.9", "80", "10.9.9.9", false},
{"cidr admits unlisted name", "anything.test", "80", "192.168.1.1", true},
{"explicit loopback entry", "127.0.0.9", "80", "127.0.0.9", true},
{"other loopback", "127.0.0.1", "80", "127.0.0.1", false},
{"ipv6 with port ok", "fd00::1", "9090", "fd00::1", false}, // entry is a single address fd00::, not the range
{"ipv6 exact", "fd00::", "9090", "fd00::", true},
{"ipv6 wrong port", "fd00::", "80", "fd00::", false},
}
for _, c := range cases {
if got := a.permits(c.host, c.port, netip.MustParseAddr(c.ip)); got != c.want {
t.Errorf("%s: permits = %v, want %v", c.name, got, c.want)
}
}
}
func TestEmptyAllowlistPermitsNothing(t *testing.T) {
var nilList *Allowlist
empty, _ := ParseAllowlist(nil)
for _, a := range []*Allowlist{nilList, empty} {
if !a.Empty() || a.permits("x.example.com", "80", netip.MustParseAddr("10.0.0.1")) {
t.Error("empty allowlist permitted something")
}
}
}
func TestParseGeneratorURL(t *testing.T) {
cases := []struct {
raw string
wantBase string
wantExpr string
ok bool
}{
{"http://prom:9090/graph?g0.expr=up%3D%3D0&g0.tab=1", "http://prom:9090", "up==0", true},
{"https://p.example.com/prom/graph?g0.expr=rate%28x%5B5m%5D%29%3E1", "https://p.example.com/prom", "rate(x[5m])>1", true},
{"https://p.example.com/query?g0.expr=up", "https://p.example.com", "up", true},
{"http://prom:9090/graph?g0.tab=1", "", "", false},
{"http://prom:9090/other?g0.expr=up", "", "", false},
{"ftp://prom/graph?g0.expr=up", "", "", false},
{"http://user:pw@prom/graph?g0.expr=up", "", "", false},
{"", "", "", false},
{"not a url", "", "", false},
}
for _, c := range cases {
base, expr, ok := ParseGeneratorURL(c.raw)
if ok != c.ok || expr != c.wantExpr || (ok && base.String() != c.wantBase) {
t.Errorf("%q = (%v, %q, %v), want (%q, %q, %v)", c.raw, base, expr, ok, c.wantBase, c.wantExpr, c.ok)
}
}
}
func TestSelectSeries(t *testing.T) {
mk := func(l map[string]string) Series { return Series{Labels: l, Points: [][2]float64{{1, 1}}} }
series := []Series{
mk(map[string]string{"__name__": "up", "instance": "a", "job": "x"}),
mk(map[string]string{"__name__": "up", "instance": "b", "job": "x"}),
mk(map[string]string{"instance": "a", "job": "y"}),
{Labels: map[string]string{"instance": "a"}}, // no points
}
got := SelectSeries(series, map[string]string{"alertname": "Down", "instance": "a", "job": "x", "severity": "page"})
if len(got) != 1 || got[0].Labels["job"] != "x" || got[0].Labels["instance"] != "a" {
t.Fatalf("got %+v", got)
}
if n := len(SelectSeries(series, nil)); n != 3 {
t.Errorf("no alert labels should keep every series with points, got %d", n)
}
}
func TestStepFor(t *testing.T) {
now := time.Now()
if s := StepFor(now.Add(-time.Minute), now); s != time.Second {
t.Errorf("short span step = %v", s)
}
if s := StepFor(now.Add(-5*time.Hour), now); s != time.Minute {
t.Errorf("5h step = %v, want 1m", s)
}
}
func fakeProm(t *testing.T) (*httptest.Server, *int) {
t.Helper()
hits := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
r.ParseForm()
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/api/v1/query_range":
w.Write([]byte(`{"status":"success","data":{"resultType":"matrix","result":[
{"metric":{"instance":"a"},"values":[[100,"1"],[110,"NaN"],[120,"2.5"]]}]}}`))
case "/api/v1/query":
w.Write([]byte(`{"status":"success","data":{"resultType":"vector","result":[
{"metric":{"instance":"a"},"value":[130,"3"]}]}}`))
case "/redirect/api/v1/query":
http.Redirect(w, r, "http://127.0.0.1:1/", http.StatusFound)
default:
w.WriteHeader(400)
w.Write([]byte(`{"status":"error","errorType":"bad_data","error":"nope"}`))
}
}))
t.Cleanup(srv.Close)
return srv, &hits
}
func TestClientQueries(t *testing.T) {
srv, _ := fakeProm(t)
u, _ := url.Parse(srv.URL)
// httptest listens on loopback, which is refused unless listed as a CIDR.
allow, _ := ParseAllowlist([]string{"127.0.0.0/8"})
c := NewClient(allow, 5*time.Second)
got, err := c.QueryRange(context.Background(), u, "up", time.Unix(100, 0), time.Unix(120, 0), 10*time.Second)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || len(got[0].Points) != 2 || got[0].Points[1] != [2]float64{120, 2.5} {
t.Fatalf("range = %+v (NaN point should be dropped)", got)
}
inst, err := c.Query(context.Background(), u, "up", time.Unix(130, 0))
if err != nil || len(inst) != 1 || inst[0].Points[0][1] != 3 {
t.Fatalf("instant = %+v, %v", inst, err)
}
}
func TestClientRefusesUnlistedAndLoopback(t *testing.T) {
srv, hits := fakeProm(t)
u, _ := url.Parse(srv.URL)
// Listed by name, but the name resolves to loopback: refused at dial time.
local, _ := url.Parse("http://localhost:" + u.Port())
wild, _ := ParseAllowlist([]string{"local*"})
if _, err := NewClient(wild, time.Second).Query(context.Background(), local, "up", time.Now()); err == nil {
t.Error("wildcard-matched name resolving to loopback was queried")
}
// Not listed at all.
other, _ := ParseAllowlist([]string{"*.example.com"})
if _, err := NewClient(other, time.Second).Query(context.Background(), u, "up", time.Now()); !errors.Is(err, ErrNotAllowed) {
t.Errorf("unlisted host: err = %v", err)
}
// Disabled.
if _, err := NewClient(nil, time.Second).Query(context.Background(), u, "up", time.Now()); !errors.Is(err, ErrNotAllowed) {
t.Errorf("disabled: err = %v", err)
}
if *hits != 0 {
t.Errorf("server was contacted %d times", *hits)
}
}
func TestClientDoesNotFollowRedirects(t *testing.T) {
srv, _ := fakeProm(t)
u, _ := url.Parse(srv.URL + "/redirect")
allow, _ := ParseAllowlist([]string{"127.0.0.0/8"})
if _, err := NewClient(allow, time.Second).Query(context.Background(), u, "up", time.Now()); err == nil {
t.Error("redirect response was treated as success")
}
}
func TestClientSurfacesPrometheusError(t *testing.T) {
srv, _ := fakeProm(t)
u, _ := url.Parse(srv.URL + "/missing")
allow, _ := ParseAllowlist([]string{"127.0.0.0/8"})
_, err := NewClient(allow, time.Second).Query(context.Background(), u, "up", time.Now())
if err == nil {
t.Fatal("expected an error")
}
}