captchaforge 0.2.28

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Serde JSON roundtrip + tag-stability tests for every public
//! captcha type. Locks the wire format so a future serde-rename or
//! enum-reorder can't silently break crowd-sourced pattern stores
//! that consumers persisted to disk.

use captchaforge::detect::{CaptchaInfo, DetectedCaptcha};
use captchaforge::solver::{CaptchaPattern, CaptchaSolveResult, CaptchaType, SolveMethod};

fn roundtrip<T>(v: &T)
where
    T: serde::Serialize + serde::de::DeserializeOwned + std::fmt::Debug + PartialEq,
{
    let json = serde_json::to_string(v).expect("serialize");
    let back: T = serde_json::from_str(&json).expect("deserialize");
    assert_eq!(v, &back, "roundtrip mismatch via JSON: {json}");
}

#[test]
fn detected_captcha_tag_strings_are_stable() {
    let pairs = [
        (DetectedCaptcha::Turnstile, "\"turnstile\""),
        (DetectedCaptcha::RecaptchaV2, "\"recaptcha_v2\""),
        (DetectedCaptcha::RecaptchaV3, "\"recaptcha_v3\""),
        (DetectedCaptcha::HCaptcha, "\"hcaptcha\""),
        (DetectedCaptcha::ImageCaptcha, "\"image_captcha\""),
        (DetectedCaptcha::AudioCaptcha, "\"audio_captcha\""),
        (DetectedCaptcha::PowCaptcha, "\"pow_captcha\""),
        (DetectedCaptcha::SliderCaptcha, "\"slider_captcha\""),
        (DetectedCaptcha::CanvasCaptcha, "\"canvas_captcha\""),
        (DetectedCaptcha::ShadowDomCaptcha, "\"shadow_dom_captcha\""),
        (DetectedCaptcha::MultiStepCaptcha, "\"multi_step_captcha\""),
        (DetectedCaptcha::None, "\"none\""),
    ];
    for (variant, expected) in pairs {
        let got = serde_json::to_string(&variant).unwrap();
        assert_eq!(
            got, expected,
            "DetectedCaptcha::{variant:?} serialised as {got}, expected {expected}"
        );
    }
}

#[test]
fn detected_captcha_roundtrips_every_variant() {
    for variant in [
        DetectedCaptcha::Turnstile,
        DetectedCaptcha::RecaptchaV2,
        DetectedCaptcha::RecaptchaV3,
        DetectedCaptcha::HCaptcha,
        DetectedCaptcha::ImageCaptcha,
        DetectedCaptcha::AudioCaptcha,
        DetectedCaptcha::PowCaptcha,
        DetectedCaptcha::SliderCaptcha,
        DetectedCaptcha::CanvasCaptcha,
        DetectedCaptcha::ShadowDomCaptcha,
        DetectedCaptcha::MultiStepCaptcha,
        DetectedCaptcha::None,
    ] {
        roundtrip(&variant);
    }
}

#[test]
fn captcha_info_roundtrips_with_and_without_optionals() {
    let full = CaptchaInfo {
        kind: DetectedCaptcha::Turnstile,
        site_key: Some("0x4AAAAAAA_abc123".into()),
        page_url: "https://example.com/login".into(),
        container_selector: Some(".cf-turnstile".into()),
    };
    let json = serde_json::to_string(&full).unwrap();
    let back: CaptchaInfo = serde_json::from_str(&json).unwrap();
    assert_eq!(back.kind, full.kind);
    assert_eq!(back.site_key, full.site_key);
    assert_eq!(back.page_url, full.page_url);
    assert_eq!(back.container_selector, full.container_selector);

    let empty = CaptchaInfo {
        kind: DetectedCaptcha::None,
        site_key: None,
        page_url: String::new(),
        container_selector: None,
    };
    let json = serde_json::to_string(&empty).unwrap();
    assert!(json.contains("\"site_key\":null"));
    assert!(json.contains("\"container_selector\":null"));
}

#[test]
fn captcha_info_roundtrips_for_new_variants() {
    for variant in [
        DetectedCaptcha::PowCaptcha,
        DetectedCaptcha::SliderCaptcha,
        DetectedCaptcha::CanvasCaptcha,
        DetectedCaptcha::ShadowDomCaptcha,
        DetectedCaptcha::MultiStepCaptcha,
    ] {
        let info = CaptchaInfo {
            kind: variant,
            site_key: Some("test-key".into()),
            page_url: "https://example.com/captcha".into(),
            container_selector: Some(".captcha".into()),
        };
        roundtrip(&info);
    }
}

#[test]
fn solve_method_tag_strings_are_stable() {
    let pairs = [
        (SolveMethod::VisionLLM, "\"vision_llm\""),
        (SolveMethod::AudioBypass, "\"audio_bypass\""),
        (SolveMethod::BehavioralBypass, "\"behavioral_bypass\""),
        (SolveMethod::ThirdPartyService, "\"third_party_service\""),
        (SolveMethod::CrowdSourced, "\"crowd_sourced\""),
    ];
    for (variant, expected) in pairs {
        let got = serde_json::to_string(&variant).unwrap();
        assert_eq!(got, expected);
    }
}

#[test]
fn captcha_type_tag_strings_are_stable() {
    let pairs = [
        (CaptchaType::RecaptchaV2, "\"recaptcha_v2\""),
        (CaptchaType::RecaptchaV3, "\"recaptcha_v3\""),
        (CaptchaType::HCaptcha, "\"hcaptcha\""),
        (CaptchaType::CloudflareTurnstile, "\"cloudflare_turnstile\""),
        (CaptchaType::ImageGrid, "\"image_grid\""),
        (CaptchaType::TextCaptcha, "\"text_captcha\""),
        (CaptchaType::AudioCaptcha, "\"audio_captcha\""),
        (CaptchaType::Slider, "\"slider\""),
    ];
    for (variant, expected) in pairs {
        let got = serde_json::to_string(&variant).unwrap();
        assert_eq!(
            got, expected,
            "CaptchaType::{variant:?} serialised as {got}, expected {expected}"
        );
    }
}

#[test]
fn captcha_type_custom_serialises_with_inner_string() {
    let v = CaptchaType::Custom("turnstile_v2".into());
    let json = serde_json::to_string(&v).unwrap();
    let back: CaptchaType = serde_json::from_str(&json).unwrap();
    match back {
        CaptchaType::Custom(s) => assert_eq!(s, "turnstile_v2"),
        other => panic!("Custom variant lost in roundtrip: {other:?}"),
    }
}

#[test]
fn captcha_pattern_roundtrips() {
    let mut p = CaptchaPattern::new(
        "example.com",
        CaptchaType::CloudflareTurnstile,
        SolveMethod::BehavioralBypass,
    );
    for i in 0..50 {
        p.record(i % 3 != 0, 100 + i * 5, SolveMethod::BehavioralBypass);
    }
    let json = serde_json::to_string(&p).unwrap();
    let back: CaptchaPattern = serde_json::from_str(&json).unwrap();
    assert_eq!(back.domain, p.domain);
    assert_eq!(back.captcha_type, p.captcha_type);
    assert_eq!(back.best_method, p.best_method);
    assert_eq!(back.sample_count, p.sample_count);
    assert!((back.success_rate - p.success_rate).abs() < 1e-6);
    assert_eq!(back.avg_solve_time, p.avg_solve_time);
}

#[test]
fn captcha_solve_result_roundtrips() {
    let v = CaptchaSolveResult {
        solution: "0x_token_payload".into(),
        confidence: 0.93,
        method: SolveMethod::VisionLLM,
        time_ms: 4321,
        success: true,
        screenshot: Some("iVBORw0KGgo=".into()),
        cookies: Vec::new(),
        verified_outcome: None,
    };
    let json = serde_json::to_string(&v).unwrap();
    let back: CaptchaSolveResult = serde_json::from_str(&json).unwrap();
    assert_eq!(back.solution, v.solution);
    assert!((back.confidence - v.confidence).abs() < 1e-6);
    assert_eq!(back.method, v.method);
    assert_eq!(back.time_ms, v.time_ms);
    assert_eq!(back.success, v.success);
}

#[test]
fn unknown_solve_method_string_is_rejected() {
    let bad = "\"telepathy\"";
    let res: Result<SolveMethod, _> = serde_json::from_str(bad);
    assert!(res.is_err(), "unknown SolveMethod tag must not deserialize");
}

#[test]
fn unknown_detected_captcha_string_is_rejected() {
    let bad = "\"funcaptcha_v9\"";
    let res: Result<DetectedCaptcha, _> = serde_json::from_str(bad);
    assert!(
        res.is_err(),
        "unknown DetectedCaptcha tag must not deserialize"
    );
}