captchaforge 0.2.39

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
use super::*;

#[test]
fn service_base_urls() {
    assert_eq!(
        ThirdPartyService::TwoCaptcha.base_url(),
        "https://2captcha.com"
    );
    assert_eq!(
        ThirdPartyService::CapMonster.base_url(),
        "https://api.capmonster.cloud"
    );
    assert_eq!(
        ThirdPartyService::CapSolver.base_url(),
        "https://api.capsolver.com"
    );
    assert_eq!(
        ThirdPartyService::Custom {
            base_url: "https://my.gw".into()
        }
        .base_url(),
        "https://my.gw"
    );
}

#[test]
fn no_api_key_means_no_support() {
    // Avoid the env override; construct directly.
    let s = ThirdPartyCaptchaSolver {
        client: reqwest::Client::new(),
        service: ThirdPartyService::TwoCaptcha,
        api_key: None,
        poll_interval_ms: 1,
        max_polls: 1,
        key_pool: None,
    };
    assert!(!s.has_api_key());
    assert!(!s.supports(&DetectedCaptcha::Turnstile));
    assert!(!s.supports(&DetectedCaptcha::RecaptchaV2));
    assert!(!s.supports(&DetectedCaptcha::HCaptcha));
}

#[test]
fn with_api_key_enables_support_for_known_kinds() {
    let s = ThirdPartyCaptchaSolver {
        client: reqwest::Client::new(),
        service: ThirdPartyService::TwoCaptcha,
        api_key: Some("test-key".into()),
        poll_interval_ms: 1,
        max_polls: 1,
        key_pool: None,
    };
    assert!(s.supports(&DetectedCaptcha::Turnstile));
    assert!(s.supports(&DetectedCaptcha::RecaptchaV2));
    assert!(s.supports(&DetectedCaptcha::RecaptchaV3));
    assert!(s.supports(&DetectedCaptcha::HCaptcha));
    assert!(s.supports(&DetectedCaptcha::Custom("datadome".into())));
    assert!(s.supports(&DetectedCaptcha::Custom("arkose_funcaptcha".into())));
    assert!(s.supports(&DetectedCaptcha::Custom("geetest_v3".into())));
    assert!(s.supports(&DetectedCaptcha::Custom("geetest_v4".into())));
}

#[test]
fn with_api_key_does_not_support_unknown_custom_or_unsupported_kinds() {
    let s = ThirdPartyCaptchaSolver {
        client: reqwest::Client::new(),
        service: ThirdPartyService::TwoCaptcha,
        api_key: Some("k".into()),
        poll_interval_ms: 1,
        max_polls: 1,
        key_pool: None,
    };
    assert!(!s.supports(&DetectedCaptcha::Custom("never_heard_of_it".into())));
    assert!(!s.supports(&DetectedCaptcha::PowCaptcha));
    assert!(!s.supports(&DetectedCaptcha::SliderCaptcha));
    assert!(!s.supports(&DetectedCaptcha::CanvasCaptcha));
    assert!(!s.supports(&DetectedCaptcha::None));
    // aws_waf is in the supports() list but build_task_params returns
    // None for it (needs iv+context), so supports() correctly excludes it.
    assert!(!s.supports(&DetectedCaptcha::Custom("aws_waf_captcha".into())));
}

fn info(kind: DetectedCaptcha, sitekey: Option<&str>) -> CaptchaInfo {
    CaptchaInfo {
        kind,
        site_key: sitekey.map(String::from),
        page_url: "https://example.com/login".into(),
        container_selector: None,
    }
}

#[test]
fn task_params_turnstile_uses_method_turnstile_with_sitekey() {
    let p = ThirdPartyCaptchaSolver::build_task_params(&info(
        DetectedCaptcha::Turnstile,
        Some("0x4AAAAAAA-test"),
    ))
    .unwrap();
    assert!(p.iter().any(|(k, v)| *k == "method" && v == "turnstile"));
    assert!(p
        .iter()
        .any(|(k, v)| *k == "sitekey" && v == "0x4AAAAAAA-test"));
    assert!(p.iter().any(|(k, _)| *k == "pageurl"));
}

#[test]
fn task_params_recaptcha_v2_uses_googlekey() {
    let p = ThirdPartyCaptchaSolver::build_task_params(&info(
        DetectedCaptcha::RecaptchaV2,
        Some("6Lc-test"),
    ))
    .unwrap();
    assert!(p
        .iter()
        .any(|(k, v)| *k == "method" && v == "userrecaptcha"));
    assert!(p.iter().any(|(k, v)| *k == "googlekey" && v == "6Lc-test"));
}

#[test]
fn task_params_recaptcha_v3_includes_version() {
    let p = ThirdPartyCaptchaSolver::build_task_params(&info(
        DetectedCaptcha::RecaptchaV3,
        Some("6Lc-v3-test"),
    ))
    .unwrap();
    assert!(p.iter().any(|(k, v)| *k == "version" && v == "v3"));
    assert!(p.iter().any(|(k, _)| *k == "min_score"));
}

#[test]
fn task_params_hcaptcha_uses_hcaptcha_method() {
    let p =
        ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::HCaptcha, Some("hsk")))
            .unwrap();
    assert!(p.iter().any(|(k, v)| *k == "method" && v == "hcaptcha"));
}

#[test]
fn task_params_datadome_works_without_sitekey() {
    let mut info = info(DetectedCaptcha::Custom("datadome".into()), None);
    info.container_selector = Some("https://geo.captcha-delivery.com/captcha?...".into());
    let p = ThirdPartyCaptchaSolver::build_task_params(&info).unwrap();
    assert!(p.iter().any(|(k, v)| *k == "method" && v == "datadome"));
    assert!(p.iter().any(|(k, _)| *k == "captcha_url"));
}

#[test]
fn task_params_funcaptcha_uses_publickey() {
    let p = ThirdPartyCaptchaSolver::build_task_params(&info(
        DetectedCaptcha::Custom("arkose_funcaptcha".into()),
        Some("AB12CD34"),
    ))
    .unwrap();
    assert!(p.iter().any(|(k, v)| *k == "method" && v == "funcaptcha"));
    assert!(p.iter().any(|(k, v)| *k == "publickey" && v == "AB12CD34"));
}

#[test]
fn task_params_returns_none_for_kinds_without_a_method() {
    assert!(
        ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::PowCaptcha, None))
            .is_none()
    );
    assert!(ThirdPartyCaptchaSolver::build_task_params(&info(
        DetectedCaptcha::SliderCaptcha,
        None
    ))
    .is_none());
    assert!(
        ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::None, None)).is_none()
    );
    // unknown Custom name
    assert!(ThirdPartyCaptchaSolver::build_task_params(&info(
        DetectedCaptcha::Custom("zomg_unknown".into()),
        Some("k")
    ))
    .is_none());
}

#[test]
fn task_params_returns_none_when_required_sitekey_missing() {
    // Turnstile / reCAPTCHA / hCaptcha all need a sitekey; without one
    // the third-party service can't construct a task.
    assert!(
        ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::Turnstile, None))
            .is_none()
    );
    assert!(
        ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::RecaptchaV2, None))
            .is_none()
    );
    assert!(
        ThirdPartyCaptchaSolver::build_task_params(&info(DetectedCaptcha::HCaptcha, None))
            .is_none()
    );
}

#[test]
fn builders_set_fields() {
    let s = ThirdPartyCaptchaSolver::two_captcha()
        .with_api_key("k123")
        .with_poll_interval_ms(2000)
        .with_max_polls(10);
    assert_eq!(s.api_key.as_deref(), Some("k123"));
    assert_eq!(s.poll_interval_ms, 2000);
    assert_eq!(s.max_polls, 10);
}

#[tokio::test]
async fn retry_succeeds_first_try_on_immediate_ok() {
    let attempts = std::sync::atomic::AtomicU32::new(0);
    let r: Result<i32> = retry_transient("test", || async {
        attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        Ok::<_, RetryError>(42)
    })
    .await;
    assert_eq!(r.unwrap(), 42);
    assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
}

#[tokio::test]
async fn retry_recovers_after_transient_failure() {
    let attempts = std::sync::atomic::AtomicU32::new(0);
    let r: Result<&'static str> = retry_transient("test", || async {
        let n = attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        if n == 0 {
            Err(RetryError::Transient(anyhow!("blip")))
        } else {
            Ok::<_, RetryError>("recovered")
        }
    })
    .await;
    assert_eq!(r.unwrap(), "recovered");
    assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2);
}

#[tokio::test]
async fn retry_exhausts_after_all_transient_failures() {
    let attempts = std::sync::atomic::AtomicU32::new(0);
    let r: Result<()> = retry_transient("test", || async {
        attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        Err(RetryError::Transient(anyhow!("always fails")))
    })
    .await;
    assert!(r.is_err());
    assert_eq!(
        attempts.load(std::sync::atomic::Ordering::SeqCst),
        TRANSIENT_RETRY_ATTEMPTS
    );
}

#[tokio::test]
async fn retry_short_circuits_on_terminal_error() {
    let attempts = std::sync::atomic::AtomicU32::new(0);
    let r: Result<()> = retry_transient("test", || async {
        attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
        Err(RetryError::Terminal(anyhow!("bad key")))
    })
    .await;
    assert!(r.is_err());
    // Terminal error must not be retried (exactly one attempt).
    assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1);
}

#[test]
fn terminal_error_codes_match_2captcha_docs() {
    // Sample of codes we should fail-fast on.
    for code in [
        "ERROR_KEY_DOES_NOT_EXIST",
        "ERROR_ZERO_BALANCE",
        "ERROR_WRONG_USER_KEY",
        "IP_BANNED",
        "ERROR_CAPTCHA_UNSOLVABLE",
        "ERROR_GOOGLEKEY",
    ] {
        assert!(is_terminal_error(code), "should be terminal: {code}");
    }
}

#[test]
fn capcha_not_ready_is_not_terminal() {
    // The polling-still-OK signal must NOT short-circuit.
    assert!(!is_terminal_error("CAPCHA_NOT_READY"));
    assert!(!is_terminal_error(""));
    assert!(!is_terminal_error("UNKNOWN_CODE_FROM_NEW_VENDOR"));
}

#[test]
fn method_and_name_are_stable() {
    let s = ThirdPartyCaptchaSolver {
        client: reqwest::Client::new(),
        service: ThirdPartyService::TwoCaptcha,
        api_key: Some("k".into()),
        poll_interval_ms: 1,
        max_polls: 1,
        key_pool: None,
    };
    assert_eq!(s.name(), "ThirdPartyCaptchaSolver");
    assert_eq!(s.method(), SolveMethod::ThirdPartyService);
}

#[test]
fn key_pool_round_robins_in_order() {
    let pool = KeyPool::new(vec!["a".into(), "b".into(), "c".into()]);
    // current() doesn't advance.
    assert_eq!(pool.current(), "a");
    assert_eq!(pool.current(), "a");
    // advance() steps + wraps.
    assert_eq!(pool.advance(), "b");
    assert_eq!(pool.advance(), "c");
    assert_eq!(pool.advance(), "a");
    assert_eq!(pool.advance(), "b");
}

#[test]
fn empty_key_pool_returns_empty_string() {
    let pool = KeyPool::new(vec![]);
    assert_eq!(pool.current(), "");
    assert_eq!(pool.advance(), "");
}

#[test]
fn with_api_key_pool_seeds_first_key_and_supports_rotation() {
    let mut s = ThirdPartyCaptchaSolver::two_captcha().with_api_key_pool([
        "k1".to_string(),
        "k2".into(),
        "k3".into(),
    ]);
    assert_eq!(s.api_key.as_deref(), Some("k1"));
    let next = s.rotate_api_key().expect("pool exists");
    assert_eq!(next, "k2");
    assert_eq!(s.api_key.as_deref(), Some("k2"));
    assert_eq!(s.rotate_api_key().unwrap(), "k3");
    assert_eq!(s.rotate_api_key().unwrap(), "k1"); // wraps
}

#[test]
fn empty_pool_disables_rotation_entirely() {
    let v: Vec<String> = vec![];
    let mut s = ThirdPartyCaptchaSolver::two_captcha().with_api_key_pool(v);
    // Pool was empty, so no rotation possible (falls back to env).
    assert!(s.rotate_api_key().is_none());
}

#[test]
fn with_api_key_clears_any_pool() {
    let mut s = ThirdPartyCaptchaSolver::two_captcha()
        .with_api_key_pool(["k1".to_string(), "k2".into()])
        .with_api_key("override");
    assert_eq!(s.api_key.as_deref(), Some("override"));
    assert!(s.rotate_api_key().is_none(), "with_api_key must clear pool");
}