check / check (push) Waiting to run
With METRICS_USERNAME and METRICS_PASSWORD both set, the backend records request metrics through go-http-metrics in a registry of its own, with Go's runtime and process metrics, and serves them at GET /metrics behind basic auth; nginx passes /metrics to it. With neither set there is no such route; one alone, or a METRICS_USERNAME containing ":", stops the start with an error naming the setting. Only requests that reach the health check or POST /api/v1/reports are recorded, as the labels are path and method, which clients could otherwise make up without end; POST /api/v1/reports is registered by its full path for that. Deviation: go get and go mod tidy ran directly; no entrypoint added a Go dependency yet (issue #45). Model: opus-5-5
232 lines
6.0 KiB
Go
232 lines
6.0 KiB
Go
package server_test
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"sneak.berlin/go/netwatch/internal/config"
|
|
"sneak.berlin/go/netwatch/internal/globals"
|
|
"sneak.berlin/go/netwatch/internal/handlers"
|
|
"sneak.berlin/go/netwatch/internal/healthcheck"
|
|
"sneak.berlin/go/netwatch/internal/logger"
|
|
"sneak.berlin/go/netwatch/internal/middleware"
|
|
"sneak.berlin/go/netwatch/internal/reportbuf"
|
|
"sneak.berlin/go/netwatch/internal/server"
|
|
|
|
"go.uber.org/fx"
|
|
"go.uber.org/fx/fxtest"
|
|
)
|
|
|
|
// newServer builds a Server from the same constructors as main,
|
|
// configured from the environment. It is never started, so nothing
|
|
// listens.
|
|
func newServer(t *testing.T) *server.Server {
|
|
t.Helper()
|
|
|
|
var srv *server.Server
|
|
|
|
app := fxtest.New(t,
|
|
fx.Provide(
|
|
config.New,
|
|
globals.New,
|
|
handlers.New,
|
|
healthcheck.New,
|
|
logger.New,
|
|
middleware.New,
|
|
reportbuf.New,
|
|
server.New,
|
|
),
|
|
fx.Populate(&srv),
|
|
)
|
|
|
|
err := app.Err()
|
|
if err != nil {
|
|
t.Fatalf("build server: %v", err)
|
|
}
|
|
|
|
return srv
|
|
}
|
|
|
|
// TestReportsAreRateLimited checks that POST /api/v1/reports is
|
|
// behind the per-address rate limit, set here to two a minute.
|
|
func TestReportsAreRateLimited(t *testing.T) {
|
|
t.Setenv("REPORTS_PER_MINUTE", "2")
|
|
|
|
srv := newServer(t)
|
|
srv.SetupRoutes()
|
|
|
|
post := func() int {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequestWithContext(t.Context(),
|
|
http.MethodPost, "/api/v1/reports",
|
|
strings.NewReader(`{"clientId":"c1","hosts":[]}`),
|
|
)
|
|
srv.ServeHTTP(rec, req)
|
|
|
|
return rec.Code
|
|
}
|
|
|
|
for i := range 2 {
|
|
if code := post(); code != http.StatusOK {
|
|
t.Fatalf("report %d: status = %d, want %d",
|
|
i+1, code, http.StatusOK)
|
|
}
|
|
}
|
|
|
|
if code := post(); code != http.StatusTooManyRequests {
|
|
t.Fatalf("third report in a minute: status = %d, want %d",
|
|
code, http.StatusTooManyRequests)
|
|
}
|
|
}
|
|
|
|
// TestCORSAllowedOriginsReachTheRouter checks that an origin listed in
|
|
// CORS_ALLOWED_ORIGINS is allowed by the router, not only when handed
|
|
// to the CORS middleware directly.
|
|
func TestCORSAllowedOriginsReachTheRouter(t *testing.T) {
|
|
const origin = "https://netwatch.example:8443"
|
|
|
|
t.Setenv("CORS_ALLOWED_ORIGINS", origin)
|
|
|
|
srv := newServer(t)
|
|
srv.SetupRoutes()
|
|
|
|
// The preflight a browser sends before it POSTs JSON from origin.
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequestWithContext(t.Context(),
|
|
http.MethodOptions, "/api/v1/reports", http.NoBody)
|
|
req.Header.Set("Origin", origin)
|
|
req.Header.Set("Access-Control-Request-Method", http.MethodPost)
|
|
req.Header.Set("Access-Control-Request-Headers", "content-type")
|
|
srv.ServeHTTP(rec, req)
|
|
|
|
got := rec.Header().Get("Access-Control-Allow-Origin")
|
|
if got != origin {
|
|
t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, origin)
|
|
}
|
|
}
|
|
|
|
// TestNoMetricsWithoutCredentials: with neither metrics setting set,
|
|
// there is no /metrics.
|
|
func TestNoMetricsWithoutCredentials(t *testing.T) {
|
|
t.Setenv("METRICS_USERNAME", "")
|
|
t.Setenv("METRICS_PASSWORD", "")
|
|
|
|
srv := newServer(t)
|
|
srv.SetupRoutes()
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequestWithContext(t.Context(),
|
|
http.MethodGet, "/metrics", http.NoBody)
|
|
srv.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
// TestMetricsBehindBasicAuth: with both metrics settings set, /metrics
|
|
// answers only with them as basic auth credentials, and shows a
|
|
// request to a route but not one to a path no route has.
|
|
func TestMetricsBehindBasicAuth(t *testing.T) {
|
|
t.Setenv("METRICS_USERNAME", "prometheus")
|
|
t.Setenv("METRICS_PASSWORD", "right")
|
|
|
|
srv := newServer(t)
|
|
srv.SetupRoutes()
|
|
|
|
get := func(path, username, password string) *httptest.ResponseRecorder {
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequestWithContext(t.Context(),
|
|
http.MethodGet, path, http.NoBody)
|
|
|
|
if username != "" {
|
|
req.SetBasicAuth(username, password)
|
|
}
|
|
|
|
srv.ServeHTTP(rec, req)
|
|
|
|
return rec
|
|
}
|
|
|
|
get("/.well-known/healthcheck", "", "")
|
|
get("/api/v1/no-such-route", "", "")
|
|
|
|
for _, creds := range [][2]string{
|
|
{"", ""},
|
|
{"prometheus", "wrong"},
|
|
{"someone", "right"},
|
|
} {
|
|
rec := get("/metrics", creds[0], creds[1])
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Errorf("credentials %q: status = %d, want %d",
|
|
creds, rec.Code, http.StatusUnauthorized)
|
|
}
|
|
}
|
|
|
|
rec := get("/metrics", "prometheus", "right")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("right credentials: status = %d, want %d",
|
|
rec.Code, http.StatusOK)
|
|
}
|
|
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, `handler="/.well-known/healthcheck"`) {
|
|
t.Errorf("metrics show no health check request:\n%s", body)
|
|
}
|
|
|
|
if strings.Contains(body, "no-such-route") {
|
|
t.Errorf("metrics show a request to a path no route has:\n%s", body)
|
|
}
|
|
|
|
if !strings.Contains(body, "go_goroutines") {
|
|
t.Errorf("metrics show no Go runtime metrics:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// TestMetricsInTwoServers: two servers in one process can both have
|
|
// metrics on.
|
|
func TestMetricsInTwoServers(t *testing.T) {
|
|
t.Setenv("METRICS_USERNAME", "prometheus")
|
|
t.Setenv("METRICS_PASSWORD", "right")
|
|
|
|
for range 2 {
|
|
newServer(t).SetupRoutes()
|
|
}
|
|
}
|
|
|
|
// TestHealthCheckRejectsOversizeBody sends the health check, which
|
|
// never reads its body, a body one byte over the limit. Only the
|
|
// router-wide body limit can reject it.
|
|
func TestHealthCheckRejectsOversizeBody(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := newServer(t)
|
|
srv.SetupRoutes()
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequestWithContext(t.Context(),
|
|
http.MethodGet, "/.well-known/healthcheck",
|
|
strings.NewReader(
|
|
strings.Repeat("x", int(server.MaxRequestBodyBytes)+1),
|
|
),
|
|
)
|
|
|
|
srv.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("status = %d, want %d",
|
|
rec.Code, http.StatusRequestEntityTooLarge)
|
|
}
|
|
|
|
if got := rec.Body.String(); got != "{\"status\":\"error\"}\n" {
|
|
t.Errorf("body = %q, want %q", got, "{\"status\":\"error\"}\n")
|
|
}
|
|
|
|
got := rec.Header().Get("Content-Type")
|
|
if got != "application/json; charset=utf-8" {
|
|
t.Errorf("Content-Type = %q, want a JSON content type", got)
|
|
}
|
|
}
|