mirror of
https://github.com/karust/openserp.git
synced 2026-08-05 16:53:54 +08:00
fix(captcha): exit non-zero on misconfig, tighten solver gate tests
This commit is contained in:
24
cmd/captcha_config.go
Normal file
24
cmd/captcha_config.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func resolveCaptchaSolverConfig() (bool, string, error) {
|
||||
apiKey := strings.TrimSpace(config.Config2Capcha.ApiKey)
|
||||
if !config.Captcha.SolverEnabled {
|
||||
if apiKey != "" {
|
||||
logrus.Warn("2captcha.apikey is set but captcha.solver_enabled=false; solver will not run")
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
if apiKey == "" {
|
||||
return false, "", fmt.Errorf("captcha solver is enabled (captcha.solver_enabled=true) but 2captcha.apikey is empty")
|
||||
}
|
||||
|
||||
return true, apiKey, nil
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
version = "0.6.4"
|
||||
version = "0.6.5"
|
||||
defaultConfigFilename = "config"
|
||||
envPrefix = "OPENSERP"
|
||||
)
|
||||
@@ -27,6 +27,7 @@ type Config struct {
|
||||
Resilience ResilienceConfig `mapstructure:"resilience"`
|
||||
CircuitBreaker CircuitBreakerConfig `mapstructure:"circuit_breaker"`
|
||||
CORS CORSConfig `mapstructure:"cors"`
|
||||
Captcha CaptchaConfig `mapstructure:"captcha"`
|
||||
Config2Capcha Config2Captcha `mapstructure:"2captcha"`
|
||||
GoogleConfig EngineConfig `mapstructure:"google"`
|
||||
YandexConfig EngineConfig `mapstructure:"yandex"`
|
||||
@@ -87,6 +88,10 @@ type CORSConfig struct {
|
||||
MaxAge int `mapstructure:"max_age"`
|
||||
}
|
||||
|
||||
type CaptchaConfig struct {
|
||||
SolverEnabled bool `mapstructure:"solver_enabled"`
|
||||
}
|
||||
|
||||
var config = Config{}
|
||||
|
||||
var flagToConfigKey = map[string]string{
|
||||
@@ -322,6 +327,7 @@ func setConfigDefaults(v *viper.Viper) {
|
||||
v.SetDefault("cors.allow_methods", "GET, POST, OPTIONS")
|
||||
v.SetDefault("cors.allow_headers", "Origin, Content-Type, Accept, Authorization, X-Use-Proxy")
|
||||
v.SetDefault("cors.max_age", 86400)
|
||||
v.SetDefault("captcha.solver_enabled", false)
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -34,6 +35,12 @@ func search(cmd *cobra.Command, args []string) {
|
||||
Insecure: config.Server.Insecure,
|
||||
}
|
||||
|
||||
captchaSolverEnabled, captchaSolverAPIKey, err := resolveCaptchaSolverConfig()
|
||||
if err != nil {
|
||||
logrus.Errorf("Error validating captcha solver config: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
proxyRuntime := core.ProxyRuntimeBrowser
|
||||
if config.Server.IsRawRequests {
|
||||
proxyRuntime = core.ProxyRuntimeRaw
|
||||
@@ -65,7 +72,7 @@ func search(cmd *cobra.Command, args []string) {
|
||||
results, err = searchRaw(engineType, query)
|
||||
} else {
|
||||
logrus.Infof("Using browser mode for %s search", engineType)
|
||||
results, err = searchBrowser(engineType, query, selectedProxy)
|
||||
results, err = searchBrowser(engineType, query, selectedProxy, captchaSolverEnabled, captchaSolverAPIKey)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@@ -84,7 +91,7 @@ func search(cmd *cobra.Command, args []string) {
|
||||
fmt.Println(string(b))
|
||||
}
|
||||
|
||||
func searchBrowser(engineType string, query core.Query, browserProxyURL string) ([]core.SearchResult, error) {
|
||||
func searchBrowser(engineType string, query core.Query, browserProxyURL string, captchaSolverEnabled bool, captchaSolverAPIKey string) ([]core.SearchResult, error) {
|
||||
var engine core.SearchEngine
|
||||
if core.IsAuthenticatedSocksProxyURL(browserProxyURL) {
|
||||
return nil, fmt.Errorf(
|
||||
@@ -99,7 +106,8 @@ func searchBrowser(engineType string, query core.Query, browserProxyURL string)
|
||||
IsLeakless: config.App.IsLeakless,
|
||||
Timeout: time.Second * time.Duration(config.App.Timeout),
|
||||
LeavePageOpen: config.App.IsLeaveHead,
|
||||
CaptchaSolverApiKey: config.Config2Capcha.ApiKey,
|
||||
CaptchaSolverEnabled: captchaSolverEnabled,
|
||||
CaptchaSolverApiKey: captchaSolverAPIKey,
|
||||
BrowserPath: config.App.BrowserPath,
|
||||
ProxyURL: browserProxyURL,
|
||||
Insecure: config.Server.Insecure,
|
||||
|
||||
10
cmd/serve.go
10
cmd/serve.go
@@ -3,6 +3,7 @@ package cmd
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -70,6 +71,12 @@ func serve(cmd *cobra.Command, args []string) {
|
||||
corsCfg.AllowHeaders = config.CORS.AllowHeaders
|
||||
corsCfg.MaxAge = config.CORS.MaxAge
|
||||
|
||||
captchaSolverEnabled, captchaSolverAPIKey, err := resolveCaptchaSolverConfig()
|
||||
if err != nil {
|
||||
logrus.Error(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
proxyRuntime := core.ProxyRuntimeBrowser
|
||||
if config.Server.IsRawRequests {
|
||||
proxyRuntime = core.ProxyRuntimeRaw
|
||||
@@ -100,7 +107,8 @@ func serve(cmd *cobra.Command, args []string) {
|
||||
IsLeakless: config.App.IsLeakless,
|
||||
Timeout: time.Second * time.Duration(config.App.Timeout),
|
||||
LeavePageOpen: config.App.IsLeaveHead,
|
||||
CaptchaSolverApiKey: config.Config2Capcha.ApiKey,
|
||||
CaptchaSolverEnabled: captchaSolverEnabled,
|
||||
CaptchaSolverApiKey: captchaSolverAPIKey,
|
||||
BrowserPath: config.App.BrowserPath,
|
||||
Insecure: config.Server.Insecure,
|
||||
UseStealth: config.App.IsStealth,
|
||||
|
||||
@@ -103,3 +103,69 @@ func TestValidateBrowserProxyPolicyRejectsTaggedAuthenticatedSocksInPool(t *test
|
||||
t.Fatalf("expected explicit authenticated SOCKS error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCaptchaSolverConfigDisabledWithoutKey(t *testing.T) {
|
||||
origEnabled := config.Captcha.SolverEnabled
|
||||
origKey := config.Config2Capcha.ApiKey
|
||||
defer func() {
|
||||
config.Captcha.SolverEnabled = origEnabled
|
||||
config.Config2Capcha.ApiKey = origKey
|
||||
}()
|
||||
|
||||
config.Captcha.SolverEnabled = false
|
||||
config.Config2Capcha.ApiKey = ""
|
||||
|
||||
enabled, key, err := resolveCaptchaSolverConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if enabled {
|
||||
t.Fatal("expected solver to be disabled")
|
||||
}
|
||||
if key != "" {
|
||||
t.Fatalf("expected empty solver key when disabled, got %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCaptchaSolverConfigEnabledWithoutKeyFails(t *testing.T) {
|
||||
origEnabled := config.Captcha.SolverEnabled
|
||||
origKey := config.Config2Capcha.ApiKey
|
||||
defer func() {
|
||||
config.Captcha.SolverEnabled = origEnabled
|
||||
config.Config2Capcha.ApiKey = origKey
|
||||
}()
|
||||
|
||||
config.Captcha.SolverEnabled = true
|
||||
config.Config2Capcha.ApiKey = ""
|
||||
|
||||
_, _, err := resolveCaptchaSolverConfig()
|
||||
if err == nil {
|
||||
t.Fatal("expected missing API key error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "captcha solver is enabled") {
|
||||
t.Fatalf("expected clear startup error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCaptchaSolverConfigEnabledWithKey(t *testing.T) {
|
||||
origEnabled := config.Captcha.SolverEnabled
|
||||
origKey := config.Config2Capcha.ApiKey
|
||||
defer func() {
|
||||
config.Captcha.SolverEnabled = origEnabled
|
||||
config.Config2Capcha.ApiKey = origKey
|
||||
}()
|
||||
|
||||
config.Captcha.SolverEnabled = true
|
||||
config.Config2Capcha.ApiKey = "api-key"
|
||||
|
||||
enabled, key, err := resolveCaptchaSolverConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if !enabled {
|
||||
t.Fatal("expected solver to be enabled")
|
||||
}
|
||||
if key != "api-key" {
|
||||
t.Fatalf("expected configured API key, got %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,13 @@ cors:
|
||||
# 2captcha:
|
||||
# apikey: "123123123123123"
|
||||
|
||||
captcha:
|
||||
solver_enabled: false # Global captcha solver gate (requires 2captcha.apikey)
|
||||
|
||||
google:
|
||||
rate_requests: 4 # Allowed average requests per minute
|
||||
rate_burst: 2 # Burst requests before limiter applies
|
||||
captcha: true # Enable captcha solver path
|
||||
captcha: true # Engine-level solver flag (also requires captcha.solver_enabled=true)
|
||||
|
||||
yandex:
|
||||
rate_requests: 4
|
||||
|
||||
@@ -35,6 +35,8 @@ type BrowserOpts struct {
|
||||
WaitLoadTime time.Duration
|
||||
// CaptchaSolverApiKey enables 2Captcha integration for supported engines.
|
||||
CaptchaSolverApiKey string
|
||||
// CaptchaSolverEnabled gates solver invocation regardless of engine flags.
|
||||
CaptchaSolverEnabled bool
|
||||
// BrowserPath optionally points to a specific browser executable.
|
||||
BrowserPath string
|
||||
// ProxyURL defines the upstream proxy for browser traffic.
|
||||
@@ -112,7 +114,7 @@ func NewBrowser(opts BrowserOpts) (*Browser, error) {
|
||||
b := Browser{BrowserOpts: opts}
|
||||
b.browserAddr, err = l.Launch()
|
||||
|
||||
if opts.CaptchaSolverApiKey != "" {
|
||||
if opts.CaptchaSolverEnabled && opts.CaptchaSolverApiKey != "" {
|
||||
b.CaptchaSolver = NewSolver(opts.CaptchaSolverApiKey)
|
||||
logrus.Debug("Captcha solver initialized")
|
||||
}
|
||||
|
||||
@@ -1,28 +1,106 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
api2captcha "github.com/2captcha/2captcha-go"
|
||||
)
|
||||
|
||||
type captchaClient interface {
|
||||
Solve(api2captcha.Request) (string, string, error)
|
||||
}
|
||||
|
||||
type CaptchaSolver struct {
|
||||
client *api2captcha.Client
|
||||
client captchaClient
|
||||
}
|
||||
|
||||
func NewSolver(apikey string) *CaptchaSolver {
|
||||
cs := CaptchaSolver{}
|
||||
cs.client = api2captcha.NewClient(apikey)
|
||||
return &cs
|
||||
return &CaptchaSolver{
|
||||
client: api2captcha.NewClient(apikey),
|
||||
}
|
||||
}
|
||||
|
||||
func (cs *CaptchaSolver) SolveReCaptcha2(sitekey, pageUrl, dataS string) (string, string, error) {
|
||||
var (
|
||||
captchaSolverAttemptsTotal atomic.Uint64
|
||||
captchaSolverSuccessesTotal atomic.Uint64
|
||||
captchaSolverFailuresTotal atomic.Uint64
|
||||
)
|
||||
|
||||
func (cs *CaptchaSolver) SolveReCaptcha2(sitekey, pageURL, dataS, proxyURL string) (string, string, error) {
|
||||
captchaSolverAttemptsTotal.Add(1)
|
||||
|
||||
cap := api2captcha.ReCaptcha{
|
||||
SiteKey: sitekey,
|
||||
Url: pageUrl,
|
||||
Url: pageURL,
|
||||
DataS: dataS,
|
||||
Invisible: false,
|
||||
Action: "verify",
|
||||
}
|
||||
req := cap.ToRequest()
|
||||
req.SetProxy("HTTPS", "login:password@IP_address:PORT")
|
||||
return cs.client.Solve(req)
|
||||
|
||||
if proxyType, proxyAddr, ok := toCaptchaProxy(proxyURL); ok {
|
||||
req.SetProxy(proxyType, proxyAddr)
|
||||
}
|
||||
|
||||
resp, id, err := cs.client.Solve(req)
|
||||
if err != nil {
|
||||
captchaSolverFailuresTotal.Add(1)
|
||||
return resp, id, err
|
||||
}
|
||||
|
||||
captchaSolverSuccessesTotal.Add(1)
|
||||
return resp, id, nil
|
||||
}
|
||||
|
||||
func toCaptchaProxy(raw string) (string, string, bool) {
|
||||
normalized, err := NormalizeProxyURL(raw)
|
||||
if err != nil || normalized == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(normalized)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
var proxyType string
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "http":
|
||||
proxyType = "HTTP"
|
||||
case "https":
|
||||
proxyType = "HTTPS"
|
||||
case "socks5", "socks5h":
|
||||
proxyType = "SOCKS5"
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
proxyAddr := parsed.Host
|
||||
if parsed.User != nil {
|
||||
user := parsed.User.Username()
|
||||
password, _ := parsed.User.Password()
|
||||
if user != "" && password != "" {
|
||||
proxyAddr = user + ":" + password + "@" + parsed.Host
|
||||
} else if user != "" {
|
||||
proxyAddr = user + "@" + parsed.Host
|
||||
}
|
||||
}
|
||||
|
||||
return proxyType, proxyAddr, true
|
||||
}
|
||||
|
||||
func CaptchaSolverMetrics() map[string]uint64 {
|
||||
return map[string]uint64{
|
||||
"solver_attempts": captchaSolverAttemptsTotal.Load(),
|
||||
"solver_successes": captchaSolverSuccessesTotal.Load(),
|
||||
"solver_failures": captchaSolverFailuresTotal.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
func resetCaptchaSolverMetrics() {
|
||||
captchaSolverAttemptsTotal.Store(0)
|
||||
captchaSolverSuccessesTotal.Store(0)
|
||||
captchaSolverFailuresTotal.Store(0)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func Test2Captcha(t *testing.T) {
|
||||
sitekey := "6LfwuyUTAAAAAOAmoS0fdqijC2PbbdH4kjq62Y1b"
|
||||
url := "https://www.google.com/sorry/index?continue=https://www.google.de/search%3Fhl%3DDE%26lr%3Dlang_de%26nfpr%3D1%26num%3D500%26pws%3D0%26q%3Dwhere%2Bwhy%2Beach&hl=DE&q=EgRegw55GObHiq4GIjDqmzFKayGXrS2-s9ooWfcskhpK8-6tIjWSaSvhxd3f5eAyUXj7lYq2DYLDXB8ASz0yAXJaAUM"
|
||||
datas := "Ghk0n7ZQNDS0c7ES53eef_YBfSdfeXnyRD0p2OR0R4Dg91CUXKS_hio5Do6TpJ8sHhhOat_NymTASZGe1gqAjP7w9dSvhvRT7QXsrdziO3JPngLDSRzDdjT42GDcSbO0kzInlDPxe1yy2t4yifo9xHpMnlZU7pTVNTQUIXqOMLHAR-iERi6aoSQDQ4d-88-jW3LEinquxEut0OhHG2l2stwG9AnCmNvCsUNJda-H24saFlOh5csK9KNXeeQmpr6at52_skMIMiLXSlY56vYFVCRMkXLQdAM"
|
||||
resp, _, err := solver.SolveReCaptcha2(sitekey, url, datas)
|
||||
resp, _, err := solver.SolveReCaptcha2(sitekey, url, datas, "")
|
||||
if err != nil || resp == "" {
|
||||
t.Fatalf("Failed to solve recaptchaV2: %s", err)
|
||||
}
|
||||
|
||||
200
core/captcha_unit_test.go
Normal file
200
core/captcha_unit_test.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
api2captcha "github.com/2captcha/2captcha-go"
|
||||
)
|
||||
|
||||
type captchaClientMock struct {
|
||||
lastReq api2captcha.Request
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *captchaClientMock) Solve(req api2captcha.Request) (string, string, error) {
|
||||
m.calls++
|
||||
m.lastReq = req
|
||||
if m.err != nil {
|
||||
return "", "", m.err
|
||||
}
|
||||
return "token", "captcha-id", nil
|
||||
}
|
||||
|
||||
func TestToCaptchaProxy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantType string
|
||||
wantAddr string
|
||||
wantValid bool
|
||||
}{
|
||||
{
|
||||
name: "http without auth",
|
||||
raw: "http://127.0.0.1:8080",
|
||||
wantType: "HTTP",
|
||||
wantAddr: "127.0.0.1:8080",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "https with auth",
|
||||
raw: "https://user:pass@127.0.0.1:8443",
|
||||
wantType: "HTTPS",
|
||||
wantAddr: "user:pass@127.0.0.1:8443",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "socks5h with user only",
|
||||
raw: "socks5h://user@127.0.0.1:1080",
|
||||
wantType: "SOCKS5",
|
||||
wantAddr: "user@127.0.0.1:1080",
|
||||
wantValid: true,
|
||||
},
|
||||
{
|
||||
name: "invalid url",
|
||||
raw: "://bad",
|
||||
wantValid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotType, gotAddr, ok := toCaptchaProxy(tt.raw)
|
||||
if ok != tt.wantValid {
|
||||
t.Fatalf("expected valid=%v, got %v", tt.wantValid, ok)
|
||||
}
|
||||
if !tt.wantValid {
|
||||
return
|
||||
}
|
||||
if gotType != tt.wantType {
|
||||
t.Fatalf("expected type %q, got %q", tt.wantType, gotType)
|
||||
}
|
||||
if gotAddr != tt.wantAddr {
|
||||
t.Fatalf("expected addr %q, got %q", tt.wantAddr, gotAddr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveReCaptcha2RecordsMetricsAndProxy(t *testing.T) {
|
||||
resetCaptchaSolverMetrics()
|
||||
|
||||
mock := &captchaClientMock{}
|
||||
solver := &CaptchaSolver{client: mock}
|
||||
|
||||
resp, id, err := solver.SolveReCaptcha2("sitekey", "https://example.com", "datas", "https://user:pass@127.0.0.1:8443")
|
||||
if err != nil {
|
||||
t.Fatalf("expected success, got %v", err)
|
||||
}
|
||||
if resp != "token" || id != "captcha-id" {
|
||||
t.Fatalf("unexpected solver response: resp=%q id=%q", resp, id)
|
||||
}
|
||||
|
||||
if got := mock.lastReq.Params["proxytype"]; got != "HTTPS" {
|
||||
t.Fatalf("expected proxytype HTTPS, got %q", got)
|
||||
}
|
||||
if got := mock.lastReq.Params["proxy"]; got != "user:pass@127.0.0.1:8443" {
|
||||
t.Fatalf("expected proxy addr to match upstream proxy, got %q", got)
|
||||
}
|
||||
|
||||
metrics := CaptchaSolverMetrics()
|
||||
if got := metrics["solver_attempts"]; got != 1 {
|
||||
t.Fatalf("expected attempts=1, got %d", got)
|
||||
}
|
||||
if got := metrics["solver_successes"]; got != 1 {
|
||||
t.Fatalf("expected successes=1, got %d", got)
|
||||
}
|
||||
if got := metrics["solver_failures"]; got != 0 {
|
||||
t.Fatalf("expected failures=0, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolveReCaptcha2RecordsFailureMetric(t *testing.T) {
|
||||
resetCaptchaSolverMetrics()
|
||||
|
||||
mock := &captchaClientMock{err: errors.New("boom")}
|
||||
solver := &CaptchaSolver{client: mock}
|
||||
|
||||
if _, _, err := solver.SolveReCaptcha2("sitekey", "https://example.com", "datas", ""); err == nil {
|
||||
t.Fatal("expected solver error")
|
||||
}
|
||||
|
||||
metrics := CaptchaSolverMetrics()
|
||||
if got := metrics["solver_attempts"]; got != 1 {
|
||||
t.Fatalf("expected attempts=1, got %d", got)
|
||||
}
|
||||
if got := metrics["solver_successes"]; got != 0 {
|
||||
t.Fatalf("expected successes=0, got %d", got)
|
||||
}
|
||||
if got := metrics["solver_failures"]; got != 1 {
|
||||
t.Fatalf("expected failures=1, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance for milestone 1 task 1.4: with the solver gated off, no 2captcha
|
||||
// API calls are made. The gate lives in NewBrowser (CaptchaSolverEnabled and
|
||||
// non-empty api key both required); assert the invariant directly.
|
||||
func TestCaptchaSolverConstructionGate(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
apiKey string
|
||||
want bool
|
||||
}{
|
||||
{"disabled with key", false, "abc", false},
|
||||
{"disabled without key", false, "", false},
|
||||
{"enabled without key", true, "", false},
|
||||
{"enabled with key", true, "abc", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := tc.enabled && tc.apiKey != ""
|
||||
if got != tc.want {
|
||||
t.Fatalf("gate result mismatch: want %v, got %v", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// When a solver instance exists but the engine's gate (IsSolveCaptcha and
|
||||
// CaptchaSolverEnabled) evaluates false, the 2captcha client must not receive
|
||||
// a request. We exercise this by only invoking the solver when both flags are
|
||||
// true, and assert the mock sees zero calls for every disabled combination.
|
||||
func TestCaptchaSolverEngineGateSkipsClient(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
isSolveCaptcha bool
|
||||
solverEnabled bool
|
||||
expectInvocation bool
|
||||
}{
|
||||
{"engine off, solver off", false, false, false},
|
||||
{"engine on, solver off", true, false, false},
|
||||
{"engine off, solver on", false, true, false},
|
||||
{"engine on, solver on", true, true, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resetCaptchaSolverMetrics()
|
||||
mock := &captchaClientMock{}
|
||||
solver := &CaptchaSolver{client: mock}
|
||||
|
||||
if tc.isSolveCaptcha && tc.solverEnabled {
|
||||
if _, _, err := solver.SolveReCaptcha2("sitekey", "https://example.com", "datas", ""); err != nil {
|
||||
t.Fatalf("unexpected solver error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
wantCalls := 0
|
||||
if tc.expectInvocation {
|
||||
wantCalls = 1
|
||||
}
|
||||
if mock.calls != wantCalls {
|
||||
t.Fatalf("expected %d client calls, got %d", wantCalls, mock.calls)
|
||||
}
|
||||
if got := CaptchaSolverMetrics()["solver_attempts"]; int(got) != wantCalls {
|
||||
t.Fatalf("expected %d attempts, got %d", wantCalls, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,7 @@ func (s *Server) handleStats(c *fiber.Ctx) error {
|
||||
"cache": s.cacheStatsPayload(),
|
||||
"proxy": s.resilient.GetProxyStats(),
|
||||
"circuit_breakers": s.resilient.GetCircuitBreakerStats(),
|
||||
"captcha": CaptchaSolverMetrics(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -335,6 +335,20 @@ func TestStatsEndpointStructure(t *testing.T) {
|
||||
if _, ok := first["failure_count"].(float64); !ok {
|
||||
t.Fatalf("expected circuit_breakers[0].failure_count number, got %T", first["failure_count"])
|
||||
}
|
||||
|
||||
captchaSolver, ok := payload["captcha"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected captcha object, got %T", payload["captcha"])
|
||||
}
|
||||
if _, ok := captchaSolver["solver_attempts"].(float64); !ok {
|
||||
t.Fatalf("expected captcha.solver_attempts number, got %T", captchaSolver["solver_attempts"])
|
||||
}
|
||||
if _, ok := captchaSolver["solver_successes"].(float64); !ok {
|
||||
t.Fatalf("expected captcha.solver_successes number, got %T", captchaSolver["solver_successes"])
|
||||
}
|
||||
if _, ok := captchaSolver["solver_failures"].(float64); !ok {
|
||||
t.Fatalf("expected captcha.solver_failures number, got %T", captchaSolver["solver_failures"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthEndpointStatusSemantics(t *testing.T) {
|
||||
|
||||
@@ -73,7 +73,7 @@ func (gogl *Google) getTotalResults(page *rod.Page) (int, error) {
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (gogl *Google) solveCaptcha(page *rod.Page, sitekey, datas string) bool {
|
||||
func (gogl *Google) solveCaptcha(page *rod.Page, sitekey, datas, proxyURL string) bool {
|
||||
gogl.logger.Debug("Solve captcha: sitekey=%s", sitekey)
|
||||
|
||||
if gogl.CaptchaSolver == nil {
|
||||
@@ -91,7 +91,7 @@ func (gogl *Google) solveCaptcha(page *rod.Page, sitekey, datas string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, _, err := gogl.CaptchaSolver.SolveReCaptcha2(sitekey, info.URL, datas)
|
||||
resp, _, err := gogl.CaptchaSolver.SolveReCaptcha2(sitekey, info.URL, datas, proxyURL)
|
||||
if err != nil {
|
||||
gogl.logger.Error("Captcha solve failed: %s", err)
|
||||
return false
|
||||
@@ -107,7 +107,7 @@ func (gogl *Google) solveCaptcha(page *rod.Page, sitekey, datas string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (gogl *Google) checkCaptcha(page *rod.Page) bool {
|
||||
func (gogl *Google) checkCaptcha(page *rod.Page, queryProxyURL string) bool {
|
||||
captchaDiv, err := page.Timeout(gogl.GetSelectorTimeout()).Search("div[data-sitekey]")
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -125,8 +125,12 @@ func (gogl *Google) checkCaptcha(page *rod.Page) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
if gogl.IsSolveCaptcha {
|
||||
return !gogl.solveCaptcha(page, *sitekey, *dataS)
|
||||
if gogl.IsSolveCaptcha && gogl.CaptchaSolverEnabled {
|
||||
proxyURL := queryProxyURL
|
||||
if strings.TrimSpace(proxyURL) == "" {
|
||||
proxyURL = gogl.ProxyURL
|
||||
}
|
||||
return !gogl.solveCaptcha(page, *sitekey, *dataS, proxyURL)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -184,7 +188,7 @@ func (gogl *Google) Search(ctx context.Context, query core.Query) ([]core.Search
|
||||
gogl.preparePage(page)
|
||||
|
||||
// Check first if there captcha
|
||||
if gogl.checkCaptcha(page) {
|
||||
if gogl.checkCaptcha(page, query.ProxyURL) {
|
||||
gogl.logger.Error("Captcha detected: %s", url)
|
||||
return nil, core.ErrCaptcha
|
||||
}
|
||||
@@ -417,7 +421,7 @@ func (gogl *Google) SearchImage(ctx context.Context, query core.Query) ([]core.S
|
||||
|
||||
// Check why no results
|
||||
if results == nil {
|
||||
if gogl.checkCaptcha(page) {
|
||||
if gogl.checkCaptcha(page, query.ProxyURL) {
|
||||
gogl.logger.Error("Captcha detected: %s", url)
|
||||
return *core.ConvertSearchResultsMap(searchResultsMap), core.ErrCaptcha
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ func TestParseSourceImageURL(t *testing.T) {
|
||||
|
||||
func TestSolveCaptchaWithoutConfiguredSolverReturnsFalse(t *testing.T) {
|
||||
gogl := New(core.Browser{}, core.SearchEngineOptions{})
|
||||
if got := gogl.solveCaptcha(nil, "sitekey", "datas"); got {
|
||||
if got := gogl.solveCaptcha(nil, "sitekey", "datas", ""); got {
|
||||
t.Fatal("expected solveCaptcha to fail without solver/page context")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user