package httpclient_test import ( "context" "crypto/tls" "errors" "io" "net" "net/http" "net/http/cookiejar" "net/http/httptest" "net/url" "slices" "strings" "sync/atomic" "testing" "time" "github.com/GopeedLab/gopeed/internal/httpclient" "github.com/GopeedLab/gopeed/internal/httpclient/httpclienttest" ) func TestFixedChromeUsesBrowserTLSFingerprintAndHTTP2(t *testing.T) { server := httpclienttest.NewFingerprintServer(t, httpclienttest.FingerprintServerOptions{ RequiredProfile: httpclienttest.ProfileAnyBrowser, }) client, err := httpclient.NewClient(httpclient.Options{ Transport: httpclient.TransportOptions{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, Impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationFixed, Browser: httpclient.BrowserChrome, }, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } response, err := client.Get(server.URL) if err != nil { t.Fatalf("Get() error = %v", err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK) } if response.ProtoMajor != 2 { t.Fatalf("protocol = %q, want HTTP/2", response.Proto) } } func TestAutoFallsBackFromNativeToBrowserFingerprint(t *testing.T) { server := httpclienttest.NewFingerprintServer(t, httpclienttest.FingerprintServerOptions{ RequiredProfile: httpclienttest.ProfileAnyBrowser, }) client, err := httpclient.NewClient(httpclient.Options{ Transport: httpclient.TransportOptions{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, Impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationAuto, }, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } request, err := http.NewRequest(http.MethodGet, server.URL, nil) if err != nil { t.Fatalf("NewRequest() error = %v", err) } request.Header.Set("User-Agent", "Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36") response, err := client.Do(request) if err != nil { t.Fatalf("Do() error = %v", err) } defer response.Body.Close() if response.ProtoMajor != 2 { t.Fatalf("protocol = %q, want HTTP/2", response.Proto) } if got, want := server.Profiles(), []httpclienttest.Profile{ httpclienttest.ProfileNative, httpclienttest.ProfileChrome, }; !slices.Equal(got, want) { t.Fatalf("ClientHello profiles = %v, want %v", got, want) } } func TestAutoChoosesBrowserFingerprintFromUserAgent(t *testing.T) { tests := []struct { name string userAgent string wantBrowser httpclient.Browser }{ { name: "chrome", userAgent: "Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36", wantBrowser: httpclient.BrowserChrome, }, { name: "firefox", userAgent: "Mozilla/5.0 Firefox/120.0", wantBrowser: httpclient.BrowserFirefox, }, { name: "safari", userAgent: "Mozilla/5.0 Version/16.6 Safari/605.1.15", wantBrowser: httpclient.BrowserSafari, }, { name: "unknown defaults to chrome", userAgent: "Gopeed/1.0", wantBrowser: httpclient.BrowserChrome, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { server := httpclienttest.NewFingerprintServer(t, httpclienttest.FingerprintServerOptions{ RequiredProfile: httpclienttest.Profile(test.wantBrowser), }) client, err := httpclient.NewClient(httpclient.Options{ Transport: httpclient.TransportOptions{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, Impersonation: httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationAuto}, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } request, err := http.NewRequest(http.MethodGet, server.URL, nil) if err != nil { t.Fatalf("NewRequest() error = %v", err) } request.Header.Set("User-Agent", test.userAgent) response, err := client.Do(request) if err != nil { t.Fatalf("Do() error = %v", err) } response.Body.Close() if response.ProtoMajor != 2 { t.Fatalf("protocol = %q, want HTTP/2", response.Proto) } if got, want := server.Profiles(), []httpclienttest.Profile{ httpclienttest.ProfileNative, httpclienttest.Profile(test.wantBrowser), }; !slices.Equal(got, want) { t.Fatalf("profiles = %v, want %v", got, want) } }) } } func TestAutoFallsBackOnForbiddenAndPinsBrowserPerURL(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { requests.Add(1) if request.Header.Get("Sec-Ch-Ua") == "" { http.Error(w, "browser required", http.StatusForbidden) return } w.WriteHeader(http.StatusOK) })) t.Cleanup(server.Close) client, err := httpclient.NewClient(httpclient.Options{ Impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationAuto, Session: httpclient.NewImpersonationSession(), }, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } for range 2 { request, err := http.NewRequest(http.MethodGet, server.URL, nil) if err != nil { t.Fatalf("NewRequest() error = %v", err) } request.Header.Set("User-Agent", "Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36") response, err := client.Do(request) if err != nil { t.Fatalf("Do() error = %v", err) } response.Body.Close() if response.StatusCode != http.StatusOK { t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK) } } if requests.Load() != 3 { t.Fatalf("requests = %d, want 3 (native + fallback + pinned)", requests.Load()) } } func TestAutoDisablesFallbackWhenBrowserReturnsHTTPFailure(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { requests.Add(1) if request.Header.Get("Sec-Ch-Ua") == "" { w.WriteHeader(http.StatusForbidden) return } w.WriteHeader(http.StatusNotFound) })) t.Cleanup(server.Close) client, err := httpclient.NewClient(httpclient.Options{ Impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationAuto, Session: httpclient.NewImpersonationSession(), }, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } wantStatuses := []int{http.StatusNotFound, http.StatusForbidden} for _, wantStatus := range wantStatuses { response, err := client.Get(server.URL) if err != nil { t.Fatalf("Get() error = %v", err) } response.Body.Close() if response.StatusCode != wantStatus { t.Fatalf("status = %d, want %d", response.StatusCode, wantStatus) } } if requests.Load() != 3 { t.Fatalf("requests = %d, want 3 (native + one fallback + native)", requests.Load()) } } func TestAutoDoesNotDisableFallbackAfterTransportFailure(t *testing.T) { server := httpclienttest.NewFingerprintServer(t, httpclienttest.FingerprintServerOptions{ RequiredProfile: httpclienttest.Profile("unsupported"), }) client, err := httpclient.NewClient(httpclient.Options{ Transport: httpclient.TransportOptions{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, Impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationAuto, Session: httpclient.NewImpersonationSession(), }, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } for range 2 { if _, err := client.Get(server.URL); err == nil { t.Fatal("Get() error = nil, want TLS failure") } } if got, want := server.Profiles(), []httpclienttest.Profile{ httpclienttest.ProfileNative, httpclienttest.ProfileChrome, httpclienttest.ProfileNative, httpclienttest.ProfileChrome, }; !slices.Equal(got, want) { t.Fatalf("ClientHello profiles = %v, want %v", got, want) } } func TestAutoWithoutSessionDoesNotCacheSelectedBrowser(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { requests.Add(1) if request.Header.Get("Sec-Ch-Ua") == "" { w.WriteHeader(http.StatusForbidden) return } w.WriteHeader(http.StatusOK) })) t.Cleanup(server.Close) client, err := httpclient.NewClient(httpclient.Options{ Impersonation: httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationAuto}, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } for range 2 { response, err := client.Get(server.URL) if err != nil { t.Fatalf("Get() error = %v", err) } response.Body.Close() } if requests.Load() != 4 { t.Fatalf("requests = %d, want 4 (native + fallback without caching)", requests.Load()) } } func TestAutoSharesSelectedBrowserAcrossClientsInSession(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { requests.Add(1) if request.Header.Get("Sec-Ch-Ua") == "" { w.WriteHeader(http.StatusForbidden) return } w.WriteHeader(http.StatusOK) })) t.Cleanup(server.Close) session := httpclient.NewImpersonationSession() for range 2 { client, err := httpclient.NewClient(httpclient.Options{ Impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationAuto, Session: session, }, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } request, err := http.NewRequest(http.MethodGet, server.URL, nil) if err != nil { t.Fatalf("NewRequest() error = %v", err) } request.Header.Set("User-Agent", "Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36") response, err := client.Do(request) if err != nil { t.Fatalf("Do() error = %v", err) } response.Body.Close() } if requests.Load() != 3 { t.Fatalf("requests = %d, want 3 (native + fallback + session pinned)", requests.Load()) } } func TestAutoDoesNotShareSelectedBrowserAcrossSessions(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { requests.Add(1) if request.Header.Get("Sec-Ch-Ua") == "" { w.WriteHeader(http.StatusForbidden) return } w.WriteHeader(http.StatusOK) })) t.Cleanup(server.Close) for range 2 { client, err := httpclient.NewClient(httpclient.Options{ Impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationAuto, Session: httpclient.NewImpersonationSession(), }, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } response, err := client.Get(server.URL) if err != nil { t.Fatalf("Get() error = %v", err) } response.Body.Close() } if requests.Load() != 4 { t.Fatalf("requests = %d, want 4 (native + fallback per session)", requests.Load()) } } func TestAutoCachesSelectedBrowserByFullURL(t *testing.T) { var publicBrowserRequests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { browserRequest := request.Header.Get("Sec-Ch-Ua") != "" switch request.URL.Query().Get("access") { case "protected": if !browserRequest { w.WriteHeader(http.StatusForbidden) return } case "public": if browserRequest { publicBrowserRequests.Add(1) } } w.WriteHeader(http.StatusOK) })) t.Cleanup(server.Close) client, err := httpclient.NewClient(httpclient.Options{ Impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationAuto, Session: httpclient.NewImpersonationSession(), }, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } for range 2 { response, err := client.Get(server.URL + "/file?access=protected") if err != nil { t.Fatalf("protected Get() error = %v", err) } response.Body.Close() } response, err := client.Get(server.URL + "/file?access=public") if err != nil { t.Fatalf("public Get() error = %v", err) } response.Body.Close() if publicBrowserRequests.Load() != 0 { t.Fatalf("public browser requests = %d, want 0", publicBrowserRequests.Load()) } } func TestImpersonationSessionClearRemovesSelections(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { requests.Add(1) if request.Header.Get("Sec-Ch-Ua") == "" { w.WriteHeader(http.StatusForbidden) return } w.WriteHeader(http.StatusOK) })) t.Cleanup(server.Close) session := httpclient.NewImpersonationSession() client, err := httpclient.NewClient(httpclient.Options{ Impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationAuto, Session: session, }, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } for range 2 { response, err := client.Get(server.URL) if err != nil { t.Fatalf("Get() error = %v", err) } response.Body.Close() session.Clear() } if requests.Load() != 4 { t.Fatalf("requests = %d, want 4 (native + fallback after each clear)", requests.Load()) } } func TestGlobalAutoSelectionDoesNotAffectNativeMode(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { if request.Header.Get("Sec-Ch-Ua") == "" { w.WriteHeader(http.StatusForbidden) return } w.WriteHeader(http.StatusOK) })) t.Cleanup(server.Close) autoClient, err := httpclient.NewClient(httpclient.Options{ Impersonation: httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationAuto}, }) if err != nil { t.Fatalf("NewClient(auto) error = %v", err) } autoResponse, err := autoClient.Get(server.URL) if err != nil { t.Fatalf("auto Get() error = %v", err) } autoResponse.Body.Close() if autoResponse.StatusCode != http.StatusOK { t.Fatalf("auto status = %d, want %d", autoResponse.StatusCode, http.StatusOK) } nativeClient, err := httpclient.NewClient(httpclient.Options{ Impersonation: httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationNative}, }) if err != nil { t.Fatalf("NewClient(native) error = %v", err) } nativeResponse, err := nativeClient.Get(server.URL) if err != nil { t.Fatalf("native Get() error = %v", err) } nativeResponse.Body.Close() if nativeResponse.StatusCode != http.StatusForbidden { t.Fatalf("native status = %d, want %d", nativeResponse.StatusCode, http.StatusForbidden) } } func TestAutoAppliesFallbackResponseCookiesBeforeRetry(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { if request.Header.Get("Sec-Ch-Ua") == "" { http.SetCookie(w, &http.Cookie{Name: "challenge", Value: "passed", Path: "/"}) w.WriteHeader(http.StatusForbidden) return } cookie, err := request.Cookie("challenge") if err != nil || cookie.Value != "passed" { w.WriteHeader(http.StatusForbidden) return } w.WriteHeader(http.StatusOK) })) t.Cleanup(server.Close) jar, err := cookiejar.New(nil) if err != nil { t.Fatalf("cookiejar.New() error = %v", err) } client, err := httpclient.NewClient(httpclient.Options{ Client: httpclient.ClientOptions{Jar: jar}, Impersonation: httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationAuto}, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } response, err := client.Get(server.URL) if err != nil { t.Fatalf("Get() error = %v", err) } response.Body.Close() if response.StatusCode != http.StatusOK { t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK) } } func TestAutoDoesNotRetryRequestWithNonReplayableBody(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests.Add(1) w.WriteHeader(http.StatusForbidden) })) t.Cleanup(server.Close) client, err := httpclient.NewClient(httpclient.Options{ Impersonation: httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationAuto}, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } bodyReader, bodyWriter := io.Pipe() t.Cleanup(func() { bodyReader.Close() bodyWriter.Close() }) go func() { _, _ = bodyWriter.Write([]byte("payload")) _ = bodyWriter.Close() }() request, err := http.NewRequest(http.MethodPost, server.URL, bodyReader) if err != nil { t.Fatalf("NewRequest() error = %v", err) } response, err := client.Do(request) if err != nil { t.Fatalf("Do() error = %v", err) } response.Body.Close() if requests.Load() != 1 { t.Fatalf("requests = %d, want 1", requests.Load()) } } func TestAutoDoesNotRetryReplayablePost(t *testing.T) { var requests atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { requests.Add(1) w.WriteHeader(http.StatusForbidden) })) t.Cleanup(server.Close) client, err := httpclient.NewClient(httpclient.Options{ Impersonation: httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationAuto}, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } request, err := http.NewRequest(http.MethodPost, server.URL, strings.NewReader("payload")) if err != nil { t.Fatalf("NewRequest() error = %v", err) } response, err := client.Do(request) if err != nil { t.Fatalf("Do() error = %v", err) } response.Body.Close() if requests.Load() != 1 { t.Fatalf("requests = %d, want 1", requests.Load()) } } func TestNativeUsesStandardTransportWithHTTP2(t *testing.T) { server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) server.EnableHTTP2 = true server.StartTLS() t.Cleanup(server.Close) client, err := httpclient.NewClient(httpclient.Options{ Transport: httpclient.TransportOptions{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, }, Impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationNative, }, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } response, err := client.Get(server.URL) if err != nil { t.Fatalf("Get() error = %v", err) } defer response.Body.Close() if response.ProtoMajor != 2 { t.Fatalf("protocol = %q, want HTTP/2", response.Proto) } } func TestClientOptionsPreserveCookiesAcrossRedirects(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { switch request.URL.Path { case "/start": http.SetCookie(w, &http.Cookie{Name: "session", Value: "gopeed", Path: "/"}) http.Redirect(w, request, "/download", http.StatusFound) case "/download": cookie, err := request.Cookie("session") if err != nil || cookie.Value != "gopeed" { http.Error(w, "cookie missing", http.StatusBadRequest) return } w.WriteHeader(http.StatusOK) default: http.NotFound(w, request) } })) t.Cleanup(server.Close) jar, err := cookiejar.New(nil) if err != nil { t.Fatalf("cookiejar.New() error = %v", err) } var redirects atomic.Int32 client, err := httpclient.NewClient(httpclient.Options{ Client: httpclient.ClientOptions{ Jar: jar, CheckRedirect: func(_ *http.Request, _ []*http.Request) error { redirects.Add(1) return nil }, }, Impersonation: httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationNative}, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } response, err := client.Get(server.URL + "/start") if err != nil { t.Fatalf("Get() error = %v", err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK) } if redirects.Load() != 1 { t.Fatalf("redirect calls = %d, want 1", redirects.Load()) } } func TestClientTimeoutCancelsRequest(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { <-request.Context().Done() })) t.Cleanup(server.Close) client, err := httpclient.NewClient(httpclient.Options{ Client: httpclient.ClientOptions{Timeout: 50 * time.Millisecond}, Impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationNative, }, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } _, err = client.Get(server.URL) if err == nil { t.Fatal("Get() error = nil, want timeout") } var networkError net.Error if !errors.As(err, &networkError) || !networkError.Timeout() { t.Fatalf("Get() error = %v, want net.Error timeout", err) } } func TestTransportOptionsUseProxyInNativeAndFixedModes(t *testing.T) { proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { if request.URL.Host != "download.example" { http.Error(w, "unexpected target", http.StatusBadRequest) return } w.WriteHeader(http.StatusOK) })) t.Cleanup(proxy.Close) proxyURL, err := url.Parse(proxy.URL) if err != nil { t.Fatalf("url.Parse() error = %v", err) } tests := []struct { name string impersonation httpclient.ImpersonationOptions }{ { name: "native", impersonation: httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationNative}, }, { name: "fixed chrome", impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationFixed, Browser: httpclient.BrowserChrome, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { client, err := httpclient.NewClient(httpclient.Options{ Transport: httpclient.TransportOptions{Proxy: http.ProxyURL(proxyURL)}, Impersonation: test.impersonation, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } response, err := client.Get("http://download.example/file") if err != nil { t.Fatalf("Get() error = %v", err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { t.Fatalf("status = %d, want %d", response.StatusCode, http.StatusOK) } }) } } func TestTransportOptionsUseDialContextInNativeAndFixedModes(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) t.Cleanup(server.Close) tests := []struct { name string impersonation httpclient.ImpersonationOptions }{ { name: "native", impersonation: httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationNative}, }, { name: "fixed chrome", impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationFixed, Browser: httpclient.BrowserChrome, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { var calls atomic.Int32 dialer := &net.Dialer{} client, err := httpclient.NewClient(httpclient.Options{ Transport: httpclient.TransportOptions{ DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { calls.Add(1) return dialer.DialContext(ctx, network, address) }, }, Impersonation: test.impersonation, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } response, err := client.Get(server.URL) if err != nil { t.Fatalf("Get() error = %v", err) } response.Body.Close() if calls.Load() == 0 { t.Fatal("DialContext() was not called") } }) } } func TestTransportOptionsApplyTLSHandshakeTimeoutInNativeAndFixedModes(t *testing.T) { listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("net.Listen() error = %v", err) } closed := make(chan struct{}) t.Cleanup(func() { close(closed) listener.Close() }) go func() { for { connection, err := listener.Accept() if err != nil { return } go func() { <-closed connection.Close() }() } }() tests := []struct { name string impersonation httpclient.ImpersonationOptions }{ { name: "native", impersonation: httpclient.ImpersonationOptions{Mode: httpclient.ImpersonationNative}, }, { name: "fixed chrome", impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationFixed, Browser: httpclient.BrowserChrome, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { client, err := httpclient.NewClient(httpclient.Options{ Client: httpclient.ClientOptions{Timeout: time.Second}, Transport: httpclient.TransportOptions{ TLSHandshakeTimeout: 50 * time.Millisecond, }, Impersonation: test.impersonation, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } started := time.Now() _, err = client.Get("https://" + listener.Addr().String()) if err == nil { t.Fatal("Get() error = nil, want TLS handshake timeout") } if elapsed := time.Since(started); elapsed >= 500*time.Millisecond { t.Fatalf("TLS handshake took %s, want less than 500ms", elapsed) } }) } } func TestFixedBrowserAddsProfileHeadersWithoutOverwritingExplicitHeaders(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { w.Header().Set("Seen-User-Agent", request.UserAgent()) w.WriteHeader(http.StatusOK) })) t.Cleanup(server.Close) client, err := httpclient.NewClient(httpclient.Options{ Impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationFixed, Browser: httpclient.BrowserChrome, }, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } response, err := client.Get(server.URL) if err != nil { t.Fatalf("Get() error = %v", err) } response.Body.Close() if userAgent := response.Header.Get("Seen-User-Agent"); !strings.Contains(userAgent, "Chrome/120") { t.Fatalf("profile User-Agent = %q, want Chrome/120", userAgent) } request, err := http.NewRequest(http.MethodGet, server.URL, nil) if err != nil { t.Fatalf("NewRequest() error = %v", err) } request.Header.Set("User-Agent", "Gopeed-Custom/1.0") response, err = client.Do(request) if err != nil { t.Fatalf("Do() error = %v", err) } response.Body.Close() if userAgent := response.Header.Get("Seen-User-Agent"); userAgent != "Gopeed-Custom/1.0" { t.Fatalf("explicit User-Agent = %q, want Gopeed-Custom/1.0", userAgent) } } func TestFixedClientCloseIdleConnectionsReachesBrowserTransport(t *testing.T) { var connections atomic.Int32 server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) server.Config.ConnState = func(_ net.Conn, state http.ConnState) { if state == http.StateNew { connections.Add(1) } } server.Start() t.Cleanup(server.Close) client, err := httpclient.NewClient(httpclient.Options{ Impersonation: httpclient.ImpersonationOptions{ Mode: httpclient.ImpersonationFixed, Browser: httpclient.BrowserChrome, }, }) if err != nil { t.Fatalf("NewClient() error = %v", err) } for attempt := range 2 { response, err := client.Get(server.URL) if err != nil { t.Fatalf("Get() error = %v", err) } _, _ = io.Copy(io.Discard, response.Body) response.Body.Close() if attempt == 0 { client.CloseIdleConnections() } } if connections.Load() != 2 { t.Fatalf("connections = %d, want 2", connections.Load()) } }