package server_test import ( "net" "strings" "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" ) // newServerApp builds an fx app holding a *server.Server wired exactly // as cmd/dnswatcher wires it, minus the watcher/resolver subtree that // would touch live DNS, plus the given option. config.New reads viper, // so the caller must first configure it, which is also why the caller // cannot run in parallel. func newServerApp(option fx.Option) *fx.App { return fx.New( fx.NopLogger, fx.Provide( globals.New, logger.New, config.New, state.New, healthcheck.New, notify.New, middleware.New, handlers.New, server.New, ), option, ) } // buildServer builds the server without starting the app's lifecycle, // so no OnStart hook runs and nothing listens or resolves. func buildServer(t *testing.T) *server.Server { t.Helper() var srv *server.Server app := newServerApp(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 serves on the port // it binds — carries every socket-level timeout, plus the two // relationships the values must satisfy. // // Run is given an unbindable port: it builds and stores s.httpServer, // then the bind fails and Run returns the error without ever // listening. Nothing here measures elapsed time. // // ReadTimeout must be at least ReadHeaderTimeout. net/http reads the // headers under ReadHeaderTimeout, then sets the read deadline for the // rest of the request to ReadTimeout, counted from when it started // reading the request. If ReadTimeout were smaller, a request whose // headers arrived after ReadTimeout but within ReadHeaderTimeout would // get a read deadline that had already passed, so reading its body // would fail at once. 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) err := srv.Run(t.Context()) if err == nil { t.Fatal("Run bound port -1") } 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, ) } } func TestPortInUseStopsStartup(t *testing.T) { // Bound on every address, as Run binds, so that Run's bind fails. lc := &net.ListenConfig{} taken, err := lc.Listen(t.Context(), "tcp", ":0") if err != nil { t.Fatalf("binding a port for the test: %v", err) } defer func() { _ = taken.Close() }() _, port, err := net.SplitHostPort(taken.Addr().String()) if err != nil { t.Fatalf("reading the bound port: %v", err) } viper.Reset() t.Setenv("DNSWATCHER_TARGETS", "example.com") t.Setenv("DNSWATCHER_DATA_DIR", t.TempDir()) t.Setenv("PORT", port) app := newServerApp(fx.Invoke(func(*server.Server) {})) err = app.Start(t.Context()) if err == nil { _ = app.Stop(t.Context()) t.Fatal("startup succeeded on a port that is already bound") } if !strings.Contains(err.Error(), ":"+port) { t.Errorf("startup error does not name port %s: %v", port, err) } }