package server_test import ( "testing" "github.com/spf13/viper" "go.uber.org/fx" "sneak.berlin/go/dnswatcher/internal/config" "sneak.berlin/go/dnswatcher/internal/globals" "sneak.berlin/go/dnswatcher/internal/handlers" "sneak.berlin/go/dnswatcher/internal/healthcheck" "sneak.berlin/go/dnswatcher/internal/logger" "sneak.berlin/go/dnswatcher/internal/middleware" "sneak.berlin/go/dnswatcher/internal/notify" "sneak.berlin/go/dnswatcher/internal/server" "sneak.berlin/go/dnswatcher/internal/state" ) // buildServer wires a *server.Server exactly as cmd/dnswatcher does, // minus the watcher/resolver subtree that would touch live DNS. fx // builds the object graph but the lifecycle is never started, so no // OnStart hook runs and nothing listens or resolves. The caller must // first configure viper (config.New reads it), which is also why the // caller cannot run in parallel. func buildServer(t *testing.T) *server.Server { t.Helper() var srv *server.Server app := fx.New( fx.NopLogger, fx.Provide( globals.New, logger.New, config.New, state.New, healthcheck.New, notify.New, middleware.New, handlers.New, server.New, ), fx.Populate(&srv), ) err := app.Err() if err != nil { t.Fatalf("building server graph: %v", err) } return srv } // TestRunWiresSocketTimeouts pins that the http.Server the running // server actually serves — the one Run builds and hands to // ListenAndServe — carries every socket-level timeout, plus the two // relationships the values must satisfy. Earlier tests asserted these // on newHTTPServer directly, which left the call site unguarded: a Run // that built its http.Server inline would drop every timeout with the // suite still green (https://git.eeqj.de/sneak/dnswatcher/issues/120). // // Run is driven to completion with an unbindable port: it builds and // stores s.httpServer, then ListenAndServe fails at once and Run // returns without ever listening. The assertions run in the same // goroutine after Run returns, so reading s.httpServer is free of any // data race. Nothing here measures elapsed time. // // On the ReadTimeout >= ReadHeaderTimeout relationship: a smaller // ReadTimeout does NOT make the header phase unreachable. net/http's // (*Server).readHeaderTimeout applies ReadHeaderTimeout directly, so // the header read keeps its full budget. What breaks is the // whole-request deadline: once the headers are read, readRequest // installs a read deadline of t0+ReadTimeout, which is already in the // past when ReadTimeout is the smaller value, severing the request. // Verified against the pinned go1.25 net/http (Dockerfile golang // 1.25-alpine; go.mod go 1.25.5): src/net/http/server.go readRequest // and (*Server).readHeaderTimeout. func TestRunWiresSocketTimeouts(t *testing.T) { // Sets an env var and touches viper global state, so like the // config tests it cannot use t.Parallel. viper.Reset() t.Setenv("DNSWATCHER_TARGETS", "example.com") srv := buildServer(t) server.SetListenPort(srv, -1) srv.Run() hs := server.HTTPServerOf(srv) if hs == nil { t.Fatal("Run did not build an http.Server") } if hs.ReadTimeout <= 0 { t.Errorf("ReadTimeout must be non-zero, got %v", hs.ReadTimeout) } if hs.ReadHeaderTimeout <= 0 { t.Errorf( "ReadHeaderTimeout must be non-zero, got %v", hs.ReadHeaderTimeout, ) } if hs.WriteTimeout <= 0 { t.Errorf("WriteTimeout must be non-zero, got %v", hs.WriteTimeout) } if hs.IdleTimeout <= 0 { t.Errorf("IdleTimeout must be non-zero, got %v", hs.IdleTimeout) } if hs.WriteTimeout <= server.RequestTimeout { t.Errorf( "WriteTimeout (%v) must exceed handler budget (%v)", hs.WriteTimeout, server.RequestTimeout, ) } if hs.ReadTimeout < hs.ReadHeaderTimeout { t.Errorf( "ReadTimeout (%v) must be >= ReadHeaderTimeout (%v)", hs.ReadTimeout, hs.ReadHeaderTimeout, ) } if hs.Handler != srv { t.Errorf( "Run wired handler %T, want the *server.Server", hs.Handler, ) } }