mirror of
https://github.com/karust/openserp.git
synced 2026-08-17 05:46:07 +08:00
fix(captcha): exit non-zero on misconfig, tighten solver gate tests
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user