The Go module moves from backend/ to the root (go.mod, go.sum, .golangci.yml, cmd/, internal/), the page's source to web/, and nginx's two files to configs/. backend/ is gone: its LICENSE and .dockerignore are deleted, and its .gitignore entries, README and scripts are folded into the root ones. script/build builds the server binary after the frontend; script/run and script/clean move up from backend/script/; make test, lint, fmt and fmt-check do what the backend's did. Vite's root is web/ and it still builds into dist/. go.mod ignores node_modules/, whose npm packages can ship .go files. script/bootstrap no longer installs gcc, which only the host Go tests needed. Model: opus-5-5
This commit was merged in pull request #127.
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user