package smtp2shoutrrr import ( "context" "net" "testing" "time" "github.com/stretchr/testify/require" ) // loopbackAddr rewrites the bound address to a dialable loopback address. // The host stays "localhost" because smtp.PlainAuth refuses to authenticate // when the dialed host differs from the one it was constructed with. It // reports "" rather than failing the test, because it runs inside // require.Eventually conditions, which execute on their own goroutine where // t.FailNow would deadlock instead of reporting. func loopbackAddr(s *Server) string { _, port, err := net.SplitHostPort(s.Addr()) if err != nil { return "" } return net.JoinHostPort("localhost", port) } func canDial(addr string) bool { if addr == "" { return false } conn, err := net.DialTimeout("tcp", addr, 200*time.Millisecond) if err != nil { return false } return conn.Close() == nil } // startServer starts srv on an ephemeral port and blocks until it is bound, // returning the dial address and the channel Start returns on. Readiness is // the bind rather than a probe connection: the address is set before Serve // runs, connections queue in the backlog either way, and a probe that opens // and drops a connection only adds accept churn and server-side log noise. func startServer(t *testing.T, srv *Server) (string, context.CancelFunc, <-chan error) { t.Helper() ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) stopped := make(chan error, 1) go func() { stopped <- srv.Start(ctx) }() require.Eventually(t, func() bool { return srv.Addr() != "" || len(stopped) > 0 }, 5*time.Second, 10*time.Millisecond, "server never started listening") // Report why Start gave up rather than letting it look like a slow bind. select { case err := <-stopped: require.NoError(t, err, "server failed to start") t.Fatal("server stopped before it began serving") default: } return loopbackAddr(srv), cancel, stopped } func testConfig() *Config { return &Config{Port: 0, Username: "testuser", Password: "testpass"} } func TestServerShutsDownOnContextCancel(t *testing.T) { srv := NewSMTPServer(testConfig()) addr, cancel, stopped := startServer(t, srv) cancel() select { case err := <-stopped: require.NoError(t, err) case <-time.After(defaultShutdownTimeout + time.Second): t.Fatal("server did not shut down after context cancellation") } require.False(t, canDial(addr), "listener should be released once Start returns") } func TestServerStartReturnsListenError(t *testing.T) { blocker, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) t.Cleanup(func() { _ = blocker.Close() }) _, port, err := net.SplitHostPort(blocker.Addr().String()) require.NoError(t, err) config := testConfig() config.Port = mustAtoi(t, port) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) t.Cleanup(cancel) require.Error(t, NewSMTPServer(config).Start(ctx)) } // Regression: go-smtp's Shutdown only closes listeners Serve has registered. // When the stop won that race, Accept kept running and Start never returned — // a SIGTERM during startup left a process that only SIGKILL could clear. func TestServerStartReturnsWhenCancelledBeforeListening(t *testing.T) { srv := NewSMTPServer(testConfig()) ctx, cancel := context.WithCancel(context.Background()) cancel() stopped := make(chan error, 1) go func() { stopped <- srv.Start(ctx) }() select { case err := <-stopped: require.NoError(t, err) case <-time.After(10 * time.Second): t.Fatal("Start did not return when its context was cancelled before the listener registered") } require.False(t, canDial(loopbackAddr(srv)), "listener should not be left accepting connections") } // Regression: an idle client is only dropped once its read deadline expires, // so the drain lasts about ReadTimeout. With the shutdown budget set to the // same value, an ordinary stop surfaced as "context deadline exceeded" and the // command turned that into a non-zero exit. func TestServerDrainsIdleConnectionWithoutError(t *testing.T) { srv := NewSMTPServer(testConfig()) srv.backend.ReadTimeout = 300 * time.Millisecond addr, cancel, stopped := startServer(t, srv) conn, err := net.DialTimeout("tcp", addr, time.Second) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) cancel() select { case err := <-stopped: require.NoError(t, err, "an intentional shutdown must not report an error") case <-time.After(defaultShutdownTimeout + time.Second): t.Fatal("server did not drain the idle connection") } } // The drain waits out a client's read deadline, so the budget needs real room // above it. A strict inequality is not enough: a budget one millisecond over // readTimeout is the same coin flip as one equal to it. func TestShutdownBudgetLeavesMarginOverReadTimeout(t *testing.T) { require.GreaterOrEqual(t, defaultShutdownTimeout, readTimeout+10*time.Second) } // Regression: overrunning the drain budget is still an intentional stop. // Returning that deadline as an error made the command exit non-zero on a // routine SIGTERM. Driven with a real overrun — a client that stays connected // past a deliberately short budget — rather than asserted on the constant. func TestServerReportsNoErrorWhenDrainOverrunsBudget(t *testing.T) { srv := NewSMTPServer(testConfig()) // Long enough that the session outlives the budget, short enough that it // does not outlive the test binary. srv.backend.ReadTimeout = 3 * time.Second srv.shutdownTimeout = 300 * time.Millisecond addr, cancel, stopped := startServer(t, srv) conn, err := net.DialTimeout("tcp", addr, time.Second) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) // Wait for the greeting so the session is registered and genuinely in // flight when the drain starts. _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) buf := make([]byte, 64) _, err = conn.Read(buf) require.NoError(t, err) start := time.Now() cancel() select { case err := <-stopped: require.NoError(t, err, "a drain that outruns its budget is still an intentional stop") case <-time.After(srv.backend.ReadTimeout + 5*time.Second): t.Fatal("Start did not return after the drain budget expired") } require.Less(t, time.Since(start), srv.backend.ReadTimeout, "Start should return on the budget, not wait out the read deadline") } func mustAtoi(t *testing.T, s string) int { t.Helper() n, err := net.LookupPort("tcp", s) require.NoError(t, err) return n }