2027 lines
54 KiB
Go
2027 lines
54 KiB
Go
//go:build linux || freebsd
|
|||
|
|
// +build linux freebsd
|
||
|
|
|
||
|
|
package transport
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"crypto/tls"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"os"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Dialer tests
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
func TestDefaultDialerConfig(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
cfg := DefaultDialerConfig()
|
||
|
|
|
||
|
|
if cfg.Timeout != 30*time.Second {
|
||
|
|
t.Errorf("expected Timeout=30s, got %v", cfg.Timeout)
|
||
|
|
}
|
||
|
|
if cfg.IPv6Timeout != 5*time.Second {
|
||
|
|
t.Errorf("expected IPv6Timeout=5s, got %v", cfg.IPv6Timeout)
|
||
|
|
}
|
||
|
|
if !cfg.IPv4Fallback {
|
||
|
|
t.Error("expected IPv4Fallback=true")
|
||
|
|
}
|
||
|
|
if cfg.Debug {
|
||
|
|
t.Error("expected Debug=false")
|
||
|
|
}
|
||
|
|
if cfg.OnConnect != nil {
|
||
|
|
t.Error("expected OnConnect=nil")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestNewDialer(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("with nil config uses defaults", func(t *testing.T) {
|
||
|
|
d := NewDialer(nil)
|
||
|
|
if d == nil {
|
||
|
|
t.Fatal("expected non-nil dialer")
|
||
|
|
}
|
||
|
|
if d.config == nil {
|
||
|
|
t.Fatal("expected non-nil config")
|
||
|
|
}
|
||
|
|
if d.config.Timeout != 30*time.Second {
|
||
|
|
t.Errorf("expected default timeout, got %v", d.config.Timeout)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("with custom config", func(t *testing.T) {
|
||
|
|
cfg := &DialerConfig{
|
||
|
|
Timeout: 10 * time.Second,
|
||
|
|
IPv6Timeout: 2 * time.Second,
|
||
|
|
IPv4Fallback: false,
|
||
|
|
Debug: true,
|
||
|
|
}
|
||
|
|
d := NewDialer(cfg)
|
||
|
|
if d == nil {
|
||
|
|
t.Fatal("expected non-nil dialer")
|
||
|
|
}
|
||
|
|
if d.config.Timeout != 10*time.Second {
|
||
|
|
t.Errorf("expected 10s, got %v", d.config.Timeout)
|
||
|
|
}
|
||
|
|
if d.config.IPv6Timeout != 2*time.Second {
|
||
|
|
t.Errorf("expected 2s, got %v", d.config.IPv6Timeout)
|
||
|
|
}
|
||
|
|
if d.config.IPv4Fallback {
|
||
|
|
t.Error("expected IPv4Fallback=false")
|
||
|
|
}
|
||
|
|
if !d.config.Debug {
|
||
|
|
t.Error("expected Debug=true")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("resolver is initialized", func(t *testing.T) {
|
||
|
|
d := NewDialer(DefaultDialerConfig())
|
||
|
|
if d.resolver == nil {
|
||
|
|
t.Fatal("expected resolver to be initialized")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestNewDialerPreservesOnConnectCallback(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
var (
|
||
|
|
mu sync.Mutex
|
||
|
|
called bool
|
||
|
|
gotIP string
|
||
|
|
gotVer int
|
||
|
|
)
|
||
|
|
|
||
|
|
cfg := &DialerConfig{
|
||
|
|
Timeout: 5 * time.Second,
|
||
|
|
IPv6Timeout: 1 * time.Second,
|
||
|
|
IPv4Fallback: true,
|
||
|
|
OnConnect: func(ip string, version int) {
|
||
|
|
mu.Lock()
|
||
|
|
called = true
|
||
|
|
gotIP = ip
|
||
|
|
gotVer = version
|
||
|
|
mu.Unlock()
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
d := NewDialer(cfg)
|
||
|
|
if d == nil {
|
||
|
|
t.Fatal("expected non-nil dialer")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify the callback is stored (actual connection is not tested here)
|
||
|
|
if d.config.OnConnect == nil {
|
||
|
|
t.Fatal("expected OnConnect callback to be preserved")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Call it manually to verify it works
|
||
|
|
d.config.OnConnect("192.0.2.1", 4)
|
||
|
|
|
||
|
|
mu.Lock()
|
||
|
|
if !called {
|
||
|
|
t.Fatal("expected OnConnect to have been called")
|
||
|
|
}
|
||
|
|
if gotIP != "192.0.2.1" {
|
||
|
|
t.Errorf("expected IP 192.0.2.1, got %s", gotIP)
|
||
|
|
}
|
||
|
|
if gotVer != 4 {
|
||
|
|
t.Errorf("expected version 4, got %d", gotVer)
|
||
|
|
}
|
||
|
|
mu.Unlock()
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestParseProxyURL(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("empty string returns nil, nil", func(t *testing.T) {
|
||
|
|
u, err := ParseProxyURL("")
|
||
|
|
if err != nil {
|
||
|
|
t.Errorf("expected nil error, got %v", err)
|
||
|
|
}
|
||
|
|
if u != nil {
|
||
|
|
t.Errorf("expected nil URL, got %v", u)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("valid HTTP proxy URL", func(t *testing.T) {
|
||
|
|
u, err := ParseProxyURL("http://proxy.example.com:8080")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if u == nil {
|
||
|
|
t.Fatal("expected non-nil URL")
|
||
|
|
}
|
||
|
|
if u.Scheme != "http" {
|
||
|
|
t.Errorf("expected scheme http, got %s", u.Scheme)
|
||
|
|
}
|
||
|
|
if u.Host != "proxy.example.com:8080" {
|
||
|
|
t.Errorf("expected host proxy.example.com:8080, got %s", u.Host)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("valid HTTPS proxy URL with auth", func(t *testing.T) {
|
||
|
|
u, err := ParseProxyURL("https://user:pass@proxy.example.com:443")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if u == nil {
|
||
|
|
t.Fatal("expected non-nil URL")
|
||
|
|
}
|
||
|
|
if u.Scheme != "https" {
|
||
|
|
t.Errorf("expected scheme https, got %s", u.Scheme)
|
||
|
|
}
|
||
|
|
if u.User == nil {
|
||
|
|
t.Fatal("expected user info")
|
||
|
|
}
|
||
|
|
password, _ := u.User.Password()
|
||
|
|
if u.User.Username() != "user" || password != "pass" {
|
||
|
|
t.Errorf("expected user:pass, got %s:%s", u.User.Username(), password)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("invalid URL returns error", func(t *testing.T) {
|
||
|
|
_, err := ParseProxyURL("://invalid")
|
||
|
|
if err == nil {
|
||
|
|
t.Error("expected error for invalid URL")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("URL without host returns error", func(t *testing.T) {
|
||
|
|
_, err := ParseProxyURL("http://")
|
||
|
|
if err == nil {
|
||
|
|
t.Error("expected error for URL without host")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("socks5 proxy URL", func(t *testing.T) {
|
||
|
|
u, err := ParseProxyURL("socks5://127.0.0.1:1080")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if u == nil {
|
||
|
|
t.Fatal("expected non-nil URL")
|
||
|
|
}
|
||
|
|
if u.Scheme != "socks5" {
|
||
|
|
t.Errorf("expected scheme socks5, got %s", u.Scheme)
|
||
|
|
}
|
||
|
|
if u.Host != "127.0.0.1:1080" {
|
||
|
|
t.Errorf("expected host 127.0.0.1:1080, got %s", u.Host)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Proxy tests
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
func TestParseProxyConfig(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("empty string returns nil, nil", func(t *testing.T) {
|
||
|
|
cfg, err := ParseProxyConfig("")
|
||
|
|
if err != nil {
|
||
|
|
t.Errorf("expected nil error, got %v", err)
|
||
|
|
}
|
||
|
|
if cfg != nil {
|
||
|
|
t.Errorf("expected nil config, got %+v", cfg)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("HTTP proxy URL", func(t *testing.T) {
|
||
|
|
cfg, err := ParseProxyConfig("http://proxy.example.com:3128")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if cfg == nil {
|
||
|
|
t.Fatal("expected non-nil config")
|
||
|
|
}
|
||
|
|
if cfg.Type != ProxyHTTP {
|
||
|
|
t.Errorf("expected ProxyHTTP, got %s", cfg.Type)
|
||
|
|
}
|
||
|
|
if cfg.URL.Host != "proxy.example.com:3128" {
|
||
|
|
t.Errorf("expected host proxy.example.com:3128, got %s", cfg.URL.Host)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("HTTPS proxy URL maps to ProxyHTTP type", func(t *testing.T) {
|
||
|
|
cfg, err := ParseProxyConfig("https://secure-proxy.example.com:443")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if cfg == nil {
|
||
|
|
t.Fatal("expected non-nil config")
|
||
|
|
}
|
||
|
|
if cfg.Type != ProxyHTTP {
|
||
|
|
t.Errorf("expected ProxyHTTP for https scheme, got %s", cfg.Type)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("SOCKS5 proxy URL", func(t *testing.T) {
|
||
|
|
cfg, err := ParseProxyConfig("socks5://127.0.0.1:1080")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if cfg == nil {
|
||
|
|
t.Fatal("expected non-nil config")
|
||
|
|
}
|
||
|
|
if cfg.Type != ProxySOCKS5 {
|
||
|
|
t.Errorf("expected ProxySOCKS5, got %s", cfg.Type)
|
||
|
|
}
|
||
|
|
if !cfg.SOCKS5LocalResolve {
|
||
|
|
t.Error("expected SOCKS5LocalResolve=true for socks5:// scheme")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("SOCKS5h proxy URL", func(t *testing.T) {
|
||
|
|
cfg, err := ParseProxyConfig("socks5h://127.0.0.1:1080")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if cfg == nil {
|
||
|
|
t.Fatal("expected non-nil config")
|
||
|
|
}
|
||
|
|
if cfg.Type != ProxySOCKS5 {
|
||
|
|
t.Errorf("expected ProxySOCKS5 for socks5h scheme, got %s", cfg.Type)
|
||
|
|
}
|
||
|
|
if cfg.SOCKS5LocalResolve {
|
||
|
|
t.Error("expected SOCKS5LocalResolve=false for socks5h:// scheme")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("unknown scheme defaults to ProxyHTTP", func(t *testing.T) {
|
||
|
|
cfg, err := ParseProxyConfig("ftp://proxy.example.com:21")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if cfg == nil {
|
||
|
|
t.Fatal("expected non-nil config")
|
||
|
|
}
|
||
|
|
if cfg.Type != ProxyHTTP {
|
||
|
|
t.Errorf("expected ProxyHTTP for unknown scheme, got %s", cfg.Type)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("with authentication", func(t *testing.T) {
|
||
|
|
cfg, err := ParseProxyConfig("http://alice:secret@proxy.example.com:8080")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if cfg == nil {
|
||
|
|
t.Fatal("expected non-nil config")
|
||
|
|
}
|
||
|
|
if cfg.Username != "alice" {
|
||
|
|
t.Errorf("expected username alice, got %s", cfg.Username)
|
||
|
|
}
|
||
|
|
if cfg.Password != "secret" {
|
||
|
|
t.Errorf("expected password secret, got %s", cfg.Password)
|
||
|
|
}
|
||
|
|
// Auth should be stripped from URL
|
||
|
|
if cfg.URL.User != nil {
|
||
|
|
t.Error("expected User to be nil in stored URL")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("with username only (no password)", func(t *testing.T) {
|
||
|
|
cfg, err := ParseProxyConfig("http://alice@proxy.example.com:8080")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if cfg == nil {
|
||
|
|
t.Fatal("expected non-nil config")
|
||
|
|
}
|
||
|
|
if cfg.Username != "alice" {
|
||
|
|
t.Errorf("expected username alice, got %s", cfg.Username)
|
||
|
|
}
|
||
|
|
if cfg.Password != "" {
|
||
|
|
t.Errorf("expected empty password, got %s", cfg.Password)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("invalid URL returns error", func(t *testing.T) {
|
||
|
|
_, err := ParseProxyConfig("://bad")
|
||
|
|
if err == nil {
|
||
|
|
t.Error("expected error for invalid URL")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGetProxyFromEnv(t *testing.T) {
|
||
|
|
// Start with a clean environment
|
||
|
|
origHTTPS := os.Getenv("HTTPS_PROXY")
|
||
|
|
origHTTP := os.Getenv("HTTP_PROXY")
|
||
|
|
origHTTPLower := os.Getenv("http_proxy")
|
||
|
|
origHTTPSSecure := os.Getenv("https_proxy")
|
||
|
|
|
||
|
|
defer func() {
|
||
|
|
os.Setenv("HTTPS_PROXY", origHTTPS)
|
||
|
|
os.Setenv("HTTP_PROXY", origHTTP)
|
||
|
|
os.Setenv("http_proxy", origHTTPLower)
|
||
|
|
os.Setenv("https_proxy", origHTTPSSecure)
|
||
|
|
}()
|
||
|
|
|
||
|
|
t.Run("no proxy env vars returns empty", func(t *testing.T) {
|
||
|
|
os.Unsetenv("HTTPS_PROXY")
|
||
|
|
os.Unsetenv("https_proxy")
|
||
|
|
os.Unsetenv("HTTP_PROXY")
|
||
|
|
os.Unsetenv("http_proxy")
|
||
|
|
|
||
|
|
result := GetProxyFromEnv()
|
||
|
|
if result != "" {
|
||
|
|
t.Errorf("expected empty string, got %s", result)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("reads HTTPS_PROXY first", func(t *testing.T) {
|
||
|
|
os.Unsetenv("HTTPS_PROXY")
|
||
|
|
os.Unsetenv("https_proxy")
|
||
|
|
os.Unsetenv("HTTP_PROXY")
|
||
|
|
os.Unsetenv("http_proxy")
|
||
|
|
|
||
|
|
os.Setenv("HTTPS_PROXY", "https://secure-proxy:443")
|
||
|
|
os.Setenv("HTTP_PROXY", "http://proxy:8080")
|
||
|
|
|
||
|
|
result := GetProxyFromEnv()
|
||
|
|
if result != "https://secure-proxy:443" {
|
||
|
|
t.Errorf("expected https://secure-proxy:443, got %s", result)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("reads HTTP_PROXY when HTTPS_PROXY is empty", func(t *testing.T) {
|
||
|
|
os.Unsetenv("HTTPS_PROXY")
|
||
|
|
os.Unsetenv("https_proxy")
|
||
|
|
os.Unsetenv("HTTP_PROXY")
|
||
|
|
os.Unsetenv("http_proxy")
|
||
|
|
|
||
|
|
os.Setenv("HTTP_PROXY", "http://proxy:8080")
|
||
|
|
|
||
|
|
result := GetProxyFromEnv()
|
||
|
|
if result != "http://proxy:8080" {
|
||
|
|
t.Errorf("expected http://proxy:8080, got %s", result)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("reads http_proxy (lowercase) when others are empty", func(t *testing.T) {
|
||
|
|
os.Unsetenv("HTTPS_PROXY")
|
||
|
|
os.Unsetenv("https_proxy")
|
||
|
|
os.Unsetenv("HTTP_PROXY")
|
||
|
|
os.Unsetenv("http_proxy")
|
||
|
|
|
||
|
|
os.Setenv("http_proxy", "http://lower-proxy:3128")
|
||
|
|
|
||
|
|
result := GetProxyFromEnv()
|
||
|
|
if result != "http://lower-proxy:3128" {
|
||
|
|
t.Errorf("expected http://lower-proxy:3128, got %s", result)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("trims whitespace from proxy value", func(t *testing.T) {
|
||
|
|
os.Unsetenv("HTTPS_PROXY")
|
||
|
|
os.Unsetenv("https_proxy")
|
||
|
|
os.Unsetenv("HTTP_PROXY")
|
||
|
|
os.Unsetenv("http_proxy")
|
||
|
|
|
||
|
|
os.Setenv("HTTP_PROXY", " http://proxy:8080 ")
|
||
|
|
|
||
|
|
result := GetProxyFromEnv()
|
||
|
|
if result != "http://proxy:8080" {
|
||
|
|
t.Errorf("expected http://proxy:8080 (trimmed), got %q", result)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("priority: HTTPS_PROXY > https_proxy > HTTP_PROXY > http_proxy", func(t *testing.T) {
|
||
|
|
os.Unsetenv("HTTPS_PROXY")
|
||
|
|
os.Unsetenv("https_proxy")
|
||
|
|
os.Unsetenv("HTTP_PROXY")
|
||
|
|
os.Unsetenv("http_proxy")
|
||
|
|
|
||
|
|
os.Setenv("https_proxy", "https://lower-secure:443")
|
||
|
|
os.Setenv("HTTP_PROXY", "http://proxy:8080")
|
||
|
|
|
||
|
|
result := GetProxyFromEnv()
|
||
|
|
if result != "https://lower-secure:443" {
|
||
|
|
t.Errorf("expected https://lower-secure:443, got %s", result)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestNewProxyTransport(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("nil config returns base transport unchanged", func(t *testing.T) {
|
||
|
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
||
|
|
result, err := NewProxyTransport(nil, base)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if result != base {
|
||
|
|
t.Error("expected same transport back")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("sets HTTP proxy", func(t *testing.T) {
|
||
|
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
||
|
|
cfg := &ProxyConfig{
|
||
|
|
URL: &url.URL{Scheme: "http", Host: "proxy.example.com:8080"},
|
||
|
|
Type: ProxyHTTP,
|
||
|
|
}
|
||
|
|
result, err := NewProxyTransport(cfg, base)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if result == nil {
|
||
|
|
t.Fatal("expected non-nil transport")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify proxy function works
|
||
|
|
req := &http.Request{
|
||
|
|
URL: &url.URL{Scheme: "http", Host: "example.com"},
|
||
|
|
}
|
||
|
|
proxyURL, err := result.Proxy(req)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("proxy function error: %v", err)
|
||
|
|
}
|
||
|
|
if proxyURL == nil {
|
||
|
|
t.Fatal("expected non-nil proxy URL")
|
||
|
|
}
|
||
|
|
if proxyURL.Host != "proxy.example.com:8080" {
|
||
|
|
t.Errorf("expected proxy host proxy.example.com:8080, got %s", proxyURL.Host)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("sets proxy with authentication", func(t *testing.T) {
|
||
|
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
||
|
|
cfg := &ProxyConfig{
|
||
|
|
URL: &url.URL{Scheme: "http", Host: "proxy.example.com:8080"},
|
||
|
|
Type: ProxyHTTP,
|
||
|
|
Username: "user",
|
||
|
|
Password: "pass",
|
||
|
|
}
|
||
|
|
result, err := NewProxyTransport(cfg, base)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if result == nil {
|
||
|
|
t.Fatal("expected non-nil transport")
|
||
|
|
}
|
||
|
|
|
||
|
|
req := &http.Request{
|
||
|
|
URL: &url.URL{Scheme: "http", Host: "example.com"},
|
||
|
|
}
|
||
|
|
proxyURL, err := result.Proxy(req)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("proxy function error: %v", err)
|
||
|
|
}
|
||
|
|
if proxyURL == nil {
|
||
|
|
t.Fatal("expected non-nil proxy URL")
|
||
|
|
}
|
||
|
|
if proxyURL.User == nil {
|
||
|
|
t.Fatal("expected user info in proxy URL")
|
||
|
|
}
|
||
|
|
password, _ := proxyURL.User.Password()
|
||
|
|
if proxyURL.User.Username() != "user" || password != "pass" {
|
||
|
|
t.Errorf("expected user:pass, got %s:%s", proxyURL.User.Username(), password)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("sets proxy with username only (no password)", func(t *testing.T) {
|
||
|
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
||
|
|
cfg := &ProxyConfig{
|
||
|
|
URL: &url.URL{Scheme: "http", Host: "proxy.example.com:8080"},
|
||
|
|
Type: ProxyHTTP,
|
||
|
|
Username: "alice",
|
||
|
|
}
|
||
|
|
result, err := NewProxyTransport(cfg, base)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if result == nil {
|
||
|
|
t.Fatal("expected non-nil transport")
|
||
|
|
}
|
||
|
|
|
||
|
|
req := &http.Request{
|
||
|
|
URL: &url.URL{Scheme: "http", Host: "example.com"},
|
||
|
|
}
|
||
|
|
proxyURL, err := result.Proxy(req)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("proxy function error: %v", err)
|
||
|
|
}
|
||
|
|
if proxyURL == nil {
|
||
|
|
t.Fatal("expected non-nil proxy URL")
|
||
|
|
}
|
||
|
|
if proxyURL.User == nil {
|
||
|
|
t.Fatal("expected user info in proxy URL")
|
||
|
|
}
|
||
|
|
if proxyURL.User.Username() != "alice" {
|
||
|
|
t.Errorf("expected username alice, got %s", proxyURL.User.Username())
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("proxy URL parse failure returns error", func(t *testing.T) {
|
||
|
|
// This is hard to trigger with normal inputs; skip edge case.
|
||
|
|
// The url.Parse function is very lenient, so this test is for completeness.
|
||
|
|
cfg := &ProxyConfig{
|
||
|
|
URL: &url.URL{Scheme: "http", Host: "proxy:8080"},
|
||
|
|
Type: ProxyHTTP,
|
||
|
|
Username: "user\x00", // null byte may cause issues
|
||
|
|
}
|
||
|
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
||
|
|
_, err := NewProxyTransport(cfg, base)
|
||
|
|
// We allow error or no error here; just make sure it doesn't panic
|
||
|
|
_ = err
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("HTTP proxy preserves TLSClientConfig", func(t *testing.T) {
|
||
|
|
// Regression guard for the BACKLOG entry "TLS config lost through
|
||
|
|
// SOCKS5 proxy transport". The original concern was that
|
||
|
|
// NewProxyTransport might construct a fresh transport without
|
||
|
|
// copying the base's TLSClientConfig, silently dropping
|
||
|
|
// InsecureSkipVerify, custom CAs, and pinned certs. Verify that
|
||
|
|
// is *not* the case for HTTP proxy too.
|
||
|
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
||
|
|
base.TLSClientConfig = &tls.Config{
|
||
|
|
InsecureSkipVerify: true, //nolint:gosec // test-only
|
||
|
|
MinVersion: tls.VersionTLS12,
|
||
|
|
}
|
||
|
|
cfg := &ProxyConfig{
|
||
|
|
URL: &url.URL{Scheme: "http", Host: "proxy.example.com:8080"},
|
||
|
|
Type: ProxyHTTP,
|
||
|
|
}
|
||
|
|
result, err := NewProxyTransport(cfg, base)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if result.TLSClientConfig == nil {
|
||
|
|
t.Fatal("TLSClientConfig was dropped from the resulting transport")
|
||
|
|
}
|
||
|
|
if !result.TLSClientConfig.InsecureSkipVerify {
|
||
|
|
t.Error("InsecureSkipVerify was dropped from TLSClientConfig")
|
||
|
|
}
|
||
|
|
if result.TLSClientConfig.MinVersion != tls.VersionTLS12 {
|
||
|
|
t.Errorf("MinVersion = %d, want %d", result.TLSClientConfig.MinVersion, tls.VersionTLS12)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestProxyTypes(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
if string(ProxyHTTP) != "http" {
|
||
|
|
t.Errorf("expected ProxyHTTP to be 'http', got %q", string(ProxyHTTP))
|
||
|
|
}
|
||
|
|
if string(ProxyHTTPS) != "https" {
|
||
|
|
t.Errorf("expected ProxyHTTPS to be 'https', got %q", string(ProxyHTTPS))
|
||
|
|
}
|
||
|
|
if string(ProxySOCKS5) != "socks5" {
|
||
|
|
t.Errorf("expected ProxySOCKS5 to be 'socks5', got %q", string(ProxySOCKS5))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDialProxy(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("nil config uses direct dialer", func(t *testing.T) {
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
// This will fail quickly because there's no server, but it should
|
||
|
|
// attempt a direct connection rather than returning an unsupported error.
|
||
|
|
_, err := DialProxy(ctx, "tcp", "127.0.0.1:1", nil)
|
||
|
|
// Should NOT get "unsupported proxy type"
|
||
|
|
if err != nil && strings.Contains(err.Error(), "unsupported proxy type") {
|
||
|
|
t.Errorf("unexpected 'unsupported proxy type' error: %v", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("unsupported network returns error", func(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
cfg := &ProxyConfig{
|
||
|
|
URL: &url.URL{Scheme: "http", Host: "127.0.0.1:8080"},
|
||
|
|
Type: ProxyHTTP,
|
||
|
|
}
|
||
|
|
_, err := DialProxy(ctx, "udp", "127.0.0.1:80", cfg)
|
||
|
|
if err == nil {
|
||
|
|
t.Error("expected error for unsupported network")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// SOCKS5 tests
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
// mockSOCKS5Server accepts a single SOCKS5 connection, performs (or skips)
|
||
|
|
// the auth negotiation, then verifies the CONNECT request and replies with
|
||
|
|
// success. The connection is then handed off to the caller via the returned
|
||
|
|
// channel.
|
||
|
|
type mockSOCKS5Result struct {
|
||
|
|
host string
|
||
|
|
port uint16
|
||
|
|
addrType byte
|
||
|
|
gotUsername string
|
||
|
|
gotPassword string
|
||
|
|
requestedAuth byte
|
||
|
|
conn net.Conn
|
||
|
|
}
|
||
|
|
|
||
|
|
func startMockSOCKS5(t *testing.T, requireAuth bool) (addr string, results <-chan mockSOCKS5Result, stop func()) {
|
||
|
|
t.Helper()
|
||
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("listen: %v", err)
|
||
|
|
}
|
||
|
|
out := make(chan mockSOCKS5Result, 1)
|
||
|
|
done := make(chan struct{})
|
||
|
|
go func() {
|
||
|
|
defer close(done)
|
||
|
|
conn, err := ln.Accept()
|
||
|
|
if err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer conn.Close()
|
||
|
|
res := mockSOCKS5Result{conn: conn}
|
||
|
|
|
||
|
|
// Greeting: read VER + NMETHODS, then NMETHODS method bytes.
|
||
|
|
// The client offers at least no-auth (0x00), and additionally
|
||
|
|
// user/pass (0x02) when a username is configured.
|
||
|
|
greet := make([]byte, 2)
|
||
|
|
if _, err := io.ReadFull(conn, greet); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if greet[0] != 0x05 || int(greet[1]) > 4 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
methods := make([]byte, greet[1])
|
||
|
|
if _, err := io.ReadFull(conn, methods); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Pick the strongest method we support. requireAuth means we
|
||
|
|
// require user/pass; otherwise we accept no-auth if offered.
|
||
|
|
offered := make(map[byte]bool, len(methods))
|
||
|
|
for _, m := range methods {
|
||
|
|
offered[m] = true
|
||
|
|
}
|
||
|
|
if requireAuth {
|
||
|
|
if !offered[0x02] {
|
||
|
|
conn.Write([]byte{0x05, 0xFF}) // no acceptable method
|
||
|
|
return
|
||
|
|
}
|
||
|
|
conn.Write([]byte{0x05, 0x02})
|
||
|
|
res.requestedAuth = 0x02
|
||
|
|
} else {
|
||
|
|
if !offered[0x00] {
|
||
|
|
conn.Write([]byte{0x05, 0xFF})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
conn.Write([]byte{0x05, 0x00})
|
||
|
|
res.requestedAuth = 0x00
|
||
|
|
}
|
||
|
|
|
||
|
|
// User/pass sub-negotiation (only when 0x02 was selected).
|
||
|
|
if res.requestedAuth == 0x02 {
|
||
|
|
ver := make([]byte, 1)
|
||
|
|
if _, err := io.ReadFull(conn, ver); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if ver[0] != 0x01 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
ulen := make([]byte, 1)
|
||
|
|
if _, err := io.ReadFull(conn, ulen); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
uname := make([]byte, ulen[0])
|
||
|
|
if _, err := io.ReadFull(conn, uname); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
plen := make([]byte, 1)
|
||
|
|
if _, err := io.ReadFull(conn, plen); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
pass := make([]byte, plen[0])
|
||
|
|
if _, err := io.ReadFull(conn, pass); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
res.gotUsername = string(uname)
|
||
|
|
res.gotPassword = string(pass)
|
||
|
|
conn.Write([]byte{0x01, 0x00})
|
||
|
|
}
|
||
|
|
|
||
|
|
// CONNECT request: VER, CMD, RSV, ATYP
|
||
|
|
hdr := make([]byte, 4)
|
||
|
|
if _, err := io.ReadFull(conn, hdr); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if hdr[0] != 0x05 || hdr[1] != 0x01 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
res.addrType = hdr[3]
|
||
|
|
switch hdr[3] {
|
||
|
|
case 0x01:
|
||
|
|
ip := make([]byte, 4)
|
||
|
|
if _, err := io.ReadFull(conn, ip); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
res.host = net.IP(ip).String()
|
||
|
|
case 0x04:
|
||
|
|
ip := make([]byte, 16)
|
||
|
|
if _, err := io.ReadFull(conn, ip); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
res.host = net.IP(ip).String()
|
||
|
|
case 0x03:
|
||
|
|
l := make([]byte, 1)
|
||
|
|
if _, err := io.ReadFull(conn, l); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
name := make([]byte, l[0])
|
||
|
|
if _, err := io.ReadFull(conn, name); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
res.host = string(name)
|
||
|
|
}
|
||
|
|
portBuf := make([]byte, 2)
|
||
|
|
if _, err := io.ReadFull(conn, portBuf); err != nil {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
res.port = uint16(portBuf[0])<<8 | uint16(portBuf[1])
|
||
|
|
|
||
|
|
// Reply success with 0.0.0.0:0 bind address.
|
||
|
|
conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||
|
|
out <- res
|
||
|
|
// Keep the connection open until the test closes it.
|
||
|
|
// Block here so the conn is not garbage-collected.
|
||
|
|
buf := make([]byte, 1)
|
||
|
|
conn.Read(buf)
|
||
|
|
}()
|
||
|
|
stop = func() {
|
||
|
|
ln.Close()
|
||
|
|
<-done
|
||
|
|
}
|
||
|
|
return ln.Addr().String(), out, stop
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestNewProxyTransport_SOCKS5(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("SOCKS5 sets DialContext and clears Proxy field", func(t *testing.T) {
|
||
|
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
||
|
|
cfg := &ProxyConfig{
|
||
|
|
URL: &url.URL{Scheme: "socks5", Host: "127.0.0.1:1080"},
|
||
|
|
Type: ProxySOCKS5,
|
||
|
|
}
|
||
|
|
result, err := NewProxyTransport(cfg, base)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if result == nil {
|
||
|
|
t.Fatal("expected non-nil transport")
|
||
|
|
}
|
||
|
|
if result.Proxy != nil {
|
||
|
|
t.Errorf("expected Proxy=nil for SOCKS5 (handled via DialContext), got %T", result.Proxy)
|
||
|
|
}
|
||
|
|
if result.DialContext == nil {
|
||
|
|
t.Error("expected DialContext to be set for SOCKS5")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("SOCKS5 with auth", func(t *testing.T) {
|
||
|
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
||
|
|
cfg := &ProxyConfig{
|
||
|
|
URL: &url.URL{Scheme: "socks5", Host: "127.0.0.1:1080"},
|
||
|
|
Type: ProxySOCKS5,
|
||
|
|
Username: "alice",
|
||
|
|
Password: "secret",
|
||
|
|
}
|
||
|
|
result, err := NewProxyTransport(cfg, base)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if result.DialContext == nil {
|
||
|
|
t.Error("expected DialContext to be set for SOCKS5 with auth")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("SOCKS5 proxy preserves TLSClientConfig", func(t *testing.T) {
|
||
|
|
// Regression guard for the BACKLOG entry "TLS config lost through
|
||
|
|
// SOCKS5 proxy transport". After Clone(), the new transport
|
||
|
|
// must still carry the caller's TLSClientConfig (with
|
||
|
|
// InsecureSkipVerify, custom CAs, pinned certs, etc.).
|
||
|
|
base := http.DefaultTransport.(*http.Transport).Clone()
|
||
|
|
base.TLSClientConfig = &tls.Config{
|
||
|
|
InsecureSkipVerify: true, //nolint:gosec // test-only
|
||
|
|
MinVersion: tls.VersionTLS12,
|
||
|
|
}
|
||
|
|
cfg := &ProxyConfig{
|
||
|
|
URL: &url.URL{Scheme: "socks5", Host: "127.0.0.1:1080"},
|
||
|
|
Type: ProxySOCKS5,
|
||
|
|
}
|
||
|
|
result, err := NewProxyTransport(cfg, base)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if result.TLSClientConfig == nil {
|
||
|
|
t.Fatal("TLSClientConfig was dropped from the SOCKS5 transport")
|
||
|
|
}
|
||
|
|
if !result.TLSClientConfig.InsecureSkipVerify {
|
||
|
|
t.Error("InsecureSkipVerify was dropped from TLSClientConfig")
|
||
|
|
}
|
||
|
|
if result.TLSClientConfig.MinVersion != tls.VersionTLS12 {
|
||
|
|
t.Errorf("MinVersion = %d, want %d", result.TLSClientConfig.MinVersion, tls.VersionTLS12)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDialProxy_SOCKS5NoAuth(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
addr, results, stop := startMockSOCKS5(t, false)
|
||
|
|
defer stop()
|
||
|
|
|
||
|
|
host, port, err := net.SplitHostPort(addr)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("split addr: %v", err)
|
||
|
|
}
|
||
|
|
cfg := &ProxyConfig{
|
||
|
|
URL: &url.URL{Scheme: "socks5", Host: addr},
|
||
|
|
Type: ProxySOCKS5,
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
conn, err := DialProxy(ctx, "tcp", net.JoinHostPort("192.0.2.1", "8080"), cfg)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("DialProxy failed: %v", err)
|
||
|
|
}
|
||
|
|
defer conn.Close()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case res := <-results:
|
||
|
|
if res.addrType != 0x01 {
|
||
|
|
t.Errorf("expected IPv4 ATYP=0x01, got 0x%02x", res.addrType)
|
||
|
|
}
|
||
|
|
if res.host != "192.0.2.1" {
|
||
|
|
t.Errorf("expected host 192.0.2.1, got %s", res.host)
|
||
|
|
}
|
||
|
|
if res.port != 8080 {
|
||
|
|
t.Errorf("expected port 8080, got %d", res.port)
|
||
|
|
}
|
||
|
|
if res.requestedAuth != 0x00 {
|
||
|
|
t.Errorf("expected no-auth selection, got 0x%02x", res.requestedAuth)
|
||
|
|
}
|
||
|
|
_ = host
|
||
|
|
_ = port
|
||
|
|
case <-time.After(2 * time.Second):
|
||
|
|
t.Fatal("timed out waiting for SOCKS5 server to receive request")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDialProxy_SOCKS5WithAuth(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
addr, results, stop := startMockSOCKS5(t, true)
|
||
|
|
defer stop()
|
||
|
|
|
||
|
|
cfg := &ProxyConfig{
|
||
|
|
URL: &url.URL{Scheme: "socks5", Host: addr},
|
||
|
|
Type: ProxySOCKS5,
|
||
|
|
Username: "alice",
|
||
|
|
Password: "s3cret",
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
conn, err := DialProxy(ctx, "tcp", "192.0.2.1:8443", cfg)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("DialProxy failed: %v", err)
|
||
|
|
}
|
||
|
|
defer conn.Close()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case res := <-results:
|
||
|
|
if res.requestedAuth != 0x02 {
|
||
|
|
t.Errorf("expected user/pass auth selection, got 0x%02x", res.requestedAuth)
|
||
|
|
}
|
||
|
|
if res.gotUsername != "alice" {
|
||
|
|
t.Errorf("expected username alice, got %s", res.gotUsername)
|
||
|
|
}
|
||
|
|
if res.gotPassword != "s3cret" {
|
||
|
|
t.Errorf("expected password s3cret, got %s", res.gotPassword)
|
||
|
|
}
|
||
|
|
if res.port != 8443 {
|
||
|
|
t.Errorf("expected port 8443, got %d", res.port)
|
||
|
|
}
|
||
|
|
case <-time.After(2 * time.Second):
|
||
|
|
t.Fatal("timed out waiting for SOCKS5 server to receive request")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDialProxy_SOCKS5HostnameRemoteResolve(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
addr, results, stop := startMockSOCKS5(t, false)
|
||
|
|
defer stop()
|
||
|
|
|
||
|
|
cfg := &ProxyConfig{
|
||
|
|
URL: &url.URL{Scheme: "socks5h", Host: addr},
|
||
|
|
Type: ProxySOCKS5,
|
||
|
|
SOCKS5LocalResolve: false,
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
conn, err := DialProxy(ctx, "tcp", "example.com:443", cfg)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("DialProxy failed: %v", err)
|
||
|
|
}
|
||
|
|
defer conn.Close()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case res := <-results:
|
||
|
|
if res.addrType != 0x03 {
|
||
|
|
t.Errorf("expected domain ATYP=0x03, got 0x%02x", res.addrType)
|
||
|
|
}
|
||
|
|
if res.host != "example.com" {
|
||
|
|
t.Errorf("expected host example.com, got %s", res.host)
|
||
|
|
}
|
||
|
|
if res.port != 443 {
|
||
|
|
t.Errorf("expected port 443, got %d", res.port)
|
||
|
|
}
|
||
|
|
case <-time.After(2 * time.Second):
|
||
|
|
t.Fatal("timed out waiting for SOCKS5 server to receive request")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDialProxy_SOCKS5LocalResolve(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
addr, results, stop := startMockSOCKS5(t, false)
|
||
|
|
defer stop()
|
||
|
|
|
||
|
|
cfg := &ProxyConfig{
|
||
|
|
URL: &url.URL{Scheme: "socks5", Host: addr},
|
||
|
|
Type: ProxySOCKS5,
|
||
|
|
SOCKS5LocalResolve: true,
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
// Use a hostname that always resolves to a real IP. localhost works.
|
||
|
|
conn, err := DialProxy(ctx, "tcp", "localhost:8080", cfg)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("DialProxy failed: %v", err)
|
||
|
|
}
|
||
|
|
defer conn.Close()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case res := <-results:
|
||
|
|
// The hostname is resolved locally, so the SOCKS5 server must
|
||
|
|
// receive an IP literal — never a bare hostname like "localhost".
|
||
|
|
// The exact address family depends on the host resolver: most
|
||
|
|
// systems return 127.0.0.1, but IPv6-preferring environments
|
||
|
|
// (some CI runners) return ::1 instead. Accept either as long
|
||
|
|
// as it is a loopback IP and the ATYP field matches.
|
||
|
|
if res.host == "localhost" {
|
||
|
|
t.Fatal("expected hostname to be resolved to IP literal, got hostname")
|
||
|
|
}
|
||
|
|
ip := net.ParseIP(res.host)
|
||
|
|
if ip == nil {
|
||
|
|
t.Fatalf("expected IP literal after local resolve, got %q", res.host)
|
||
|
|
}
|
||
|
|
if !ip.IsLoopback() {
|
||
|
|
t.Errorf("expected loopback IP, got %s", res.host)
|
||
|
|
}
|
||
|
|
|
||
|
|
wantATYP := byte(0x01) // IPv4
|
||
|
|
if ip.To4() == nil {
|
||
|
|
wantATYP = 0x04 // IPv6
|
||
|
|
}
|
||
|
|
if res.addrType != wantATYP {
|
||
|
|
t.Errorf("ATYP = 0x%02x, want 0x%02x for host %s", res.addrType, wantATYP, res.host)
|
||
|
|
}
|
||
|
|
case <-time.After(2 * time.Second):
|
||
|
|
t.Fatal("timed out waiting for SOCKS5 server to receive request")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Retry tests
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
func TestDefaultRetryConfig(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
cfg := DefaultRetryConfig()
|
||
|
|
|
||
|
|
if cfg.MaxRetries != 3 {
|
||
|
|
t.Errorf("expected MaxRetries=3, got %d", cfg.MaxRetries)
|
||
|
|
}
|
||
|
|
if cfg.InitialDelay != 1*time.Second {
|
||
|
|
t.Errorf("expected InitialDelay=1s, got %v", cfg.InitialDelay)
|
||
|
|
}
|
||
|
|
if cfg.MaxDelay != 30*time.Second {
|
||
|
|
t.Errorf("expected MaxDelay=30s, got %v", cfg.MaxDelay)
|
||
|
|
}
|
||
|
|
if cfg.Multiplier != 2.0 {
|
||
|
|
t.Errorf("expected Multiplier=2.0, got %f", cfg.Multiplier)
|
||
|
|
}
|
||
|
|
if len(cfg.RetryableStatusCodes) != 6 {
|
||
|
|
t.Errorf("expected 6 retryable status codes, got %d", len(cfg.RetryableStatusCodes))
|
||
|
|
}
|
||
|
|
if cfg.OnRetry != nil {
|
||
|
|
t.Error("expected OnRetry=nil")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify specific status codes
|
||
|
|
expectedCodes := []int{408, 429, 500, 502, 503, 504}
|
||
|
|
for i, code := range expectedCodes {
|
||
|
|
if cfg.RetryableStatusCodes[i] != code {
|
||
|
|
t.Errorf("expected status code %d at index %d, got %d", code, i, cfg.RetryableStatusCodes[i])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestRetrySuccess(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("succeeds on first attempt", func(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
attempts := 0
|
||
|
|
err := Retry(ctx, func(ctx context.Context, attempt int) error {
|
||
|
|
attempts++
|
||
|
|
return nil
|
||
|
|
}, &RetryConfig{
|
||
|
|
MaxRetries: 3,
|
||
|
|
InitialDelay: 10 * time.Millisecond,
|
||
|
|
MaxDelay: 100 * time.Millisecond,
|
||
|
|
Multiplier: 2.0,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Errorf("expected no error, got %v", err)
|
||
|
|
}
|
||
|
|
if attempts != 1 {
|
||
|
|
t.Errorf("expected 1 attempt, got %d", attempts)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("succeeds after retries", func(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
var attempts int
|
||
|
|
err := Retry(ctx, func(ctx context.Context, attempt int) error {
|
||
|
|
attempts++
|
||
|
|
if attempt < 3 {
|
||
|
|
return fmt.Errorf("attempt %d failed", attempt)
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}, &RetryConfig{
|
||
|
|
MaxRetries: 3,
|
||
|
|
InitialDelay: 5 * time.Millisecond,
|
||
|
|
MaxDelay: 50 * time.Millisecond,
|
||
|
|
Multiplier: 1.5,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Errorf("expected no error, got %v", err)
|
||
|
|
}
|
||
|
|
if attempts != 3 {
|
||
|
|
t.Errorf("expected 3 attempts, got %d", attempts)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("uses default config when nil", func(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
err := Retry(ctx, func(ctx context.Context, attempt int) error {
|
||
|
|
return nil
|
||
|
|
}, nil)
|
||
|
|
if err != nil {
|
||
|
|
t.Errorf("expected no error, got %v", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestRetryFailAll(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
ctx := context.Background()
|
||
|
|
var attempts int
|
||
|
|
expectedFailures := 4 // maxRetries(3) + initial(1) = 4
|
||
|
|
|
||
|
|
err := Retry(ctx, func(ctx context.Context, attempt int) error {
|
||
|
|
attempts++
|
||
|
|
return fmt.Errorf("persistent error on attempt %d", attempt)
|
||
|
|
}, &RetryConfig{
|
||
|
|
MaxRetries: 3,
|
||
|
|
InitialDelay: 5 * time.Millisecond,
|
||
|
|
MaxDelay: 50 * time.Millisecond,
|
||
|
|
Multiplier: 1.5,
|
||
|
|
})
|
||
|
|
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("expected an error")
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "all 4 attempts failed") {
|
||
|
|
t.Errorf("expected 'all 4 attempts failed' in error, got %q", err.Error())
|
||
|
|
}
|
||
|
|
if attempts != expectedFailures {
|
||
|
|
t.Errorf("expected %d attempts, got %d", expectedFailures, attempts)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestRetryWithContextCancel(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("cancel before retry begins", func(t *testing.T) {
|
||
|
|
ctx, cancel := context.WithCancel(context.Background())
|
||
|
|
cancel() // cancel immediately
|
||
|
|
|
||
|
|
err := Retry(ctx, func(ctx context.Context, attempt int) error {
|
||
|
|
return fmt.Errorf("should not be called")
|
||
|
|
}, &RetryConfig{
|
||
|
|
MaxRetries: 3,
|
||
|
|
InitialDelay: 5 * time.Millisecond,
|
||
|
|
MaxDelay: 50 * time.Millisecond,
|
||
|
|
Multiplier: 1.5,
|
||
|
|
})
|
||
|
|
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("expected context canceled error")
|
||
|
|
}
|
||
|
|
if !errors.Is(err, context.Canceled) {
|
||
|
|
t.Errorf("expected context.Canceled, got %v", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("cancel during retry delay", func(t *testing.T) {
|
||
|
|
ctx, cancel := context.WithCancel(context.Background())
|
||
|
|
|
||
|
|
var attempts int
|
||
|
|
errCh := make(chan error, 1)
|
||
|
|
|
||
|
|
go func() {
|
||
|
|
errCh <- Retry(ctx, func(ctx context.Context, attempt int) error {
|
||
|
|
attempts++
|
||
|
|
// Cancel on second attempt
|
||
|
|
if attempt == 2 {
|
||
|
|
cancel()
|
||
|
|
}
|
||
|
|
return fmt.Errorf("failing attempt %d", attempt)
|
||
|
|
}, &RetryConfig{
|
||
|
|
MaxRetries: 5,
|
||
|
|
InitialDelay: 1 * time.Second, // long enough for cancel to take effect
|
||
|
|
MaxDelay: 2 * time.Second,
|
||
|
|
Multiplier: 1.0,
|
||
|
|
})
|
||
|
|
}()
|
||
|
|
|
||
|
|
select {
|
||
|
|
case err := <-errCh:
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("expected an error")
|
||
|
|
}
|
||
|
|
if !errors.Is(err, context.Canceled) {
|
||
|
|
t.Errorf("expected context.Canceled, got %v", err)
|
||
|
|
}
|
||
|
|
case <-time.After(3 * time.Second):
|
||
|
|
t.Fatal("test timed out")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestIsRetryableError(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("nil error is not retryable", func(t *testing.T) {
|
||
|
|
if IsRetryableError(nil) {
|
||
|
|
t.Error("expected false for nil error")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("connection refused", func(t *testing.T) {
|
||
|
|
if !IsRetryableError(fmt.Errorf("connection refused")) {
|
||
|
|
t.Error("expected true for 'connection refused'")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("connection reset", func(t *testing.T) {
|
||
|
|
if !IsRetryableError(fmt.Errorf("connection reset by peer")) {
|
||
|
|
t.Error("expected true for 'connection reset'")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("connection timed out", func(t *testing.T) {
|
||
|
|
if !IsRetryableError(fmt.Errorf("connection timed out")) {
|
||
|
|
t.Error("expected true for 'connection timed out'")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("i/o timeout", func(t *testing.T) {
|
||
|
|
if !IsRetryableError(fmt.Errorf("i/o timeout")) {
|
||
|
|
t.Error("expected true for 'i/o timeout'")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("temporary failure", func(t *testing.T) {
|
||
|
|
if !IsRetryableError(fmt.Errorf("temporary failure in name resolution")) {
|
||
|
|
t.Error("expected true for 'temporary failure'")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("no route to host", func(t *testing.T) {
|
||
|
|
if !IsRetryableError(fmt.Errorf("no route to host")) {
|
||
|
|
t.Error("expected true for 'no route to host'")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("case insensitive matching", func(t *testing.T) {
|
||
|
|
if !IsRetryableError(fmt.Errorf("connection refused")) {
|
||
|
|
t.Error("expected true for 'connection refused' (case insensitive)")
|
||
|
|
}
|
||
|
|
if !IsRetryableError(fmt.Errorf("connection timed out")) {
|
||
|
|
t.Error("expected true for 'connection timed out' (case insensitive)")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("non-retryable error", func(t *testing.T) {
|
||
|
|
if IsRetryableError(fmt.Errorf("404 not found")) {
|
||
|
|
t.Error("expected false for non-retryable error")
|
||
|
|
}
|
||
|
|
if IsRetryableError(fmt.Errorf("permission denied")) {
|
||
|
|
t.Error("expected false for permission denied")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("retryable substring in longer message", func(t *testing.T) {
|
||
|
|
if !IsRetryableError(fmt.Errorf("dial tcp 192.168.1.1:80: connect: connection refused")) {
|
||
|
|
t.Error("expected true for longer message containing 'connection refused'")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestIsRetryableStatusCode(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("uses default status codes with nil config", func(t *testing.T) {
|
||
|
|
if !IsRetryableStatusCode(408, nil) {
|
||
|
|
t.Error("expected 408 to be retryable with nil config")
|
||
|
|
}
|
||
|
|
if !IsRetryableStatusCode(429, nil) {
|
||
|
|
t.Error("expected 429 to be retryable with nil config")
|
||
|
|
}
|
||
|
|
if !IsRetryableStatusCode(500, nil) {
|
||
|
|
t.Error("expected 500 to be retryable with nil config")
|
||
|
|
}
|
||
|
|
if !IsRetryableStatusCode(502, nil) {
|
||
|
|
t.Error("expected 502 to be retryable with nil config")
|
||
|
|
}
|
||
|
|
if !IsRetryableStatusCode(503, nil) {
|
||
|
|
t.Error("expected 503 to be retryable with nil config")
|
||
|
|
}
|
||
|
|
if !IsRetryableStatusCode(504, nil) {
|
||
|
|
t.Error("expected 504 to be retryable with nil config")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("non-retryable status codes", func(t *testing.T) {
|
||
|
|
if IsRetryableStatusCode(200, nil) {
|
||
|
|
t.Error("expected 200 not to be retryable")
|
||
|
|
}
|
||
|
|
if IsRetryableStatusCode(301, nil) {
|
||
|
|
t.Error("expected 301 not to be retryable")
|
||
|
|
}
|
||
|
|
if IsRetryableStatusCode(400, nil) {
|
||
|
|
t.Error("expected 400 not to be retryable")
|
||
|
|
}
|
||
|
|
if IsRetryableStatusCode(401, nil) {
|
||
|
|
t.Error("expected 401 not to be retryable")
|
||
|
|
}
|
||
|
|
if IsRetryableStatusCode(403, nil) {
|
||
|
|
t.Error("expected 403 not to be retryable")
|
||
|
|
}
|
||
|
|
if IsRetryableStatusCode(404, nil) {
|
||
|
|
t.Error("expected 404 not to be retryable")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("custom retryable codes", func(t *testing.T) {
|
||
|
|
cfg := &RetryConfig{
|
||
|
|
RetryableStatusCodes: []int{429, 503},
|
||
|
|
}
|
||
|
|
if !IsRetryableStatusCode(429, cfg) {
|
||
|
|
t.Error("expected 429 to be retryable with custom config")
|
||
|
|
}
|
||
|
|
if !IsRetryableStatusCode(503, cfg) {
|
||
|
|
t.Error("expected 503 to be retryable with custom config")
|
||
|
|
}
|
||
|
|
if IsRetryableStatusCode(500, cfg) {
|
||
|
|
t.Error("expected 500 not to be retryable with custom config")
|
||
|
|
}
|
||
|
|
if IsRetryableStatusCode(502, cfg) {
|
||
|
|
t.Error("expected 502 not to be retryable with custom config")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("empty retryable codes list", func(t *testing.T) {
|
||
|
|
cfg := &RetryConfig{
|
||
|
|
RetryableStatusCodes: []int{},
|
||
|
|
}
|
||
|
|
if IsRetryableStatusCode(503, cfg) {
|
||
|
|
t.Error("expected 503 not to be retryable with empty codes list")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestOnRetryCallback(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
var (
|
||
|
|
mu sync.Mutex
|
||
|
|
callbackCount int
|
||
|
|
lastAttempt int
|
||
|
|
lastDelay time.Duration
|
||
|
|
)
|
||
|
|
|
||
|
|
cfg := &RetryConfig{
|
||
|
|
MaxRetries: 2,
|
||
|
|
InitialDelay: 5 * time.Millisecond,
|
||
|
|
MaxDelay: 50 * time.Millisecond,
|
||
|
|
Multiplier: 1.0,
|
||
|
|
OnRetry: func(attempt int, err error, delay time.Duration) {
|
||
|
|
mu.Lock()
|
||
|
|
callbackCount++
|
||
|
|
lastAttempt = attempt
|
||
|
|
lastDelay = delay
|
||
|
|
mu.Unlock()
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx := context.Background()
|
||
|
|
err := Retry(ctx, func(ctx context.Context, attempt int) error {
|
||
|
|
return fmt.Errorf("failing attempt %d", attempt)
|
||
|
|
}, cfg)
|
||
|
|
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("expected an error")
|
||
|
|
}
|
||
|
|
|
||
|
|
mu.Lock()
|
||
|
|
if callbackCount != 2 {
|
||
|
|
t.Errorf("expected 2 OnRetry callbacks, got %d", callbackCount)
|
||
|
|
}
|
||
|
|
// Last callback should be for attempt 2 (the retry before final attempt)
|
||
|
|
if lastAttempt != 2 {
|
||
|
|
t.Errorf("expected last callback attempt=2, got %d", lastAttempt)
|
||
|
|
}
|
||
|
|
if lastDelay != 5*time.Millisecond {
|
||
|
|
t.Errorf("expected last delay=5ms, got %v", lastDelay)
|
||
|
|
}
|
||
|
|
mu.Unlock()
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// TLS tests
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
func TestDefaultTLSConfig(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
cfg := DefaultTLSConfig()
|
||
|
|
|
||
|
|
if cfg.MinVersion != tls.VersionTLS12 {
|
||
|
|
t.Errorf("expected MinVersion=TLS 1.2, got 0x%04X", cfg.MinVersion)
|
||
|
|
}
|
||
|
|
if cfg.MaxVersion != tls.VersionTLS13 {
|
||
|
|
t.Errorf("expected MaxVersion=TLS 1.3, got 0x%04X", cfg.MaxVersion)
|
||
|
|
}
|
||
|
|
if cfg.InsecureSkipVerify {
|
||
|
|
t.Error("expected InsecureSkipVerify=false")
|
||
|
|
}
|
||
|
|
if cfg.CACertFile != "" {
|
||
|
|
t.Errorf("expected empty CACertFile, got %s", cfg.CACertFile)
|
||
|
|
}
|
||
|
|
if cfg.PinnedCertHash != "" {
|
||
|
|
t.Errorf("expected empty PinnedCertHash, got %s", cfg.PinnedCertHash)
|
||
|
|
}
|
||
|
|
if cfg.TLSServerNameOverride != "" {
|
||
|
|
t.Errorf("expected empty TLSServerNameOverride, got %s", cfg.TLSServerNameOverride)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestToTLSConfig(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("basic conversion", func(t *testing.T) {
|
||
|
|
cfg := &TLSConfig{
|
||
|
|
MinVersion: tls.VersionTLS12,
|
||
|
|
MaxVersion: tls.VersionTLS13,
|
||
|
|
}
|
||
|
|
tlsCfg, err := cfg.ToTLSConfig()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if tlsCfg == nil {
|
||
|
|
t.Fatal("expected non-nil tls.Config")
|
||
|
|
}
|
||
|
|
if tlsCfg.MinVersion != tls.VersionTLS12 {
|
||
|
|
t.Errorf("expected MinVersion=TLS 1.2, got 0x%04X", tlsCfg.MinVersion)
|
||
|
|
}
|
||
|
|
if tlsCfg.MaxVersion != tls.VersionTLS13 {
|
||
|
|
t.Errorf("expected MaxVersion=TLS 1.3, got 0x%04X", tlsCfg.MaxVersion)
|
||
|
|
}
|
||
|
|
if tlsCfg.InsecureSkipVerify {
|
||
|
|
t.Error("expected InsecureSkipVerify=false")
|
||
|
|
}
|
||
|
|
if tlsCfg.ServerName != "" {
|
||
|
|
t.Errorf("expected empty ServerName, got %s", tlsCfg.ServerName)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("with InsecureSkipVerify set", func(t *testing.T) {
|
||
|
|
cfg := &TLSConfig{
|
||
|
|
InsecureSkipVerify: true,
|
||
|
|
}
|
||
|
|
tlsCfg, err := cfg.ToTLSConfig()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if !tlsCfg.InsecureSkipVerify {
|
||
|
|
t.Error("expected InsecureSkipVerify=true")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("with TLSServerNameOverride set", func(t *testing.T) {
|
||
|
|
cfg := &TLSConfig{
|
||
|
|
TLSServerNameOverride: "example.com",
|
||
|
|
}
|
||
|
|
tlsCfg, err := cfg.ToTLSConfig()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if tlsCfg.ServerName != "example.com" {
|
||
|
|
t.Errorf("expected ServerName=example.com, got %s", tlsCfg.ServerName)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("with empty TLSServerNameOverride leaves ServerName empty", func(t *testing.T) {
|
||
|
|
// Sanity check: an empty override must not be propagated into the
|
||
|
|
// tls.Config, otherwise Go's stdlib would treat it as an explicit
|
||
|
|
// (empty) SNI value and certificate verification would fail.
|
||
|
|
cfg := &TLSConfig{}
|
||
|
|
tlsCfg, err := cfg.ToTLSConfig()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if tlsCfg.ServerName != "" {
|
||
|
|
t.Errorf("expected empty ServerName, got %s", tlsCfg.ServerName)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("with custom TLS versions", func(t *testing.T) {
|
||
|
|
cfg := &TLSConfig{
|
||
|
|
MinVersion: tls.VersionTLS10,
|
||
|
|
MaxVersion: tls.VersionTLS12,
|
||
|
|
}
|
||
|
|
tlsCfg, err := cfg.ToTLSConfig()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if tlsCfg.MinVersion != tls.VersionTLS10 {
|
||
|
|
t.Errorf("expected MinVersion=TLS 1.0, got 0x%04X", tlsCfg.MinVersion)
|
||
|
|
}
|
||
|
|
if tlsCfg.MaxVersion != tls.VersionTLS12 {
|
||
|
|
t.Errorf("expected MaxVersion=TLS 1.2, got 0x%04X", tlsCfg.MaxVersion)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("non-existent CA cert file returns error", func(t *testing.T) {
|
||
|
|
cfg := &TLSConfig{
|
||
|
|
CACertFile: "/nonexistent/path/ca.pem",
|
||
|
|
}
|
||
|
|
_, err := cfg.ToTLSConfig()
|
||
|
|
if err == nil {
|
||
|
|
t.Error("expected error for non-existent CA cert file")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("with PinnedCertHash returns config without error", func(t *testing.T) {
|
||
|
|
// PinnedCertHash is not yet implemented, but it should not cause an error
|
||
|
|
cfg := &TLSConfig{
|
||
|
|
PinnedCertHash: "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||
|
|
}
|
||
|
|
tlsCfg, err := cfg.ToTLSConfig()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if tlsCfg == nil {
|
||
|
|
t.Fatal("expected non-nil tls.Config")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("default TLSConfig via ToTLSConfig", func(t *testing.T) {
|
||
|
|
cfg := DefaultTLSConfig()
|
||
|
|
tlsCfg, err := cfg.ToTLSConfig()
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if tlsCfg.MinVersion != tls.VersionTLS12 {
|
||
|
|
t.Errorf("expected MinVersion=TLS 1.2, got 0x%04X", tlsCfg.MinVersion)
|
||
|
|
}
|
||
|
|
if tlsCfg.MaxVersion != tls.VersionTLS13 {
|
||
|
|
t.Errorf("expected MaxVersion=TLS 1.3, got 0x%04X", tlsCfg.MaxVersion)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestGetTLSVersionName(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
tests := []struct {
|
||
|
|
version uint16
|
||
|
|
want string
|
||
|
|
}{
|
||
|
|
{tls.VersionTLS10, "TLS 1.0"},
|
||
|
|
{tls.VersionTLS11, "TLS 1.1"},
|
||
|
|
{tls.VersionTLS12, "TLS 1.2"},
|
||
|
|
{tls.VersionTLS13, "TLS 1.3"},
|
||
|
|
{0x0000, "TLS 0x0000"},
|
||
|
|
{0x0300, "TLS 0x0300"},
|
||
|
|
{0xFFFF, "TLS 0xFFFF"},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, tt := range tests {
|
||
|
|
t.Run(fmt.Sprintf("version_0x%04X", tt.version), func(t *testing.T) {
|
||
|
|
got := GetTLSVersionName(tt.version)
|
||
|
|
if got != tt.want {
|
||
|
|
t.Errorf("expected %q for version 0x%04X, got %q", tt.want, tt.version, got)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Token bucket tests
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
func TestNewTokenBucket(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("valid parameters", func(t *testing.T) {
|
||
|
|
tb := NewTokenBucket(1024, 2048)
|
||
|
|
if tb == nil {
|
||
|
|
t.Fatal("expected non-nil token bucket")
|
||
|
|
}
|
||
|
|
// Check initial state via Allow and subsequent behavior
|
||
|
|
if tb.capacity != 2048 {
|
||
|
|
t.Errorf("expected capacity=2048, got %d", tb.capacity)
|
||
|
|
}
|
||
|
|
if tb.tokens != float64(2048) {
|
||
|
|
t.Errorf("expected tokens=2048, got %f", tb.tokens)
|
||
|
|
}
|
||
|
|
if tb.refillRate != float64(1024) {
|
||
|
|
t.Errorf("expected refillRate=1024, got %f", tb.refillRate)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("equal rate and burst", func(t *testing.T) {
|
||
|
|
tb := NewTokenBucket(5000, 5000)
|
||
|
|
if tb == nil {
|
||
|
|
t.Fatal("expected non-nil token bucket")
|
||
|
|
}
|
||
|
|
if tb.capacity != 5000 {
|
||
|
|
t.Errorf("expected capacity=5000, got %d", tb.capacity)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestNewTokenBucketNil(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("zero rate returns nil", func(t *testing.T) {
|
||
|
|
tb := NewTokenBucket(0, 1024)
|
||
|
|
if tb != nil {
|
||
|
|
t.Error("expected nil for zero rate")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("negative rate returns nil", func(t *testing.T) {
|
||
|
|
tb := NewTokenBucket(-100, 1024)
|
||
|
|
if tb != nil {
|
||
|
|
t.Error("expected nil for negative rate")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTokenBucketAllow(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("nil bucket Allow is no-op", func(t *testing.T) {
|
||
|
|
// Should not panic
|
||
|
|
var tb *TokenBucket
|
||
|
|
tb.Allow(100)
|
||
|
|
// No assertion needed - if it didn't panic, we're good
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("allows up to capacity immediately", func(t *testing.T) {
|
||
|
|
tb := NewTokenBucket(1000, 500)
|
||
|
|
if tb == nil {
|
||
|
|
t.Fatal("expected non-nil bucket")
|
||
|
|
}
|
||
|
|
// Should allow up to burst size without blocking
|
||
|
|
start := time.Now()
|
||
|
|
tb.Allow(300)
|
||
|
|
tb.Allow(200)
|
||
|
|
elapsed := time.Since(start)
|
||
|
|
if elapsed > 100*time.Millisecond {
|
||
|
|
t.Errorf("expected near-instant Allow for <= capacity, took %v", elapsed)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("blocks when exceeding rate", func(t *testing.T) {
|
||
|
|
// Rate: 100 bytes/sec, burst: 100 bytes
|
||
|
|
tb := NewTokenBucket(100, 100)
|
||
|
|
if tb == nil {
|
||
|
|
t.Fatal("expected non-nil bucket")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Consume all tokens
|
||
|
|
tb.Allow(100)
|
||
|
|
|
||
|
|
// Next Allow should block for some time
|
||
|
|
start := time.Now()
|
||
|
|
tb.Allow(50) // needs ~500ms to refill at 100 bytes/sec
|
||
|
|
elapsed := time.Since(start)
|
||
|
|
if elapsed < 200*time.Millisecond {
|
||
|
|
t.Errorf("expected Allow(50) to block for ~500ms, took %v", elapsed)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("allows after waiting for refill", func(t *testing.T) {
|
||
|
|
tb := NewTokenBucket(1000, 500)
|
||
|
|
if tb == nil {
|
||
|
|
t.Fatal("expected non-nil bucket")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Consume all tokens
|
||
|
|
tb.Allow(500)
|
||
|
|
|
||
|
|
// Wait for refill
|
||
|
|
time.Sleep(200 * time.Millisecond) // ~200 tokens refilled
|
||
|
|
|
||
|
|
start := time.Now()
|
||
|
|
tb.Allow(150) // should have enough tokens
|
||
|
|
elapsed := time.Since(start)
|
||
|
|
if elapsed > 50*time.Millisecond {
|
||
|
|
t.Errorf("expected Allow(150) to be near-instant after waiting, took %v", elapsed)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestTokenBucketSmallBurst(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("burst of 1 works with rate limiting", func(t *testing.T) {
|
||
|
|
// Rate: 1000 bytes/sec, burst: 1 byte — each byte needs ~1ms refill
|
||
|
|
tb := NewTokenBucket(1000, 1)
|
||
|
|
if tb == nil {
|
||
|
|
t.Fatal("expected non-nil bucket")
|
||
|
|
}
|
||
|
|
if tb.capacity != 1 {
|
||
|
|
t.Errorf("expected capacity=1, got %d", tb.capacity)
|
||
|
|
}
|
||
|
|
// First byte should be allowed immediately (burst capacity)
|
||
|
|
start := time.Now()
|
||
|
|
tb.Allow(1)
|
||
|
|
elapsed := time.Since(start)
|
||
|
|
if elapsed > 50*time.Millisecond {
|
||
|
|
t.Errorf("expected Allow(1) to be instant, took %v", elapsed)
|
||
|
|
}
|
||
|
|
// Second byte should block for ~1ms at 1000 bytes/sec
|
||
|
|
start = time.Now()
|
||
|
|
tb.Allow(1)
|
||
|
|
elapsed = time.Since(start)
|
||
|
|
if elapsed < 500*time.Microsecond {
|
||
|
|
t.Errorf("expected Allow(1) to block briefly for refill, took %v", elapsed)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("zero burst with valid rate returns non-nil but Allow blocks indefinitely", func(t *testing.T) {
|
||
|
|
// capacity=0 means tokens are always capped to 0, so no token can ever be consumed.
|
||
|
|
// This is an inherent limitation — the test verifies the bucket is created without
|
||
|
|
// panic and that Allow(1) would block (but we don't actually call Allow to avoid timeout).
|
||
|
|
tb := NewTokenBucket(1000, 0)
|
||
|
|
if tb == nil {
|
||
|
|
t.Fatal("expected non-nil bucket")
|
||
|
|
}
|
||
|
|
if tb.capacity != 0 {
|
||
|
|
t.Errorf("expected capacity=0, got %d", tb.capacity)
|
||
|
|
}
|
||
|
|
// Note: Allow(1) with capacity=0 would loop forever because tokens
|
||
|
|
// are always capped back to 0 after refill.
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestWrapReader(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("nil bucket returns original reader", func(t *testing.T) {
|
||
|
|
var tb *TokenBucket
|
||
|
|
r := strings.NewReader("hello")
|
||
|
|
wrapped := tb.WrapReader(r)
|
||
|
|
if wrapped != r {
|
||
|
|
t.Error("expected original reader when bucket is nil")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("wraps reader with rate limiter", func(t *testing.T) {
|
||
|
|
tb := NewTokenBucket(10000, 10000)
|
||
|
|
if tb == nil {
|
||
|
|
t.Fatal("expected non-nil bucket")
|
||
|
|
}
|
||
|
|
r := strings.NewReader("hello world")
|
||
|
|
wrapped := tb.WrapReader(r)
|
||
|
|
if wrapped == nil {
|
||
|
|
t.Fatal("expected non-nil wrapped reader")
|
||
|
|
}
|
||
|
|
if _, ok := wrapped.(*limitedReader); !ok {
|
||
|
|
t.Error("expected *limitedReader type")
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestLimitedReader(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
t.Run("reads all data through limiter", func(t *testing.T) {
|
||
|
|
tb := NewTokenBucket(100000, 100000)
|
||
|
|
if tb == nil {
|
||
|
|
t.Fatal("expected non-nil bucket")
|
||
|
|
}
|
||
|
|
|
||
|
|
input := []byte("the quick brown fox jumps over the lazy dog")
|
||
|
|
r := tb.WrapReader(bytes.NewReader(input))
|
||
|
|
|
||
|
|
output, err := io.ReadAll(r)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
if !bytes.Equal(output, input) {
|
||
|
|
t.Errorf("output doesn't match input: got %q, want %q", string(output), string(input))
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("reads with small buffer", func(t *testing.T) {
|
||
|
|
tb := NewTokenBucket(50000, 50000)
|
||
|
|
if tb == nil {
|
||
|
|
t.Fatal("expected non-nil bucket")
|
||
|
|
}
|
||
|
|
|
||
|
|
input := []byte("hello world")
|
||
|
|
r := tb.WrapReader(bytes.NewReader(input))
|
||
|
|
|
||
|
|
// Read byte by byte
|
||
|
|
var output []byte
|
||
|
|
buf := make([]byte, 1)
|
||
|
|
for {
|
||
|
|
n, err := r.Read(buf)
|
||
|
|
if n > 0 {
|
||
|
|
output = append(output, buf[:n]...)
|
||
|
|
}
|
||
|
|
if err == io.EOF {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("unexpected error: %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !bytes.Equal(output, input) {
|
||
|
|
t.Errorf("output doesn't match input: got %q, want %q", string(output), string(input))
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("empty reader returns immediately", func(t *testing.T) {
|
||
|
|
tb := NewTokenBucket(1000, 1000)
|
||
|
|
if tb == nil {
|
||
|
|
t.Fatal("expected non-nil bucket")
|
||
|
|
}
|
||
|
|
|
||
|
|
r := tb.WrapReader(bytes.NewReader(nil))
|
||
|
|
n, err := r.Read(make([]byte, 10))
|
||
|
|
if n != 0 || err != io.EOF {
|
||
|
|
t.Errorf("expected (0, EOF), got (%d, %v)", n, err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Integration test: retry with OnRetry counts
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
func TestRetryExponentialBackoff(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
var delays []time.Duration
|
||
|
|
cfg := &RetryConfig{
|
||
|
|
MaxRetries: 3,
|
||
|
|
InitialDelay: 10 * time.Millisecond,
|
||
|
|
MaxDelay: 100 * time.Millisecond,
|
||
|
|
Multiplier: 3.0, // aggressive multiplier for testing
|
||
|
|
OnRetry: func(attempt int, err error, delay time.Duration) {
|
||
|
|
delays = append(delays, delay)
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx := context.Background()
|
||
|
|
Retry(ctx, func(ctx context.Context, attempt int) error {
|
||
|
|
return fmt.Errorf("fail")
|
||
|
|
}, cfg)
|
||
|
|
|
||
|
|
// We should have had 3 retries: delays: 10ms, 30ms, 90ms
|
||
|
|
if len(delays) != 3 {
|
||
|
|
t.Fatalf("expected 3 delays, got %d: %v", len(delays), delays)
|
||
|
|
}
|
||
|
|
if delays[0] != 10*time.Millisecond {
|
||
|
|
t.Errorf("expected first delay=10ms, got %v", delays[0])
|
||
|
|
}
|
||
|
|
if delays[1] != 30*time.Millisecond {
|
||
|
|
t.Errorf("expected second delay=30ms, got %v", delays[1])
|
||
|
|
}
|
||
|
|
if delays[2] != 90*time.Millisecond {
|
||
|
|
t.Errorf("expected third delay=90ms, got %v", delays[2])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestRetryExponentialBackoffMaxDelay(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
var delays []time.Duration
|
||
|
|
cfg := &RetryConfig{
|
||
|
|
MaxRetries: 4,
|
||
|
|
InitialDelay: 10 * time.Millisecond,
|
||
|
|
MaxDelay: 25 * time.Millisecond,
|
||
|
|
Multiplier: 3.0,
|
||
|
|
OnRetry: func(attempt int, err error, delay time.Duration) {
|
||
|
|
delays = append(delays, delay)
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx := context.Background()
|
||
|
|
Retry(ctx, func(ctx context.Context, attempt int) error {
|
||
|
|
return fmt.Errorf("fail")
|
||
|
|
}, cfg)
|
||
|
|
|
||
|
|
// delays: 10ms, 30ms (but capped at 25ms), 75ms (capped at 25ms), ...
|
||
|
|
if len(delays) != 4 {
|
||
|
|
t.Fatalf("expected 4 delays, got %d: %v", len(delays), delays)
|
||
|
|
}
|
||
|
|
if delays[1] != 25*time.Millisecond {
|
||
|
|
t.Errorf("expected second delay capped at 25ms, got %v", delays[1])
|
||
|
|
}
|
||
|
|
if delays[2] != 25*time.Millisecond {
|
||
|
|
t.Errorf("expected third delay capped at 25ms, got %v", delays[2])
|
||
|
|
}
|
||
|
|
if delays[3] != 25*time.Millisecond {
|
||
|
|
t.Errorf("expected fourth delay capped at 25ms, got %v", delays[3])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Dialer edge cases (unit-level, no network required)
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
func TestDialerLookupIPAddr(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
d := NewDialer(DefaultDialerConfig())
|
||
|
|
if d == nil {
|
||
|
|
t.Fatal("expected non-nil dialer")
|
||
|
|
}
|
||
|
|
|
||
|
|
// LookupIPAddr sorts IPv6 first - we can't easily test with real DNS
|
||
|
|
// without network, but we can verify the method exists and returns
|
||
|
|
// appropriate error for invalid hostname.
|
||
|
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||
|
|
defer cancel()
|
||
|
|
|
||
|
|
_, err := d.LookupIPAddr(ctx, "invalid-host-name-that-does-not-exist.example.com")
|
||
|
|
// Should get some kind of DNS error
|
||
|
|
if err == nil {
|
||
|
|
t.Log("DNS lookup succeeded (unexpected on offline test)")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDialerDialContextInvalidAddress(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
d := NewDialer(DefaultDialerConfig())
|
||
|
|
if d == nil {
|
||
|
|
t.Fatal("expected non-nil dialer")
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx := context.Background()
|
||
|
|
_, err := d.DialContext(ctx, "tcp", "invalid-address")
|
||
|
|
if err == nil {
|
||
|
|
t.Error("expected error for invalid address")
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "invalid address") {
|
||
|
|
t.Errorf("expected 'invalid address' in error, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestDialerDialContextEmptyHost(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
d := NewDialer(DefaultDialerConfig())
|
||
|
|
if d == nil {
|
||
|
|
t.Fatal("expected non-nil dialer")
|
||
|
|
}
|
||
|
|
|
||
|
|
ctx := context.Background()
|
||
|
|
// ":80" will fail SplitHostPort with "missing host" or similar
|
||
|
|
_, err := d.DialContext(ctx, "tcp", ":80")
|
||
|
|
if err == nil {
|
||
|
|
t.Error("expected error for empty host with port")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestContainsIgnoreCase(t *testing.T) {
|
||
|
|
t.Parallel()
|
||
|
|
|
||
|
|
tests := []struct {
|
||
|
|
s, substr string
|
||
|
|
want bool
|
||
|
|
}{
|
||
|
|
{"Hello World", "world", true},
|
||
|
|
{"Hello World", "hello", true},
|
||
|
|
{"Hello World", "WORLD", true},
|
||
|
|
{"Hello World", "xyz", false},
|
||
|
|
{"", "", true},
|
||
|
|
{"abc", "", true},
|
||
|
|
{"", "abc", false},
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, tt := range tests {
|
||
|
|
t.Run("", func(t *testing.T) {
|
||
|
|
got := containsIgnoreCase(tt.s, tt.substr)
|
||
|
|
if got != tt.want {
|
||
|
|
t.Errorf("containsIgnoreCase(%q, %q) = %v, want %v", tt.s, tt.substr, got, tt.want)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|