package server_test import ( "io" "net/http" "net/http/httptest" "strings" "testing" "time" "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" "github.com/getsentry/sentry-go" "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) } } // TestMetricsAreRateLimited: a client that has used up its /metrics // allowance on wrong passwords gets 429 even with the right one, which // is then not checked, while another client behind the same nginx // still gets in. func TestMetricsAreRateLimited(t *testing.T) { t.Setenv("METRICS_USERNAME", "prometheus") t.Setenv("METRICS_PASSWORD", "right") // As in the container: nginx connects from loopback and names the // client in X-Forwarded-For. t.Setenv("TRUSTED_PROXIES", "127.0.0.1/32") srv := newServer(t) srv.SetupRoutes() get := func(client, password string) int { rec := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/metrics", http.NoBody) req.RemoteAddr = "127.0.0.1:40000" req.Header.Set("X-Forwarded-For", client) req.SetBasicAuth("prometheus", password) srv.ServeHTTP(rec, req) return rec.Code } for i := range server.MetricsRequestsPerMinute { if code := get("203.0.113.7", "wrong"); code != http.StatusUnauthorized { t.Fatalf("guess %d: status = %d, want %d", i+1, code, http.StatusUnauthorized) } } if code := get("203.0.113.7", "right"); code != http.StatusTooManyRequests { t.Fatalf("right password past the limit: status = %d, want %d", code, http.StatusTooManyRequests) } if code := get("203.0.113.8", "right"); code != http.StatusOK { t.Fatalf("another client: status = %d, want %d", code, http.StatusOK) } } // 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() } } // TestSentry: with SENTRY_DSN empty there is no Sentry client. With it // pointing at a local server standing in for Sentry, a panic in a // handler reaches that server, and the request still gets the 500 from // the panic recovery. func TestSentry(t *testing.T) { const panicMessage = "handler panic for TestSentry" // sentry.Init sets the client for the whole process; take it away // again so that no other test reports to Sentry. t.Cleanup(func() { sentry.CurrentHub().BindClient(nil) }) t.Setenv("SENTRY_DSN", "") newServer(t) if sentry.CurrentHub().Client() != nil { t.Fatal("a Sentry client exists with SENTRY_DSN empty") } // The body of the first request the stand-in for Sentry receives. received := make(chan string, 1) sentryServer := httptest.NewServer(http.HandlerFunc( func(_ http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) select { case received <- string(body): default: } }, )) defer sentryServer.Close() t.Setenv("SENTRY_DSN", "http://key@"+sentryServer.Listener.Addr().String()+"/1") srv := newServer(t) srv.SetupRoutes() srv.Router().Get("/panic", func(http.ResponseWriter, *http.Request) { panic(panicMessage) }) rec := httptest.NewRecorder() req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/panic", http.NoBody) srv.ServeHTTP(rec, req) if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) } // Sentry sends from a goroutine of its own. select { case body := <-received: if !strings.Contains(body, panicMessage) { t.Fatalf("the Sentry server received no report of the panic:\n%s", body) } case <-time.After(5 * time.Second): t.Fatal("nothing reached the Sentry server") } } // 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) } }