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") } }