Files
dnswatcher/internal/server/server_test.go
T
sneak 71b8d46f7b
check / check (push) Failing after 1s
server: assert timeouts on the served http.Server, not just the constructor (closes #120)
The timeout tests called newHTTPServer directly, leaving Run's call
site unguarded: a Run that built its http.Server inline would drop
every timeout with the suite still green. TestRunWiresSocketTimeouts
now wires a Server as cmd/dnswatcher does (minus watcher and resolver,
so no live DNS), drives Run with an unbindable port so it stores its
http.Server and returns without listening, and asserts the served
server carries all four timeouts and both required relationships.
Reverting Run to an inline http.Server without timeouts fails it. The
near-tautological addr/handler test is dropped; the wiring test covers
the handler end to end.

The read-deadline note is corrected: a too-small ReadTimeout does not
make ReadHeaderTimeout unreachable (net/http applies it directly); it
installs an already-expired whole-request deadline. Verified against
the pinned go1.25 net/http.

Model: opus-4-8
2026-09-21 07:51:28 +00:00

137 lines
3.9 KiB
Go

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,
)
}
}