use captchaforge::solver::adversarial_decoy::standard_mutations;
use captchaforge::solver::bandit::Bandit;
use captchaforge::solver::decoy_detector::{classify, DecoyVerdict};
use captchaforge::solver::token_shapes::for_vendor;
use captchaforge::solver::{CaptchaType, SolveMethod};
use proptest::prelude::*;
const VENDORS_FOR_FUZZ: &[&str] = &[
"turnstile",
"hcaptcha",
"recaptcha_v2",
"recaptcha_v3",
"geetest",
"arkose",
"datadome",
"aws_waf",
"akamai",
"perimeterx",
];
proptest! {
#![proptest_config(ProptestConfig {
cases: 30_000,
max_local_rejects: 10_000_000,
max_global_rejects: 10_000_000,
.. ProptestConfig::default()
})]
#[test]
fn fuzz_decoy_detector_never_panics(
bytes in prop::collection::vec(0u8..=255, 0..1024),
vendor_idx in 0usize..VENDORS_FOR_FUZZ.len(),
) {
let token = String::from_utf8_lossy(&bytes).to_string();
let vendor = VENDORS_FOR_FUZZ[vendor_idx];
let _ = classify(&token, vendor);
}
#[test]
fn fuzz_token_shapes_oracle_never_panics(
bytes in prop::collection::vec(0u8..=255, 0..1024),
) {
let token = String::from_utf8_lossy(&bytes).to_string();
for vendor in &[
"cloudflare-turnstile",
"recaptcha-v2",
"recaptcha-v3",
"recaptcha-enterprise",
"hcaptcha",
] {
if let Some(oracle) = for_vendor(vendor) {
let _ = oracle.classify(&token);
}
}
}
#[test]
fn fuzz_bandit_observe_never_panics(
rounds in 1u32..50,
success_pattern in any::<u32>(),
) {
let bandit = Bandit::new();
let methods = vec![
SolveMethod::AutoPass,
SolveMethod::BehavioralBypass,
SolveMethod::VisionLLM,
SolveMethod::AudioBypass,
SolveMethod::CrowdSourced,
];
let ct = CaptchaType::CloudflareTurnstile;
for r in 0..rounds {
let chosen = bandit.choose(&ct, &methods).unwrap();
let succeeded = (success_pattern >> (r % 32)) & 1 == 1;
bandit.observe(&ct, &chosen, succeeded);
}
for arm in bandit.snapshot() {
prop_assert!(arm.alpha >= 1.0);
prop_assert!(arm.beta >= 1.0);
}
}
#[test]
fn fuzz_mutation_apply_never_panics(
body in prop::collection::vec(b'!'..=b'~', 0..500),
) {
let token = String::from_utf8(body).unwrap();
for m in standard_mutations() {
let _ = m.apply(&token);
}
}
#[test]
fn fuzz_mutations_typically_yield_non_real(
body in prop::collection::vec(b'a'..=b'z', 220..400),
) {
let token = format!("0.{}", String::from_utf8(body).unwrap());
let mut non_real = 0usize;
let mutations = standard_mutations();
for m in &mutations {
let mutated = m.apply(&token);
if classify(&mutated, "turnstile") != DecoyVerdict::Real {
non_real += 1;
}
}
prop_assert!(
non_real * 5 >= mutations.len() * 3,
"only {} / {} mutations flagged non-Real",
non_real,
mutations.len()
);
}
#[test]
fn fuzz_mutation_composition_safe(
body in prop::collection::vec(b'a'..=b'z', 100..500),
first_idx in 0usize..22,
second_idx in 0usize..22,
) {
let mutations = standard_mutations();
let first = mutations[first_idx % mutations.len()];
let second = mutations[second_idx % mutations.len()];
let token = String::from_utf8(body).unwrap();
let intermediate = first.apply(&token);
let _ = second.apply(&intermediate);
}
#[test]
fn fuzz_low_entropy_scores_lower(
len in 30usize..400,
) {
use captchaforge::solver::decoy_detector::extract_features;
let low_entropy: String = "a".repeat(len);
let mixed: String = (0..len).map(|i| match i % 4 {
0 => 'A', 1 => 'b', 2 => '3', _ => '-',
}).collect();
let f_low = extract_features(&low_entropy, "turnstile");
let f_mix = extract_features(&mixed, "turnstile");
prop_assert!(f_mix.entropy > f_low.entropy);
}
#[test]
fn fuzz_bandit_posterior_always_valid(
ops in prop::collection::vec(any::<bool>(), 0..100),
) {
let bandit = Bandit::new();
let methods = vec![SolveMethod::AutoPass, SolveMethod::BehavioralBypass];
let ct = CaptchaType::CloudflareTurnstile;
for succeeded in ops {
let chosen = bandit.choose(&ct, &methods).unwrap();
bandit.observe(&ct, &chosen, succeeded);
}
for arm in bandit.snapshot() {
prop_assert!(arm.alpha > 0.0);
prop_assert!(arm.beta > 0.0);
prop_assert!(arm.posterior_mean() >= 0.0);
prop_assert!(arm.posterior_mean() <= 1.0);
}
}
}