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