fix(captcha): exit non-zero on misconfig, tighten solver gate tests

This commit is contained in:
Rustem Kamalov
2026-04-20 20:12:19 +03:00
parent 1842c4bb07
commit 08edec5779
14 changed files with 453 additions and 39 deletions

View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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
View 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)
}
})
}
}

View File

@@ -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(),
})
}

View File

@@ -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) {