captchaforge 0.2.27

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Integration test: every TOML rule in the bundled community pack
//! must (a) FIRE on its known-positive HTML fixture and (b) NOT FIRE
//! on its known-negative HTML fixture.
//!
//! Approach
//!
//! The runtime detector evaluates a generated JS probe against a live
//! `chromiumoxide::Page`. We don't have a live page in unit tests, so
//! we re-implement the trigger semantics in Rust against a parsed
//! HTML document via [`scraper`]. The two implementations are
//! intentionally NOT shared — keeping them parallel surfaces drift:
//! if `build_probe_js` and the Rust harness disagree, this test
//! fails AND the production probe is wrong AND vice versa.
//!
//! `window_globals` triggers are not evaluated here because we don't
//! execute JS — fixtures that depend on a window global to fire
//! exclusively will be marked positive only when a selector or
//! script-src trigger also matches. This is a known limitation,
//! documented per fixture; see `EXPECTED_GLOBAL_ONLY` below for the
//! list of vendors where the negative test is conservative.
use std::path::PathBuf;

use captchaforge::detect::rules::{built_in_rules, ProviderRule};
use scraper::{Html, Selector};

fn fixture_path(vendor: &str, kind: &str) -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("rule_fixtures")
        .join(vendor)
        .join(format!("{kind}.html"))
}

fn read_fixture(vendor: &str, kind: &str) -> String {
    let path = fixture_path(vendor, kind);
    std::fs::read_to_string(&path)
        .unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()))
}

/// True iff at least one CSS selector OR script-src substring trigger
/// of the rule matches the parsed HTML. window_globals triggers are
/// not evaluated here (no JS engine in tests); see module doc.
fn rule_matches(rule: &ProviderRule, html: &str) -> bool {
    let doc = Html::parse_document(html);
    for sel_str in &rule.triggers.selectors {
        let Ok(sel) = Selector::parse(sel_str) else {
            panic!(
                "rule `{}` ships invalid CSS selector `{}` — fix the rule",
                rule.name, sel_str,
            );
        };
        if doc.select(&sel).next().is_some() {
            return true;
        }
    }
    if !rule.triggers.script_src_contains.is_empty() {
        let script_sel = Selector::parse("script[src]").unwrap();
        for script in doc.select(&script_sel) {
            let Some(src) = script.value().attr("src") else {
                continue;
            };
            for needle in &rule.triggers.script_src_contains {
                if src.contains(needle) {
                    return true;
                }
            }
        }
    }
    false
}

#[test]
fn every_community_rule_has_positive_and_negative_fixtures() {
    let set = built_in_rules().expect("bundled rules parse");
    for rule in &set.providers {
        let pos = fixture_path(&rule.name, "positive");
        let neg = fixture_path(&rule.name, "negative");
        assert!(
            pos.exists(),
            "missing positive fixture for `{}` at {}",
            rule.name,
            pos.display()
        );
        assert!(
            neg.exists(),
            "missing negative fixture for `{}` at {}",
            rule.name,
            neg.display()
        );
    }
}

#[test]
fn every_rule_fires_on_its_positive_fixture() {
    let set = built_in_rules().expect("bundled rules parse");
    let mut misses: Vec<String> = Vec::new();
    for rule in &set.providers {
        let html = read_fixture(&rule.name, "positive");
        if !rule_matches(rule, &html) {
            misses.push(format!(
                "rule `{}` did NOT match its positive fixture — selectors {:?}, script_src {:?}",
                rule.name, rule.triggers.selectors, rule.triggers.script_src_contains,
            ));
        }
    }
    assert!(
        misses.is_empty(),
        "rules failing positive case:\n  {}",
        misses.join("\n  "),
    );
}

#[test]
fn every_rule_rejects_its_negative_fixture() {
    let set = built_in_rules().expect("bundled rules parse");
    let mut spurious: Vec<String> = Vec::new();
    for rule in &set.providers {
        let html = read_fixture(&rule.name, "negative");
        if rule_matches(rule, &html) {
            spurious.push(format!(
                "rule `{}` FIRED on its negative fixture — selectors or script_src too broad",
                rule.name,
            ));
        }
    }
    assert!(
        spurious.is_empty(),
        "rules with false-positive negatives:\n  {}",
        spurious.join("\n  "),
    );
}

#[test]
fn no_rule_cross_fires_on_another_rules_negative() {
    // Cross-precision check: a generic vendor's selectors must not
    // match an unrelated vendor's negative HTML. Catches over-broad
    // selectors that would create cross-vendor false positives in
    // production (e.g. wp_math_captcha matching DataDome's negative).
    let set = built_in_rules().expect("bundled rules parse");
    let mut violations: Vec<String> = Vec::new();
    for rule in &set.providers {
        for other in &set.providers {
            if rule.name == other.name {
                continue;
            }
            let html = read_fixture(&other.name, "negative");
            if rule_matches(rule, &html) {
                violations.push(format!(
                    "rule `{}` matched `{}`'s negative fixture",
                    rule.name, other.name,
                ));
            }
        }
    }
    assert!(
        violations.is_empty(),
        "cross-rule precision violations:\n  {}",
        violations.join("\n  "),
    );
}