Files
developer e8c5b414d9
build / build-push (push) Failing after 23s
half broken
2026-09-27 17:17:53 +00:00

137 lines
5.7 KiB
Go

package api
import (
"net/http"
"strings"
"testing"
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
)
// stubMetrics is the exposition handler the app would have installed. The api
// package takes it as a func so it does not import the metrics package, which
// means a test can hand it a two-line stand-in and assert on the plumbing
// (routing, auth, content type) instead of on Prometheus internals.
func stubMetrics() http.Handler {
body := "# HELP fancydns_test_metric stub\n# TYPE fancydns_test_metric gauge\nfancydns_test_metric 1\n"
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
_, _ = w.Write([]byte(body))
})
}
// wireMetrics installs a stub exposition handler and restores the "nothing
// installed" state afterwards, so the package-level handler does not leak into
// the next test.
func wireMetrics(t *testing.T, h http.Handler) {
t.Helper()
SetMetricsHandler(h)
t.Cleanup(func() { SetMetricsHandler(nil) })
}
// TestMetricsDisabledIsNotRouted pins the default: with metrics.enabled false
// there is no /metrics route at all, so the 404 is the mux's and not a handler
// answering "metrics are disabled". A scrape of a disabled instance must fail
// loudly enough to be noticed.
func TestMetricsDisabledIsNotRouted(t *testing.T) {
wireMetrics(t, stubMetrics())
h := newHarness(t, nil) // testConfig leaves Metrics.Enabled false
res := h.get("/api/metrics")
if res.status != http.StatusNotFound {
t.Fatalf("GET /api/metrics with metrics disabled = %d, want 404: %s", res.status, res.body)
}
if strings.Contains(string(res.body), "fancydns_test_metric") {
t.Error("a disabled instance served metrics")
}
}
// TestMetricsServesExposition checks the enabled, unprotected case: no API key
// needed, the Prometheus text content type comes through, and the body is the
// handler's.
func TestMetricsServesExposition(t *testing.T) {
wireMetrics(t, stubMetrics())
h := newHarness(t, func(c *config.Config) {
c.Metrics = config.Metrics{Enabled: true, Path: "/metrics", Protect: false}
})
res := h.do(http.MethodGet, "/api/metrics", "")
if res.status != http.StatusOK {
t.Fatalf("GET /api/metrics = %d, want 200: %s", res.status, res.body)
}
if got := res.header.Get("Content-Type"); !strings.HasPrefix(got, "text/plain") {
t.Errorf("Content-Type = %q, want the Prometheus text/plain form", got)
}
if !strings.Contains(string(res.body), "fancydns_test_metric 1") {
t.Errorf("body does not carry the exposition: %q", res.body)
}
// metrics.path is documented as unused (README: "the endpoint is always
// ${api.base_path}/metrics"), so a config that sets it to something else
// must not move the endpoint. Pinned here because the natural-looking fix
// — honour the field — is a URL change under a running scrape.
h2 := newHarness(t, func(c *config.Config) {
c.Metrics = config.Metrics{Enabled: true, Path: "/internal/metrics", Protect: false}
})
if res := h2.do(http.MethodGet, "/api/metrics", ""); res.status != http.StatusOK {
t.Errorf("GET /api/metrics with a custom metrics path = %d, want 200: %s", res.status, res.body)
}
if res := h2.do(http.MethodGet, "/internal/metrics", ""); res.status != http.StatusNotFound {
t.Errorf("GET /internal/metrics = %d, want 404: metrics.path is not honoured: %s", res.status, res.body)
}
}
// TestMetricsProtectedRequiresAKey checks metrics.protect: the scrape endpoint
// exposes client addresses and queried domains, so without a key it must not
// answer, and with one it must. The principal is reused from the authed
// routes, so a read-only key is enough.
func TestMetricsProtectedRequiresAKey(t *testing.T) {
wireMetrics(t, stubMetrics())
h := newHarness(t, func(c *config.Config) {
c.Metrics = config.Metrics{Enabled: true, Path: "/metrics", Protect: true}
})
cases := []struct {
name string
header string
want int
}{
{"no key", "", http.StatusUnauthorized},
{"wrong key", "X-API-Key: nope", http.StatusUnauthorized},
{"read-only key", "X-API-Key: ro-key", http.StatusOK},
{"full-access key", "X-API-Key: rw-key", http.StatusOK},
{"basic credentials", "Authorization: Basic YWRtaW46YWRtaW4=", http.StatusUnauthorized},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := h.do(http.MethodGet, "/api/metrics", "", tc.header)
if res.status != tc.want {
t.Fatalf("GET /api/metrics with %s = %d, want %d: %s", tc.name, res.status, tc.want, res.body)
}
if tc.want == http.StatusOK && !strings.Contains(string(res.body), "fancydns_test_metric") {
t.Errorf("authorised scrape did not reach the handler: %q", res.body)
}
})
}
if got := h.do(http.MethodGet, "/api/metrics", "").json(t)["error"]; got == nil {
t.Error("the unauthenticated scrape did not answer with a JSON error")
}
}
// TestMetricsHandlerNotWired pins the third state, which is easy to leave
// untested and looks like a product bug when it happens: metrics.enabled true
// but the app never installed the handler. The route answers 501 rather than
// panicking on a nil handler, because the api package holds it as a func it
// never sets itself.
func TestMetricsHandlerNotWired(t *testing.T) {
wireMetrics(t, nil) // enabled, but nothing installed
h := newHarness(t, func(c *config.Config) {
c.Metrics = config.Metrics{Enabled: true, Path: "/metrics", Protect: false}
})
res := h.do(http.MethodGet, "/api/metrics", "")
if res.status != http.StatusNotImplemented {
t.Fatalf("GET /api/metrics with no handler wired = %d, want 501: %s", res.status, res.body)
}
// http.Error, so this one is plain text rather than the JSON envelope the
// rest of the API uses.
if !strings.Contains(string(res.body), "metrics not available") {
t.Errorf("body = %q, want it to say metrics not available", res.body)
}
}