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
//! Truth tests for the `BehavioralCaptchaSolver` same-origin grid pre-pass:
//! assert it (a) acts on the CORRECT tiles and (b) delivers TRUSTED clicks
//! (`isTrusted === true`) to both the tiles and the verify button.
//!
//! These cover a previously shape-only-tested path (the old tests only checked
//! "solve() runs") and pin the contract that a same-origin custom/in-house
//! captcha which gates on `isTrusted` is actually solvable, not just clicked
//! with a synthetic event the page can reject.
//!
//! Live: requires Firefox (via `common::launch_browser`), same harness as the
//! other integration tests.

use captchaforge::captcha_detect::{CaptchaInfo, DetectedCaptcha};
use captchaforge::solver::{BehavioralCaptchaSolver, CaptchaSolver};

mod common;

/// Read `window.__clicks` = `[{id, trusted}]` recorded by a fixture's tile
/// listeners.
async fn clicks(page: &captchaforge::Page) -> Vec<(String, bool)> {
    let raw = page
        .evaluate("JSON.stringify(window.__clicks || [])")
        .await
        .ok()
        .and_then(|e| e.into_value::<String>().ok())
        .unwrap_or_default();
    serde_json::from_str::<Vec<serde_json::Value>>(&raw)
        .unwrap_or_default()
        .into_iter()
        .filter_map(|v| {
            Some((
                v["id"].as_str()?.to_string(),
                v["trusted"].as_bool().unwrap_or(false),
            ))
        })
        .collect()
}

/// Standard tile-recorder script appended to each fixture: records each tile
/// click `{id, trusted}` and the verify button's `isTrusted`.
const RECORDER: &str = r#"<script>
window.__clicks = [];
window.__verifyTrusted = null;
document.querySelectorAll('.cell, .icon-item, .color-item').forEach(function(el){
  el.addEventListener('click', function(e){ window.__clicks.push({ id: el.id, trusted: e.isTrusted }); });
});
var vb = document.querySelector('#recaptcha-verify-button, #submit');
if (vb) vb.addEventListener('click', function(e){ window.__verifyTrusted = e.isTrusted; });
</script>"#;

/// Drive the behavioral pre-pass against `body_html`, then assert the `want`
/// tiles were clicked TRUSTED, the `never` tiles were not clicked, and the
/// verify button click was trusted.
async fn assert_grid_trusted(body_html: &str, want: &[&str], never: &[&str]) {
    let html = format!(
        "<!doctype html><html><head><meta charset=\"utf-8\"><title>grid</title></head><body>{body_html}{RECORDER}</body></html>"
    );
    let browser = common::launch_browser().await;
    let addr = common::serve_once(html).await;
    let url = format!("http://{addr}/");
    let page = browser.new_page(&url).await.expect("navigate");

    let info = CaptchaInfo {
        kind: DetectedCaptcha::ImageCaptcha,
        site_key: None,
        page_url: url.clone(),
        container_selector: Some("#widget".into()),
    };
    let _ = BehavioralCaptchaSolver::new().solve(page, &info).await;

    let recorded = clicks(page).await;
    for w in want {
        assert!(
            recorded.iter().any(|(id, _)| id == w),
            "tile {w} must be clicked; recorded={recorded:?}"
        );
    }
    for n in never {
        assert!(
            !recorded.iter().any(|(id, _)| id == n),
            "non-matching tile {n} must NOT be clicked; recorded={recorded:?}"
        );
    }
    for (id, trusted) in &recorded {
        assert!(
            *trusted,
            "tile {id} click must be isTrusted === true (a same-origin captcha can \
             gate on it); recorded={recorded:?}"
        );
    }
    let verify_trusted = page
        .evaluate("window.__verifyTrusted")
        .await
        .ok()
        .and_then(|e| e.into_value::<Option<bool>>().ok())
        .flatten();
    assert_eq!(
        verify_trusted,
        Some(true),
        "verify button click must be isTrusted === true"
    );

    let _ = browser.close().await;
}

fn cell(id: &str, emoji: &str) -> String {
    format!(
        r#"<div class="cell" id="{id}" style="display:inline-block;width:64px;height:64px">{emoji}</div>"#
    )
}
fn icon(id: &str, emoji: &str) -> String {
    format!(
        r#"<div class="icon-item" id="{id}" style="display:inline-block;width:64px;height:64px">{emoji}</div>"#
    )
}
fn color(id: &str, rgb: &str) -> String {
    format!(
        r#"<div class="color-item" id="{id}" style="display:inline-block;width:64px;height:64px;background-color:{rgb}"></div>"#
    )
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn behavioral_image_grid_clicks_correct_tiles_trusted() {
    let cells = [
        cell("c0", "🚦"),
        cell("c1", "🌳"),
        cell("c2", "🚦"),
        cell("c3", "🍎"),
        cell("c4", "🚦"),
        cell("c5", "🐶"),
        cell("c6", "🚗"),
        cell("c7", "🏠"),
        cell("c8", "🚦"),
    ]
    .concat();
    let body = format!(
        r#"<div id="widget"><div class="prompt">Select all traffic lights</div>
        <div class="grid">{cells}</div>
        <button id="recaptcha-verify-button">Verify</button></div>"#
    );
    assert_grid_trusted(
        &body,
        &["c0", "c2", "c4", "c8"],
        &["c1", "c3", "c5", "c6", "c7"],
    )
    .await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn behavioral_icon_grid_clicks_correct_tiles_trusted() {
    let icons = [
        icon("i0", "🚗"),
        icon("i1", "🐶"),
        icon("i2", "🚌"),
        icon("i3", "🍎"),
        icon("i4", "🏍"),
        icon("i5", "🌳"),
    ]
    .concat();
    let body = format!(
        r#"<div id="widget"><div class="prompt">Select all vehicles</div>
        <div class="grid">{icons}</div>
        <button id="submit">Verify</button></div>"#
    );
    assert_grid_trusted(&body, &["i0", "i2", "i4"], &["i1", "i3", "i5"]).await;
}

#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn behavioral_color_grid_clicks_correct_tiles_trusted() {
    let tiles = [
        color("k0", "rgb(220,20,20)"),  // red
        color("k1", "rgb(20,120,220)"), // blue
        color("k2", "rgb(220,30,30)"),  // red
        color("k3", "rgb(30,160,40)"),  // green
        color("k4", "rgb(200,20,20)"),  // red
        color("k5", "rgb(240,220,40)"), // yellow
    ]
    .concat();
    let body = format!(
        r#"<div id="widget"><div class="prompt">Select all red tiles</div>
        <div class="grid">{tiles}</div>
        <button id="submit">Verify</button></div>"#
    );
    assert_grid_trusted(&body, &["k0", "k2", "k4"], &["k1", "k3", "k5"]).await;
}

/// A captcha-related native checkbox must receive a TRUSTED click. Regression for
/// the prior untrusted path: the solver used to `cb.checked = true` +
/// `cb.dispatchEvent(new Event('click'))` (isTrusted === false), and worse, the
/// programmatic check removed the box from the `:not(:checked)` set so the trusted
/// grid pre-pass never fired either. Now the per-frame pre-pass locates the box in
/// its own frame and delivers a trusted `click_at_in`. The fixture gates on
/// `isTrusted`, so only a real click satisfies it.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn behavioral_captcha_checkbox_click_is_trusted() {
    if which::which("firefox").is_err() {
        eprintln!("SKIP behavioral_captcha_checkbox_click_is_trusted: firefox not on PATH");
        return;
    }
    let html = r#"<!doctype html><html><head><meta charset="utf-8"><title>cb</title></head><body>
<div id="widget" class="captcha">
  <p>Confirm you are human</p>
  <input type="checkbox" id="human-cb">
</div>
<script>
  window.__cbTrusted = null;
  document.getElementById('human-cb').addEventListener('click', function(e){
    window.__cbTrusted = e.isTrusted;
    if (e.isTrusted) { document.title = 'verified'; }
  });
</script></body></html>"#;

    let browser = common::launch_browser().await;
    let addr = common::serve_once(html.to_string()).await;
    let url = format!("http://{addr}/");
    let page = browser.new_page(&url).await.expect("navigate");

    // NEGATIVE control: a synthetic JS dispatchEvent click must arrive untrusted 
    // proving the gate is real and that the old untrusted pre-pass never satisfied
    // it. (dispatchEvent does not perform the checkbox's default toggle, so the box
    // stays `:not(:checked)` for the solver's real click below.)
    let untrusted = page
        .evaluate(
            "(() => { document.getElementById('human-cb').dispatchEvent(new Event('click',{bubbles:true})); return window.__cbTrusted; })()",
        )
        .await
        .expect("control eval")
        .into_value::<Option<bool>>()
        .ok()
        .flatten();
    assert_eq!(
        untrusted,
        Some(false),
        "fixture sanity: a synthetic dispatchEvent click must be isTrusted === false"
    );
    let _ = page.evaluate("window.__cbTrusted = null; void 0").await;

    let info = CaptchaInfo {
        kind: DetectedCaptcha::CanvasCaptcha,
        site_key: None,
        page_url: url.clone(),
        container_selector: Some("#widget".into()),
    };
    let _ = BehavioralCaptchaSolver::new().solve(page, &info).await;

    let cb_trusted = page
        .evaluate("window.__cbTrusted")
        .await
        .ok()
        .and_then(|e| e.into_value::<Option<bool>>().ok())
        .flatten();
    let title = page
        .evaluate("document.title")
        .await
        .ok()
        .and_then(|e| e.into_value::<String>().ok())
        .unwrap_or_default();
    let _ = browser.close().await;

    assert_eq!(
        cb_trusted,
        Some(true),
        "the behavioral solver must deliver a TRUSTED click to the captcha checkbox \
         (got {cb_trusted:?}); a synthetic dispatchEvent would be isTrusted === false"
    );
    assert_eq!(
        title, "verified",
        "a trusted checkbox click must satisfy the isTrusted-gated widget; got title={title:?}"
    );
}