check / check (push) Successful in 3m12s
With SENTRY_DSN set, the server initialises sentry-go with the release netwatch-server-<version>, adds the sentryhttp middleware with Repanic as the last router-wide middleware, after the timeout, and flushes Sentry for 2 seconds on shutdown. A DSN Sentry refuses stops the start with an error naming SENTRY_DSN. With it empty, nothing is set up. The metrics middleware stays on the matched routes only, so it runs inside the Sentry middleware rather than before it. Model: opus-5-5
299 lines
7.8 KiB
Go
299 lines
7.8 KiB
Go
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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|