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
//! Unit tests for [`super`]. Tier-B detection rule matching.

use super::*;

#[test]
fn parses_minimal_rule() {
    let toml = r#"
            [[provider]]
            name = "aws_waf"
            priority = 12

            [provider.triggers]
            selectors = [".aws-waf-token"]
        "#;
    let set = RuleSet::parse(toml).unwrap();
    assert_eq!(set.providers.len(), 1);
    assert_eq!(set.providers[0].name, "aws_waf");
    assert_eq!(set.providers[0].priority, 12);
    assert_eq!(set.providers[0].triggers.selectors, vec![".aws-waf-token"]);
}

#[test]
fn parses_title_and_cookie_triggers() {
    let toml = r#"
            [[provider]]
            name = "ddos_screen"
            priority = 50

            [provider.triggers]
            title_contains = ["DDoS protection", "Checking your browser"]
            cookie_names = ["__cf_bm", "_dd_jwt"]
        "#;
    let set = RuleSet::parse(toml).unwrap();
    assert_eq!(set.providers[0].triggers.title_contains.len(), 2);
    assert_eq!(set.providers[0].triggers.cookie_names.len(), 2);
    let det: RuleDetector = set.providers[0].clone().into();
    let js = det.js();
    // The probe must reference both kinds of triggers.
    assert!(
        js.contains("document.title"),
        "title trigger missing from JS: {js}"
    );
    assert!(
        js.contains("document.cookie"),
        "cookie trigger missing from JS: {js}"
    );
    // Title is lowercased before substring-matching, so the
    // generated literal must be lowercase.
    assert!(js.contains("ddos protection"));
}

#[test]
fn parses_multiple_rules() {
    let toml = r#"
            [[provider]]
            name = "aws_waf"
            priority = 12
            [provider.triggers]
            selectors = [".aws-waf-token"]
            window_globals = ["awsWafCaptcha"]

            [[provider]]
            name = "datadome"
            priority = 13
            [provider.triggers]
            selectors = ["[data-datadome]"]
            script_src_contains = ["js.datadome.co"]
        "#;
    let set = RuleSet::parse(toml).unwrap();
    assert_eq!(set.providers.len(), 2);
    assert_eq!(set.providers[1].name, "datadome");
}

#[test]
fn empty_rules_block_yields_no_providers() {
    let set = RuleSet::parse("").unwrap();
    assert!(set.providers.is_empty());
}

#[test]
fn missing_required_field_is_an_error() {
    // No `name` → must fail. (priority is required too.)
    let toml = r#"
            [[provider]]
            priority = 10
            [provider.triggers]
        "#;
    assert!(RuleSet::parse(toml).is_err());
}

#[test]
fn build_probe_js_escapes_selector_with_quotes() {
    let rule = ProviderRule {
        name: "evil".into(),
        priority: 99,
        solver_methods: default_solver_methods(),
        solver_names: Vec::new(),
        triggers: Triggers {
            selectors: vec!["[data-x=\"a\"]".into()],
            window_globals: vec![],
            script_src_contains: vec![],
            title_contains: vec![],
            cookie_names: vec![],
        },
    };
    let js = build_probe_js(&rule);
    // Embedded quote must NOT appear unescaped in the produced JS.
    // serde_json escapes `"` as `\"` inside the JSON string literal.
    assert!(
        js.contains("\\\""),
        "expected an escaped quote in generated JS; got: {js}"
    );
}

#[test]
fn build_probe_js_handles_all_three_trigger_types() {
    let rule = ProviderRule {
        name: "aws_waf".into(),
        priority: 12,
        solver_methods: default_solver_methods(),
        solver_names: Vec::new(),
        triggers: Triggers {
            selectors: vec![".aws-waf-token".into()],
            window_globals: vec!["awsWafCaptcha".into()],
            script_src_contains: vec!["awswafcaptcha.com".into()],
            title_contains: vec![],
            cookie_names: vec![],
        },
    };
    let js = build_probe_js(&rule);
    assert!(js.contains("querySelector(\".aws-waf-token\")"));
    assert!(js.contains("typeof window[\"awsWafCaptcha\"]"));
    assert!(js.contains("document.querySelectorAll('script[src]')"));
    assert!(js.contains("\"awswafcaptcha.com\""));
    assert!(js.contains("\"custom:aws_waf\""));
}

#[test]
fn rule_detector_round_trips_name_and_priority() {
    let rule = ProviderRule {
        name: "datadome".into(),
        priority: 13,
        solver_methods: default_solver_methods(),
        solver_names: Vec::new(),
        triggers: Triggers::default(),
    };
    let det = RuleDetector::from(rule);
    // RuleDetector implements both Detector and CaptchaProvider 
    // both define `name(&self)`, so disambiguate at the call site.
    assert_eq!(<RuleDetector as Detector>::name(&det), "datadome");
    assert_eq!(det.priority(), 13);
}

#[test]
fn built_in_rules_parse_and_cover_expected_vendors() {
    let set = built_in_rules().expect("bundled community.toml parses");
    let names: Vec<&str> = set.providers.iter().map(|p| p.name.as_str()).collect();
    // Anchor: every vendor we ship coverage for. Removing one of
    // these without an explicit decision should fail the test so
    // the regression is loud.
    for expected in [
        "datadome",
        "akamai_bot_manager",
        "perimeterx_human",
        "arkose_funcaptcha",
        "geetest_v3",
        "geetest_v4",
        "aws_waf_captcha",
        "friendly_captcha",
        "mtcaptcha",
        "wp_math_captcha",
    ] {
        assert!(
            names.contains(&expected),
            "bundled community.toml is missing rule: {expected}",
        );
    }
}

#[test]
fn built_in_rules_priorities_dont_collide_with_each_other() {
    let set = built_in_rules().unwrap();
    let mut prios: Vec<i32> = set.providers.iter().map(|p| p.priority).collect();
    prios.sort_unstable();
    let mut deduped = prios.clone();
    deduped.dedup();
    assert_eq!(prios, deduped, "priorities must be unique within the pack");
}

#[test]
fn built_in_rules_become_detectors_with_valid_js_probes() {
    let set = built_in_rules().unwrap();
    assert!(set.providers.len() >= 10);
    // Build each detector directly so we can inspect the
    // generated JS (the `Box<dyn Detector>` returned by
    // `into_detectors` erases the concrete RuleDetector type).
    for rule in set.providers {
        let det = RuleDetector::from(rule);
        let js = det.js();
        let name = <RuleDetector as Detector>::name(&det);
        assert!(js.starts_with("(function()"), "{name}: bad prefix");
        assert!(js.ends_with("})()"), "{name}: bad suffix");
        assert!(js.contains("custom:"), "{name}: missing custom-kind tag");
    }
}

#[test]
fn into_detectors_keeps_insertion_order() {
    let toml = r#"
            [[provider]]
            name = "a"
            priority = 100
            [provider.triggers]
            selectors = [".a"]

            [[provider]]
            name = "b"
            priority = 5
            [provider.triggers]
            selectors = [".b"]
        "#;
    let dets = RuleSet::parse(toml).unwrap().into_detectors();
    assert_eq!(dets.len(), 2);
    assert_eq!(dets[0].name(), "a");
    assert_eq!(dets[1].name(), "b");
}