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`] (the decoy classifier + every statistical feature).

use super::*;

// ------------------ feature smoke tests ------------------

#[test]
fn shannon_entropy_zero_for_constant_string() {
    let bytes = b"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
    assert!(shannon_entropy(bytes) < 0.1);
}

#[test]
fn shannon_entropy_high_for_random_base64url() {
    let bytes = b"aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ";
    assert!(shannon_entropy(bytes) > 5.0);
}

#[test]
fn ks_d_uniform_low_for_balanced_base64url() {
    // Should produce uniform-ish ks_d (small D).
    let bytes: Vec<u8> = (0..240)
        .map(|i| match i % 6 {
            0 => b'A' + (i % 26) as u8,
            1 => b'a' + (i % 26) as u8,
            2 => b'0' + (i % 10) as u8,
            3 => b'5' + (i % 5) as u8,
            4 => b'-',
            _ => b'_',
        })
        .collect();
    let d = ks_d_against_uniform_base64(&bytes);
    assert!(
        d < 0.5,
        "KS-D should be < 0.5 for balanced base64url, got {d}"
    );
}

#[test]
fn ks_d_uniform_high_for_repetitive_input() {
    let bytes = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    let d = ks_d_against_uniform_base64(bytes);
    assert!(d > 0.5, "KS-D should be high for repetitive, got {d}");
}

#[test]
fn markov_transition_score_higher_for_real_token() {
    let real = b"aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ";
    let decoy = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    let real_score = markov_transition_score(real);
    let decoy_score = markov_transition_score(decoy);
    assert!(
        real_score >= decoy_score,
        "real {real_score} should be >= decoy {decoy_score}"
    );
}

#[test]
fn run_length_score_high_for_random_input() {
    let s = b"aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ";
    assert!(run_length_score(s) > 0.7);
}

#[test]
fn run_length_score_low_for_repeated() {
    let s = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    assert!(run_length_score(s) < 0.5);
}

#[test]
fn compressibility_distinguishes_random_vs_repetitive() {
    let random = b"aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ";
    let repetitive = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    assert!(
        compressibility_score(random) > compressibility_score(repetitive),
        "random {} should be > repetitive {}",
        compressibility_score(random),
        compressibility_score(repetitive)
    );
}

#[test]
fn ascii_concentration_high_for_typical_token() {
    let s = b"aB3xY7zQ9mK2wL5";
    assert!(ascii_concentration(s) > 0.9);
}

#[test]
fn dot_segment_variation_high_for_jwt_shape() {
    // 3 segments of ~20 chars each.
    let token = "aaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbb.cccccccccccccccccccc";
    assert!(dot_segment_variation(token) > 0.7);
}

#[test]
fn classify_rejects_empty_token() {
    assert_eq!(classify("", "turnstile"), DecoyVerdict::Decoy);
}

#[test]
fn classify_rejects_obvious_decoy() {
    assert_eq!(classify("DUMMY", "turnstile"), DecoyVerdict::Decoy);
    assert_eq!(classify("ok", "turnstile"), DecoyVerdict::Decoy);
}

#[test]
fn classify_accepts_high_entropy_long_token() {
    let token = "0.aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ8tW1nB4mE7sD0xL3kJ6hG9fR2qV5yU8cP1aB4eX7zM0nQ3kL6jH9pR2tV5wY8xC1dF4gN7eM0lJ3kS6aZbY9cV2uI5oP8rQ1tW4nB7mE0sD3xL6kJ9hG2fR5qV8yU1cP4aB7eX0zM3nQ";
    let v = classify(token, "turnstile");
    assert_ne!(v, DecoyVerdict::Decoy, "real-shape token rejected: {v:?}");
}

#[test]
fn classify_rejects_padded_low_entropy_decoy() {
    // 220 chars but mostly 'a' filler.
    let s = "0.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    assert_eq!(classify(s, "turnstile"), DecoyVerdict::Decoy);
}

#[test]
fn feature_vector_score_in_unit_interval() {
    // Hand-construct extreme features.
    let max = FeatureVector {
        entropy: 1.0,
        ks_uniform: 1.0,
        markov: 1.0,
        chi_sq_uniform: 1.0,
        run_length: 1.0,
        compressibility: 1.0,
        ascii_concentration: 1.0,
        dot_segment_cv: 1.0,
        hex_ratio: 1.0,
        bigram_coverage: 1.0,
        length_match: 1.0,
    };
    assert!(max.score() >= 0.99);
    let min = FeatureVector {
        entropy: 0.0,
        ks_uniform: 0.0,
        markov: 0.0,
        chi_sq_uniform: 0.0,
        run_length: 0.0,
        compressibility: 0.0,
        ascii_concentration: 0.0,
        dot_segment_cv: 0.0,
        hex_ratio: 0.0,
        bigram_coverage: 0.0,
        length_match: 0.0,
    };
    assert!(min.score() <= 0.01);
}

#[test]
fn vendor_profile_for_each_known_vendor() {
    for v in &["turnstile", "hcaptcha", "recaptcha-v2", "recaptcha-v3"] {
        assert!(profile_for(v).is_some());
    }
    assert!(profile_for("unknown_vendor").is_none());
}

#[test]
fn decoy_detector_todo_lists_concrete_items() {
    // Like BORING_BACKEND_TODO, every TODO must be a concrete
    // unit of work, not exploratory language.
    for item in DECOY_DETECTOR_TODO {
        let s = item.to_lowercase();
        assert!(
            !s.contains("consider")
                && !s.contains("investigate")
                && !s.contains("maybe")
                && !s.contains("could"),
            "TODO must be concrete: {item}"
        );
    }
}

// ------------------ scale tests: 10k random tokens ------------------

#[test]
fn scale_classify_10k_random_high_entropy_real_tokens_mostly_real() {
    use rand::{rngs::StdRng, Rng, SeedableRng};
    let mut rng = StdRng::seed_from_u64(0xC4FF_5C0E);
    const N: usize = 10_000;
    let mut real_count = 0;
    for _ in 0..N {
        // Real-shape: 0./1. prefix + 320 high-entropy base64url chars.
        let prefix = if rng.gen_bool(0.5) { "0." } else { "1." };
        let body: String = (0..320)
            .map(|_| {
                let idx: u8 = rng.gen_range(0..64);
                match idx {
                    0..=25 => (b'A' + idx) as char,
                    26..=51 => (b'a' + (idx - 26)) as char,
                    52..=61 => (b'0' + (idx - 52)) as char,
                    62 => '-',
                    _ => '_',
                }
            })
            .collect();
        let token = format!("{prefix}{body}");
        if classify(&token, "turnstile") != DecoyVerdict::Decoy {
            real_count += 1;
        }
    }
    // Realistic threshold: high-entropy synthetic real tokens with
    // proper prefix should be classified non-Decoy ≥ 75% of the time.
    // (Statistical detectors have non-zero FP/FN by design.)
    let rate = (real_count as f32) / (N as f32);
    assert!(
        rate >= 0.75,
        "real-shape token acceptance rate {:.2} below 0.75; saw {} real / {} total",
        rate,
        real_count,
        N
    );
}

#[test]
fn scale_classify_10k_random_constant_decoys_mostly_decoy() {
    // Pad-only decoys.
    const N: usize = 10_000;
    let mut decoy_count = 0;
    for i in 0..N {
        let token = format!("0.{}", "a".repeat(220 + (i % 50)));
        if classify(&token, "turnstile") == DecoyVerdict::Decoy {
            decoy_count += 1;
        }
    }
    let rate = (decoy_count as f32) / (N as f32);
    assert!(rate >= 0.99, "decoy detection rate {:.2} below 0.99", rate);
}

#[test]
fn scale_extract_features_10k_calls_finishes_quickly() {
    use std::time::Instant;
    let token = "0.aB3xY7zQ9mK2wL5jH8nR4pT6vC1dF0gN-8eXqM7lJ4kS3aZbY2cV5uI6oP9rQ8tW1nB4mE7sD0xL3kJ6hG9fR2qV5yU8cP1aB4eX7zM0nQ3kL6jH9pR2tV5wY8xC1dF4gN7eM0lJ3kS6aZbY9cV2uI5oP8rQ1tW4nB7mE0sD3xL6kJ9hG2fR5qV8yU1cP4aB";
    let t0 = Instant::now();
    for _ in 0..10_000 {
        let _ = extract_features(token, "turnstile");
    }
    let elapsed = t0.elapsed();
    assert!(
        elapsed.as_secs() < 5,
        "10k extract_features took {:?}; budget 5s",
        elapsed
    );
}

// ------------------ property tests via proptest ------------------

proptest::proptest! {
    #![proptest_config(proptest::test_runner::Config {
        cases: 10_000, .. proptest::test_runner::Config::default()
    })]

    #[test]
    fn prop_classify_never_panics(s in proptest::collection::vec(0u8..=255, 0..400)) {
        let token = String::from_utf8_lossy(&s).to_string();
        for vendor in ["turnstile", "hcaptcha", "recaptcha-v2", "recaptcha-v3", "geetest"] {
            let _ = classify(&token, vendor);
        }
    }

    #[test]
    fn prop_empty_token_always_decoy(vendor in "turnstile|hcaptcha|recaptcha-v2|recaptcha-v3") {
        assert_eq!(classify("", &vendor), DecoyVerdict::Decoy);
    }

    #[test]
    fn prop_short_token_under_min_always_decoy(len in 0usize..40) {
        let token: String = "a".repeat(len);
        assert_eq!(classify(&token, "turnstile"), DecoyVerdict::Decoy);
    }

    #[test]
    fn prop_features_in_unit_interval(s in proptest::collection::vec(b'a'..=b'z', 0..400)) {
        let token = String::from_utf8(s).unwrap();
        let f = extract_features(&token, "turnstile");
        // Every individual feature must be in [0, 1].
        for value in [
            f.entropy, f.ks_uniform, f.markov, f.chi_sq_uniform,
            f.run_length, f.compressibility, f.ascii_concentration,
            f.dot_segment_cv, f.hex_ratio, f.bigram_coverage, f.length_match,
        ] {
            assert!(value >= 0.0 && value <= 1.0, "feature out of [0,1]: {value}");
        }
    }

    #[test]
    fn prop_score_in_unit_interval(s in proptest::collection::vec(0u8..=255, 0..400)) {
        let token = String::from_utf8_lossy(&s).to_string();
        let f = extract_features(&token, "turnstile");
        let score = f.score();
        assert!(score >= 0.0 && score <= 1.0, "score out of [0,1]: {score}");
    }

    #[test]
    fn prop_classify_monotone_in_length_for_same_alphabet(
        shorter in proptest::collection::vec(b'a'..=b'z', 0..100),
        longer_padding in proptest::collection::vec(b'a'..=b'z', 0..200),
    ) {
        // If a token's longer version is rejected, the shorter
        // version should also be rejected (or be insufficient).
        // This expresses the monotonic length-filter property.
        let shorter_s: String = shorter.iter().map(|b| *b as char).collect();
        let mut longer_s = shorter_s.clone();
        for c in &longer_padding {
            longer_s.push(*c as char);
        }
        let s_verdict = classify(&shorter_s, "turnstile");
        let l_verdict = classify(&longer_s, "turnstile");
        // Decoy(shorter) does NOT force Decoy(longer), extra chars
        // may bring it into range. But Real(longer) must not imply
        // Decoy(shorter) iff shorter also meets min_len.
        if shorter_s.len() < 200 {
            assert_eq!(s_verdict, DecoyVerdict::Decoy,
                "short token must be decoy regardless");
        }
        // Suppress unused warnings.
        let _ = l_verdict;
    }

    #[test]
    fn prop_high_entropy_long_string_not_always_decoy(
        payload in proptest::collection::vec(b'a'..=b'z', 200..400),
    ) {
        let token: String = payload.iter().map(|b| *b as char).collect();
        let v = classify(&token, "turnstile");
        // High-entropy long string MAY still be Decoy (no prefix)
        // but the classifier must not panic and must produce a
        // valid variant. Just verify it ran.
        let _ = matches!(v, DecoyVerdict::Real | DecoyVerdict::Borderline | DecoyVerdict::Decoy);
    }

    #[test]
    fn prop_runlength_score_inverts_runlength(len in 5usize..200) {
        let s = vec![b'a'; len];
        assert!(run_length_score(&s) < 0.5);
    }

    #[test]
    fn prop_shannon_entropy_bounded(bytes in proptest::collection::vec(0u8..=255, 0..1024)) {
        let h = shannon_entropy(&bytes);
        assert!(h >= 0.0);
        assert!(h <= 8.0001, "entropy {h} should be <= 8.0");
    }

    #[test]
    fn prop_ks_d_in_unit_interval(bytes in proptest::collection::vec(0u8..=255, 0..1024)) {
        let d = ks_d_against_uniform_base64(&bytes);
        assert!(d >= 0.0 && d <= 1.0);
    }
}