package httpc import ( "context" "errors" "io" "net" "net/http" "net/http/httptest" "net/url" "os" "os/exec" "strings" "sync" "sync/atomic" "testing" "time" ) func testClient(t *testing.T, cfg Config) *Client { t.Helper() cfg.DisableLogging = true c, err := New(cfg) if err != nil { t.Fatal(err) } t.Cleanup(func() { c.Close() }) return c } func TestTransportConfigurationAndIsolation(t *testing.T) { base := http.DefaultTransport.(*http.Transport) oldConns, oldIdle, oldTimeout := base.MaxConnsPerHost, base.MaxIdleConns, base.ResponseHeaderTimeout cfg := Config{Proxy: ProxyConfig{Mode: ProxyDirect}, DialTimeout: time.Second, DialKeepAlive: 20 * time.Second, TLSHandshakeTimeout: 2 * time.Second, ResponseHeaderTimeout: 3 * time.Second, ExpectContinueTimeout: 4 * time.Second, IdleConnTimeout: 5 * time.Second, MaxConnsPerHost: 8, MaxIdleConns: 12, MaxIdleConnsPerHost: 3} c := testClient(t, cfg) if c.owned == base || c.owned.Proxy != nil || c.owned.DialContext == nil || c.owned.MaxConnsPerHost != 8 || c.owned.MaxIdleConns != 12 || c.owned.MaxIdleConnsPerHost != 3 || c.owned.TLSHandshakeTimeout != 2*time.Second || c.owned.ResponseHeaderTimeout != 3*time.Second || c.owned.ExpectContinueTimeout != 4*time.Second || c.owned.IdleConnTimeout != 5*time.Second { t.Fatal("transport options not applied") } if base.MaxConnsPerHost != oldConns || base.MaxIdleConns != oldIdle || base.ResponseHeaderTimeout != oldTimeout { t.Fatal("modified shared transport") } if c.owned.TLSClientConfig != nil && c.owned.TLSClientConfig.InsecureSkipVerify { t.Fatal("TLS verification disabled") } d := testClient(t, Config{}) if d.owned.MaxConnsPerHost != 64 || d.owned.MaxIdleConnsPerHost != 64 || d.owned.MaxIdleConns != 128 || d.owned.IdleConnTimeout != base.IdleConnTimeout || d.owned.TLSHandshakeTimeout != base.TLSHandshakeTimeout || d.owned.ResponseHeaderTimeout != base.ResponseHeaderTimeout { t.Fatal("default behavior changed") } ctx, cancel := context.WithCancel(context.Background()) cancel() if conn, err := c.owned.DialContext(ctx, "tcp", "127.0.0.1:1"); err == nil { conn.Close() t.Fatal("dial ignored context") } } func TestProxyValidation(t *testing.T) { for _, scheme := range []string{"http", "https", "socks5", "socks5h"} { c := testClient(t, Config{Proxy: ProxyConfig{Mode: ProxyURL, URL: scheme + "://user:private-value@proxy.invalid:8080"}}) req, _ := http.NewRequest("GET", "https://target.invalid", nil) u, err := c.owned.Proxy(req) if err != nil || u.Host != "proxy.invalid:8080" || u.Scheme != scheme { t.Fatal("explicit proxy unavailable") } } bad := []Config{ {Proxy: ProxyConfig{Mode: "private-value"}}, {Proxy: ProxyConfig{URL: "http://private-value"}}, {Proxy: ProxyConfig{Mode: ProxyDirect, URL: "http://private-value"}}, {Proxy: ProxyConfig{Mode: ProxyURL, URL: "http://user:private-value@proxy.invalid:bad"}}, {Proxy: ProxyConfig{Mode: ProxyURL, URL: "file:///private-value"}}, {Proxy: ProxyConfig{Mode: ProxyURL, URL: "http://proxy.invalid/private-value"}}, {Proxy: ProxyConfig{Mode: ProxyURL, URL: "http://proxy.invalid?token=private-value"}}, {Proxy: ProxyConfig{Mode: ProxyURL, URL: "http://proxy.invalid:65536"}}, {DialTimeout: -1}, {DialKeepAlive: -1}, {TLSHandshakeTimeout: -1}, {ResponseHeaderTimeout: -1}, {ExpectContinueTimeout: -1}, {IdleConnTimeout: -1}, {MaxIdleConns: -1}, {MaxIdleConnsPerHost: 65537}, {Transport: benchmarkTransport{}, Proxy: ProxyConfig{Mode: ProxyDirect}}, {Transport: benchmarkTransport{}, DialTimeout: time.Second}, {Transport: benchmarkTransport{}, MaxConnsPerHost: 2}, } for i, cfg := range bad { if _, err := New(cfg); err == nil || strings.Contains(err.Error(), "private-value") { t.Fatalf("case %d: unsafe or missing error %v", i, err) } } var nilTransport *http.Transport if _, err := New(Config{Transport: nilTransport}); err == nil { t.Fatal("typed nil accepted") } } func TestProxyRouting(t *testing.T) { var proxied, target atomic.Int32 proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { proxied.Add(1) if r.URL.Scheme != "http" || r.URL.Host != "target.invalid" { t.Error("not an absolute proxy request") } io.WriteString(w, `{"proxy":true}`) })) defer proxy.Close() origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { target.Add(1); io.WriteString(w, `{}`) })) defer origin.Close() // Isolate the standard library's process-cached environment proxy lookup. executable, err := os.Executable() if err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() cmd := exec.CommandContext(ctx, executable, "-test.run=^TestProxyEnvironmentProcess$") for _, value := range os.Environ() { key := strings.SplitN(value, "=", 2)[0] switch strings.ToUpper(key) { case "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "GO_OMNI_HTTPC_PROXY_CHILD", "GO_OMNI_HTTPC_TARGET": continue } cmd.Env = append(cmd.Env, value) } cmd.Env = append(cmd.Env, "GO_OMNI_HTTPC_PROXY_CHILD=1", "GO_OMNI_HTTPC_TARGET="+origin.URL, "HTTP_PROXY="+proxy.URL, "HTTPS_PROXY="+proxy.URL, "NO_PROXY=bypass.invalid") if out, err := cmd.CombinedOutput(); err != nil { t.Fatal(err, string(out)) } if proxied.Load() != 3 || target.Load() != 1 { t.Fatalf("proxy=%d direct=%d", proxied.Load(), target.Load()) } } func TestProxyEnvironmentProcess(t *testing.T) { if os.Getenv("GO_OMNI_HTTPC_PROXY_CHILD") != "1" { t.Skip("subprocess only") } for _, proxy := range []ProxyConfig{{}, {Mode: ProxyEnvironment}, {Mode: ProxyURL, URL: os.Getenv("HTTP_PROXY")}} { c := testClient(t, Config{Proxy: proxy}) if err := c.JSON(context.Background(), "GET", "http://target.invalid/path", nil, nil); err != nil { t.Fatal(err) } } environment := testClient(t, Config{Proxy: ProxyConfig{Mode: ProxyEnvironment}}) req, _ := http.NewRequest("GET", "http://bypass.invalid", nil) u, err := environment.owned.Proxy(req) if err != nil || u != nil { t.Fatal("NO_PROXY ignored") } direct := testClient(t, Config{Proxy: ProxyConfig{Mode: ProxyDirect}}) if direct.owned.Proxy != nil { t.Fatal("direct still has proxy") } if err := direct.JSON(context.Background(), "GET", os.Getenv("GO_OMNI_HTTPC_TARGET"), nil, nil); err != nil { t.Fatal(err) } } func TestResponseHeaderTimeoutRecovery(t *testing.T) { s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/slow" { <-r.Context().Done() return } io.WriteString(w, `{}`) })) defer s.Close() c := testClient(t, Config{Timeout: 2 * time.Second, ResponseHeaderTimeout: 30 * time.Millisecond}) start := time.Now() err := c.JSON(context.Background(), "GET", s.URL+"/slow", nil, nil) var network net.Error if !errors.As(err, &network) || !network.Timeout() || time.Since(start) > time.Second { t.Fatal("header timeout", err) } if err = c.JSON(context.Background(), "GET", s.URL+"/ok", nil, nil); err != nil { t.Fatal("did not recover", err) } } func TestTLSHandshakeTimeout(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } defer listener.Close() done := make(chan struct{}) go func() { defer close(done) conn, err := listener.Accept() if err != nil { return } defer conn.Close() _, _ = io.Copy(io.Discard, conn) }() c := testClient(t, Config{Timeout: 2 * time.Second, Proxy: ProxyConfig{Mode: ProxyDirect}, TLSHandshakeTimeout: 30 * time.Millisecond}) start := time.Now() err = c.JSON(context.Background(), "GET", "https://"+listener.Addr().String(), nil, nil) var network net.Error if !errors.As(err, &network) || !network.Timeout() || time.Since(start) > time.Second { t.Fatal("handshake timeout", err) } select { case <-done: case <-time.After(time.Second): t.Fatal("TLS connection leaked") } } func TestPoolLimitsAndIdleExpiry(t *testing.T) { var connections atomic.Int32 entered := make(chan struct{}, 12) release := make(chan struct{}) closed := make(chan struct{}, 12) s := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { entered <- struct{}{} select { case <-release: case <-r.Context().Done(): return } io.WriteString(w, `{}`) })) s.Config.ConnState = func(_ net.Conn, state http.ConnState) { if state == http.StateNew { connections.Add(1) } if state == http.StateClosed { closed <- struct{}{} } } s.Start() defer s.Close() c := testClient(t, Config{Timeout: 2 * time.Second, MaxConnsPerHost: 2, MaxIdleConnsPerHost: 2, MaxIdleConns: 2, IdleConnTimeout: 40 * time.Millisecond}) var wg sync.WaitGroup for i := 0; i < 12; i++ { wg.Add(1) go func() { defer wg.Done() if err := c.JSON(context.Background(), "GET", s.URL, nil, nil); err != nil { t.Error(err) } }() } for i := 0; i < 2; i++ { select { case <-entered: case <-time.After(time.Second): close(release) t.Fatal("requests did not start") } } if connections.Load() != 2 { t.Error("connection cap", connections.Load()) } close(release) wg.Wait() if connections.Load() != 2 { t.Fatal("pool was not reused", connections.Load()) } for i := 0; i < 2; i++ { select { case <-closed: case <-time.After(time.Second): t.Fatal("idle pool was not reclaimed") } } if err := c.JSON(context.Background(), "GET", s.URL, nil, nil); err != nil { t.Fatal(err) } if connections.Load() != 3 { t.Fatal("new connection not established after idle timeout") } c.Close() select { case <-closed: case <-time.After(time.Second): t.Fatal("Close left idle connections open") } } func TestHTTPSVerificationRetained(t *testing.T) { s := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, `{}`) })) defer s.Close() c := testClient(t, Config{Proxy: ProxyConfig{Mode: ProxyDirect}}) err := c.JSON(context.Background(), "GET", s.URL, nil, nil) var urlError *url.Error if !errors.As(err, &urlError) { t.Fatal("self-signed server unexpectedly trusted", err) } }