captchaforge 0.2.40

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
//! End-to-end solve-loop validation for the dedicated vendor solvers.
//!
//! The per-vendor unit tests cover the *pure* classifiers. These tests cover the
//! other half the Testing Contract requires, the probe-JS ↔ solver pair, by
//! serving a local page that reproduces the exact DOM / cookie / window state
//! each vendor's clearance flow leaves behind, driving a real headless Firefox,
//! and asserting the solver's `solve()` reaches the right terminal outcome
//! (success / hard-block failure / interactive yield). This catches probe-JS
//! bugs (selector typos, cookie-parsing slips, phase mislabelling) that a pure
//! classifier test cannot.
//!
//! A real HTTP origin is required: `about:blank` has an opaque origin where
//! `document.cookie` writes are dropped, so the cookie-reading probes would see
//! nothing. `common::serve_once` gives each test a real `http://127.0.0.1:port`.

use captchaforge::detect::{CaptchaInfo, DetectedCaptcha};
use captchaforge::solver::{
    CaptchaSolver, CloudflareInterstitialSolver, DataDomeSolver, ImpervaSolver, KasadaSolver,
};

mod common;

fn info(kind: DetectedCaptcha, url: &str) -> CaptchaInfo {
    CaptchaInfo {
        kind,
        site_key: None,
        page_url: url.to_string(),
        container_selector: None,
    }
}

/// Serve `body_html` at a fresh local origin, navigate a real browser to it, and
/// return the live page wrapped in its `TestBrowser` (kept alive by the caller).
async fn page_with(body_html: &str) -> (common::TestBrowser, String) {
    let html = format!(
        "<!doctype html><html><head><title>t</title></head><body>{body_html}</body></html>"
    );
    let addr = common::serve_once(html).await;
    let url = format!("http://{addr}/");
    let browser = common::launch_browser().await;
    browser
        .new_page(&url)
        .await
        .expect("navigate to mock origin");
    (browser, url)
}

#[tokio::test]
async fn datadome_valid_cookie_solves() {
    // Long base64-ish datadome cookie ⇒ DataDomeCookie::Valid ⇒ success.
    let (browser, url) = page_with(
        r#"<script>document.cookie = 'datadome=' + 'A1b2C3d4'.repeat(16);</script>Loaded"#,
    )
    .await;
    let solver = DataDomeSolver::new().with_max_wait_ms(5_000);
    let res = solver
        .solve(
            &browser,
            &info(DetectedCaptcha::Custom("datadome".into()), &url),
        )
        .await
        .unwrap();
    assert!(res.success, "valid datadome cookie should solve");
    assert_eq!(res.solution, "datadome:cookie");
    browser.close().await.unwrap();
}

#[tokio::test]
async fn datadome_slider_iframe_yields() {
    // captcha-delivery.com iframe present, no cookie ⇒ yield to slider
    // (success=false, screenshot captured for the fallback).
    let (browser, url) =
        page_with(r#"<iframe src="https://geo.captcha-delivery.com/captcha/"></iframe>"#).await;
    let solver = DataDomeSolver::new().with_max_wait_ms(3_000);
    let res = solver
        .solve(
            &browser,
            &info(DetectedCaptcha::Custom("datadome".into()), &url),
        )
        .await
        .unwrap();
    assert!(
        !res.success,
        "slider variant must yield, not falsely succeed"
    );
    browser.close().await.unwrap();
}

#[tokio::test]
async fn imperva_reese84_cookie_solves() {
    let (browser, url) =
        page_with(r#"<script>document.cookie = 'reese84=' + 'r'.repeat(90);</script>Loaded"#).await;
    let solver = ImpervaSolver::new().with_max_wait_ms(5_000);
    let res = solver
        .solve(
            &browser,
            &info(DetectedCaptcha::Custom("imperva_incapsula".into()), &url),
        )
        .await
        .unwrap();
    assert!(res.success, "valid reese84 token should solve");
    assert_eq!(res.solution, "imperva:cookie");
    browser.close().await.unwrap();
}

#[tokio::test]
async fn imperva_incident_page_is_blocked() {
    // The classic Incapsula block body ⇒ hard failure (no screenshot/yield).
    let (browser, url) =
        page_with(r#"Request unsuccessful. Incapsula incident ID: 1234-5678901234567890"#).await;
    let solver = ImpervaSolver::new().with_max_wait_ms(2_000);
    let res = solver
        .solve(
            &browser,
            &info(DetectedCaptcha::Custom("imperva_incapsula".into()), &url),
        )
        .await
        .unwrap();
    assert!(!res.success, "incident page must not be reported as solved");
    assert!(
        res.screenshot.is_none(),
        "a hard block is a failure(), not a yield"
    );
    browser.close().await.unwrap();
}

#[tokio::test]
async fn kasada_token_and_kpsdk_solves() {
    // KP_UIDz long-form + KPSDK initialised + no ips.js interruption ⇒ success.
    let (browser, url) = page_with(
        r#"<script>window.KPSDK = { configured: true };
           document.cookie = 'KP_UIDz=' + 'k'.repeat(40);</script>Loaded"#,
    )
    .await;
    let solver = KasadaSolver::new().with_max_wait_ms(5_000);
    let res = solver
        .solve(
            &browser,
            &info(DetectedCaptcha::Custom("kasada".into()), &url),
        )
        .await
        .unwrap();
    assert!(res.success, "kasada clearance should solve");
    assert_eq!(res.solution, "kasada:cookie");
    browser.close().await.unwrap();
}

#[tokio::test]
async fn kasada_interruption_present_does_not_falsely_solve() {
    // ips.js interruption still active (KPSDK not ready) ⇒ no false success.
    let (browser, url) = page_with(
        r#"<script src="/ips.js"></script>
           <script>document.cookie = 'KP_UIDz=' + 'k'.repeat(40);</script>"#,
    )
    .await;
    let solver = KasadaSolver::new().with_max_wait_ms(1_500);
    let res = solver
        .solve(
            &browser,
            &info(DetectedCaptcha::Custom("kasada".into()), &url),
        )
        .await
        .unwrap();
    assert!(
        !res.success,
        "active interruption must not be reported as solved"
    );
    browser.close().await.unwrap();
}

#[tokio::test]
async fn cloudflare_clearance_cookie_solves() {
    let (browser, url) =
        page_with(r#"<script>document.cookie = 'cf_clearance=' + 'c'.repeat(48);</script>Loaded"#)
            .await;
    let solver = CloudflareInterstitialSolver::new().with_max_wait_ms(5_000);
    let res = solver
        .solve(&browser, &info(DetectedCaptcha::Turnstile, &url))
        .await
        .unwrap();
    assert!(res.success, "valid cf_clearance should solve");
    assert_eq!(res.solution, "cf_clearance");
    browser.close().await.unwrap();
}

#[tokio::test]
async fn cloudflare_error_1020_is_blocked() {
    let (browser, url) =
        page_with(r#"<h1>Sorry, you have been blocked</h1><p>Error 1020</p>"#).await;
    let solver = CloudflareInterstitialSolver::new().with_max_wait_ms(2_000);
    let res = solver
        .solve(&browser, &info(DetectedCaptcha::Turnstile, &url))
        .await
        .unwrap();
    assert!(!res.success, "error 1020 block must fail");
    assert!(
        res.screenshot.is_none(),
        "a hard block is a failure(), not a yield"
    );
    browser.close().await.unwrap();
}

#[tokio::test]
async fn cloudflare_managed_turnstile_yields_interactive() {
    // _cf_chl_opt + an embedded challenges.cloudflare.com widget ⇒ interactive
    // yield (success=false WITH a screenshot) so the chain hands off.
    let (browser, url) = page_with(
        r#"<script>window._cf_chl_opt = { cType: 'managed' };</script>
           <iframe src="https://challenges.cloudflare.com/cdn-cgi/challenge-platform/x"></iframe>"#,
    )
    .await;
    let solver = CloudflareInterstitialSolver::new().with_max_wait_ms(2_000);
    let res = solver
        .solve(&browser, &info(DetectedCaptcha::Turnstile, &url))
        .await
        .unwrap();
    assert!(
        !res.success,
        "interactive managed challenge must yield, not auto-pass"
    );
    assert!(
        res.screenshot.is_some(),
        "interactive yield must capture a screenshot for the hand-off"
    );
    browser.close().await.unwrap();
}