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`] (token-shape oracle classification).

use super::*;
use guise::fingerprint::default_profile_facts;

#[test]
fn classify_4xx_rejected() {
    assert_eq!(classify_response(403, None), ValidationVerdict::Rejected);
    assert_eq!(classify_response(429, None), ValidationVerdict::Rejected);
}

#[test]
fn classify_5xx_rejected() {
    assert_eq!(classify_response(500, None), ValidationVerdict::Rejected);
    assert_eq!(classify_response(503, None), ValidationVerdict::Rejected);
}

#[test]
fn classify_2xx_clean_body_accepted() {
    assert_eq!(
        classify_response(200, Some("{\"ok\": true}")),
        ValidationVerdict::Accepted,
    );
    assert_eq!(classify_response(204, None), ValidationVerdict::Accepted,);
}

#[test]
fn classify_2xx_with_block_phrase_rejected() {
    // A vendor that returns 200 with a "Sorry, you have been
    // blocked" body is still a rejection, the token wasn't
    // accepted, the page just didn't bother with a 4xx.
    assert_eq!(
        classify_response(200, Some("Sorry, you have been blocked.")),
        ValidationVerdict::Rejected,
    );
    assert_eq!(
        classify_response(200, Some("Access Denied")),
        ValidationVerdict::Rejected,
    );
    assert_eq!(
        classify_response(200, Some("Pardon Our Interruption")),
        ValidationVerdict::Rejected,
    );
}

#[test]
fn classify_2xx_block_phrase_case_insensitive() {
    assert_eq!(
        classify_response(200, Some("REQUEST BLOCKED")),
        ValidationVerdict::Rejected,
    );
}

#[test]
fn validator_builder_chains() {
    let v = TokenValidator::new("https://x.test/v")
        .with_field("g-recaptcha-response")
        .with_encoding(TokenEncoding::Json)
        .with_method(ValidatorMethod::Get)
        .with_timeout(Duration::from_secs(2));
    assert_eq!(v.endpoint, "https://x.test/v");
    assert_eq!(v.field, "g-recaptcha-response");
    assert_eq!(v.encoding, TokenEncoding::Json);
    assert_eq!(v.method, ValidatorMethod::Get);
    assert_eq!(v.timeout, Duration::from_secs(2));
}

#[test]
fn default_field_is_turnstile() {
    let v = TokenValidator::new("https://x.test/v");
    assert_eq!(v.field, "cf-turnstile-response");
}

fn header_from_pairs<'a>(headers: &'a [(String, String)], name: &str) -> Option<&'a str> {
    headers
        .iter()
        .find(|(key, _)| key.eq_ignore_ascii_case(name))
        .map(|(_, value)| value.as_str())
}

#[test]
fn validation_request_uses_shared_same_origin_fetch_headers() {
    let request = TokenValidator::new("https://app.test/verify")
        .validation_request("tok value")
        .expect("valid request");
    let facts = default_profile_facts();

    assert_eq!(request.method, "POST");
    assert_eq!(request.url, "https://app.test/verify");
    assert_eq!(
        header_from_pairs(&request.headers, "User-Agent"),
        Some(facts.user_agent)
    );
    assert_eq!(header_from_pairs(&request.headers, "Accept"), Some("*/*"));
    assert_eq!(
        header_from_pairs(&request.headers, "Accept-Language"),
        Some(facts.accept_language)
    );
    assert_eq!(
        header_from_pairs(&request.headers, "Sec-Fetch-Dest"),
        Some("empty")
    );
    assert_eq!(
        header_from_pairs(&request.headers, "Sec-Fetch-Mode"),
        Some("cors")
    );
    assert_eq!(
        header_from_pairs(&request.headers, "Sec-Fetch-Site"),
        Some("same-origin")
    );
    assert_eq!(
        header_from_pairs(&request.headers, "Content-Type"),
        Some("application/x-www-form-urlencoded")
    );
    assert!(header_from_pairs(&request.headers, "Accept-Encoding").is_none());
    assert!(header_from_pairs(&request.headers, "Upgrade-Insecure-Requests").is_none());
    assert!(header_from_pairs(&request.headers, "Sec-Fetch-User").is_none());
    assert_eq!(
        String::from_utf8(request.body.expect("form body")).unwrap(),
        "cf-turnstile-response=tok+value"
    );
}

// Used only by the `not(tls-impersonate)` reqwest tests below, which capture the
// raw wire request off a local TCP listener; gated to match so it isn't dead
// under `--features tls-impersonate` (where validation goes via StealthClient).
#[cfg(not(feature = "tls-impersonate"))]
fn captured_header<'a>(raw: &'a str, name: &str) -> Option<&'a str> {
    raw.lines().find_map(|line| {
        let (key, value) = line.split_once(':')?;
        key.eq_ignore_ascii_case(name).then(|| value.trim())
    })
}

#[cfg(not(feature = "tls-impersonate"))]
#[tokio::test]
async fn validator_validate_sends_shared_fetch_headers_and_form_body() {
    use tokio::io::{AsyncReadExt, AsyncWriteExt};

    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let url = format!("http://{}/verify", listener.local_addr().unwrap());
    let server = tokio::spawn(async move {
        let (mut socket, _) = listener.accept().await.unwrap();
        let mut request = Vec::new();
        let mut buf = [0_u8; 1024];
        let mut expected_len = None;
        loop {
            let n = socket.read(&mut buf).await.unwrap();
            if n == 0 {
                break;
            }
            request.extend_from_slice(&buf[..n]);
            if expected_len.is_none() {
                if let Some(header_end) = request
                    .windows(4)
                    .position(|window| window == b"\r\n\r\n")
                    .map(|idx| idx + 4)
                {
                    let headers = String::from_utf8_lossy(&request[..header_end]);
                    let body_len = captured_header(&headers, "Content-Length")
                        .and_then(|value| value.parse::<usize>().ok())
                        .unwrap_or(0);
                    expected_len = Some(header_end + body_len);
                }
            }
            if expected_len.is_some_and(|len| request.len() >= len) {
                break;
            }
        }
        socket
            .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
            .await
            .unwrap();
        String::from_utf8(request).unwrap()
    });

    let verdict = TokenValidator::new(url)
        .validate("tok value")
        .await
        .expect("validation request");

    assert_eq!(verdict, ValidationVerdict::Accepted);
    let raw_request = server.await.unwrap();
    let facts = default_profile_facts();
    assert_eq!(
        captured_header(&raw_request, "User-Agent"),
        Some(facts.user_agent)
    );
    assert_eq!(captured_header(&raw_request, "Accept"), Some("*/*"));
    assert_eq!(
        captured_header(&raw_request, "Accept-Language"),
        Some(facts.accept_language)
    );
    assert_eq!(
        captured_header(&raw_request, "Sec-Fetch-Dest"),
        Some("empty")
    );
    assert_eq!(
        captured_header(&raw_request, "Sec-Fetch-Mode"),
        Some("cors")
    );
    assert_eq!(
        captured_header(&raw_request, "Sec-Fetch-Site"),
        Some("same-origin")
    );
    assert_eq!(
        captured_header(&raw_request, "Content-Type"),
        Some("application/x-www-form-urlencoded")
    );
    assert!(captured_header(&raw_request, "Accept-Encoding").is_none());
    assert!(captured_header(&raw_request, "Upgrade-Insecure-Requests").is_none());
    assert!(captured_header(&raw_request, "Sec-Fetch-User").is_none());
    assert!(raw_request.ends_with("cf-turnstile-response=tok+value"));
}