206 lines
6.0 KiB
Go
206 lines
6.0 KiB
Go
package email
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"net"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// fakeSMTPServer is a deliberately tiny SMTP responder: just enough of the
|
|
// protocol for the probe to negotiate a connection. It exists so the probe can be
|
|
// exercised against a real socket and a real EHLO/STARTTLS exchange instead of a
|
|
// mock that would pass even if the transport code were wrong.
|
|
type fakeSMTPServer struct {
|
|
listener net.Listener
|
|
advertised []string
|
|
greeting string
|
|
mu sync.Mutex
|
|
connections int
|
|
}
|
|
|
|
func startFakeSMTPServer(t *testing.T, advertised []string) *fakeSMTPServer {
|
|
t.Helper()
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen error = %v", err)
|
|
}
|
|
server := &fakeSMTPServer{listener: listener, advertised: advertised, greeting: "220 fake ESMTP ready"}
|
|
t.Cleanup(func() { _ = listener.Close() })
|
|
|
|
go func() {
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
server.mu.Lock()
|
|
server.connections++
|
|
server.mu.Unlock()
|
|
go server.handle(conn)
|
|
}
|
|
}()
|
|
return server
|
|
}
|
|
|
|
func (s *fakeSMTPServer) address() (string, int) {
|
|
host, port, err := net.SplitHostPort(s.listener.Addr().String())
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return host, atoiOrFail(port)
|
|
}
|
|
|
|
func atoiOrFail(value string) int {
|
|
total := 0
|
|
for _, r := range value {
|
|
total = total*10 + int(r-'0')
|
|
}
|
|
return total
|
|
}
|
|
|
|
func (s *fakeSMTPServer) handle(conn net.Conn) {
|
|
defer func() { _ = conn.Close() }()
|
|
writer := bufio.NewWriter(conn)
|
|
_, _ = writer.WriteString(s.greeting + "\r\n")
|
|
_ = writer.Flush()
|
|
|
|
scanner := bufio.NewScanner(conn)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
switch {
|
|
case strings.HasPrefix(strings.ToUpper(line), "EHLO"), strings.HasPrefix(strings.ToUpper(line), "HELO"):
|
|
_, _ = writer.WriteString("250-" + s.listener.Addr().String() + "\r\n")
|
|
for i, ext := range s.advertised {
|
|
sep := "-"
|
|
if i == len(s.advertised)-1 {
|
|
sep = " "
|
|
}
|
|
_, _ = writer.WriteString("250" + sep + ext + "\r\n")
|
|
}
|
|
if len(s.advertised) == 0 {
|
|
_, _ = writer.WriteString("250 SIZE 35882577\r\n")
|
|
}
|
|
_ = writer.Flush()
|
|
case strings.HasPrefix(strings.ToUpper(line), "QUIT"):
|
|
_, _ = writer.WriteString("221 Bye\r\n")
|
|
_ = writer.Flush()
|
|
return
|
|
case strings.HasPrefix(strings.ToUpper(line), "STARTTLS"):
|
|
_, _ = writer.WriteString("454 TLS not available\r\n")
|
|
_ = writer.Flush()
|
|
case strings.HasPrefix(strings.ToUpper(line), "AUTH"):
|
|
_, _ = writer.WriteString("535 Authentication credentials invalid\r\n")
|
|
_ = writer.Flush()
|
|
default:
|
|
_, _ = writer.WriteString("250 OK\r\n")
|
|
_ = writer.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
func probeConfig(host string, port int) ClientConfig {
|
|
return ClientConfig{
|
|
Provider: ProviderSMTP,
|
|
SMTPHost: host,
|
|
SMTPPort: port,
|
|
SMTPUseTLS: false,
|
|
}
|
|
}
|
|
|
|
func TestProbeSMTPConnectsAndReportsCapabilities(t *testing.T) {
|
|
server := startFakeSMTPServer(t, []string{"PIPELINING", "8BITMIME", "SIZE 35882577"})
|
|
host, port := server.address()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
result, err := ProbeSMTP(ctx, probeConfig(host, port))
|
|
if err != nil {
|
|
t.Fatalf("ProbeSMTP() error = %v", err)
|
|
}
|
|
if result.Host != host || result.Port != port {
|
|
t.Fatalf("probe reported %s:%d, want %s:%d", result.Host, result.Port, host, port)
|
|
}
|
|
if !contains(result.Capabilities, "8BITMIME") || !contains(result.Capabilities, "PIPELINING") {
|
|
t.Fatalf("capabilities = %v, want the advertised extensions", result.Capabilities)
|
|
}
|
|
if contains(result.Capabilities, "STARTTLS") {
|
|
t.Fatal("STARTTLS was not advertised but shows as a capability")
|
|
}
|
|
// A server with no credentials configured must not report an auth attempt.
|
|
if result.AuthAttempted {
|
|
t.Fatal("a binding with no credentials should not attempt auth")
|
|
}
|
|
}
|
|
|
|
// The test-server escape hatch: a self-signed endpoint that advertises STARTTLS
|
|
// would otherwise fail verification and take the probe down with it. Here the
|
|
// fake refuses the upgrade outright, so the probe must name the transport step
|
|
// and still report what the server offered.
|
|
func TestProbeSMTPReportsSTARTTLSOfferedAndFailsTheUpgrade(t *testing.T) {
|
|
server := startFakeSMTPServer(t, []string{"STARTTLS", "PIPELINING"})
|
|
host, port := server.address()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
result, err := ProbeSMTP(ctx, probeConfig(host, port))
|
|
if err == nil {
|
|
t.Fatal("expected the refused STARTTLS to be reported rather than treated as healthy")
|
|
}
|
|
if result == nil {
|
|
t.Fatal("a failed probe must still return what it reached")
|
|
}
|
|
if !result.STARTTLSOffered {
|
|
t.Fatal("STARTTLS was advertised but not reported")
|
|
}
|
|
if result.TLSEstablished {
|
|
t.Fatal("TLS must not be reported as established when the upgrade was refused")
|
|
}
|
|
if !strings.Contains(err.Error(), "starttls") {
|
|
t.Fatalf("error %q should name the transport step that failed", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "allow-insecure") {
|
|
t.Fatalf("error %q should point at the test-server setting", err)
|
|
}
|
|
}
|
|
|
|
func TestProbeSMTPFailsWhenNothingIsListening(t *testing.T) {
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("listen error = %v", err)
|
|
}
|
|
host, port, err := net.SplitHostPort(listener.Addr().String())
|
|
if err != nil {
|
|
t.Fatalf("split error = %v", err)
|
|
}
|
|
// Close immediately so the port is almost certainly free.
|
|
_ = listener.Close()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
if _, err := ProbeSMTP(ctx, probeConfig(host, atoiOrFail(port))); err == nil {
|
|
t.Fatal("expected an unreachable server to fail the probe")
|
|
}
|
|
}
|
|
|
|
func TestProbeSMTPRequiresAHost(t *testing.T) {
|
|
if _, err := ProbeSMTP(context.Background(), ClientConfig{Provider: ProviderSMTP}); err == nil {
|
|
t.Fatal("expected a missing host to be reported instead of dialling nothing")
|
|
}
|
|
}
|
|
|
|
func contains(values []string, needle string) bool {
|
|
for _, value := range values {
|
|
if value == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|