use captchaforge::solver::bandit::Bandit;
use captchaforge::solver::decoy_detector::{classify, extract_features, DecoyVerdict};
use captchaforge::solver::token_shapes::{for_vendor, TokenShape};
use captchaforge::solver::{CaptchaType, SolveMethod};
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig {
// 100k random cases per invariant — single-process, ~1-3s
// wall-clock per invariant on a modern CPU.
cases: 100_000,
max_local_rejects: 100_000_000,
max_global_rejects: 100_000_000,
.. ProptestConfig::default()
})]
#[test]
fn scale_decoy_never_panics_on_short_input(
bytes in prop::collection::vec(0u8..=255, 0..400),
) {
let token = String::from_utf8_lossy(&bytes).to_string();
for vendor in [
"turnstile", "hcaptcha", "recaptcha_v2", "recaptcha_v3",
"arkose", "datadome", "aws_waf", "akamai", "perimeterx",
"geetest",
] {
let _ = classify(&token, vendor);
}
}
#[test]
fn scale_decoy_features_in_unit_interval(
bytes in prop::collection::vec(b'!'..=b'~', 0..512),
) {
let token = String::from_utf8(bytes).unwrap();
let f = extract_features(&token, "turnstile");
for v 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,
] {
prop_assert!(v >= 0.0 && v <= 1.0, "feature out of [0,1]: {v}");
}
}
#[test]
fn scale_decoy_truncation_monotone(
bytes in prop::collection::vec(b'A'..=b'Z', 50..400),
truncate_to in 0usize..50,
) {
let full = String::from_utf8(bytes.clone()).unwrap();
let truncated: String = full.chars().take(truncate_to).collect();
if truncate_to < 200 {
prop_assert_eq!(classify(&truncated, "turnstile"), DecoyVerdict::Decoy);
}
}
#[test]
fn scale_empty_string_always_decoy(
vendor in r"turnstile|hcaptcha|recaptcha_v2|recaptcha_v3|geetest|arkose|datadome|aws_waf|akamai|perimeterx",
) {
prop_assert_eq!(classify("", &vendor), DecoyVerdict::Decoy);
}
#[test]
fn scale_oracle_under_min_len_always_decoy(
bytes in prop::collection::vec(b'A'..=b'Z', 0..60),
) {
let s = String::from_utf8(bytes).unwrap();
let oracle = for_vendor("hcaptcha").unwrap();
prop_assert_eq!(oracle.classify(&s), TokenShape::Decoy);
}
#[test]
fn scale_bandit_choose_returns_member(
seed_obs_count in 0u32..100,
n_candidates in 1usize..6,
) {
let candidates: Vec<SolveMethod> = vec![
SolveMethod::AutoPass,
SolveMethod::BehavioralBypass,
SolveMethod::VisionLLM,
SolveMethod::AudioBypass,
SolveMethod::CrowdSourced,
].into_iter().take(n_candidates).collect();
let bandit = Bandit::new();
let ct = CaptchaType::CloudflareTurnstile;
for _ in 0..seed_obs_count {
let chosen = bandit.choose(&ct, &candidates).unwrap();
prop_assert!(candidates.contains(&chosen));
bandit.observe(&ct, &chosen, false);
}
}
#[test]
fn scale_bandit_observe_grows_arm_set(
n_observes in 1u32..100,
) {
let bandit = Bandit::new();
for _ in 0..n_observes {
bandit.observe(
&CaptchaType::CloudflareTurnstile,
&SolveMethod::AutoPass,
true,
);
}
prop_assert!(bandit.len() >= 1);
let snap = bandit.snapshot();
prop_assert_eq!(snap.len(), 1);
prop_assert!(snap[0].alpha >= 1.0 + n_observes as f64);
}
#[test]
fn scale_short_prefixed_token_always_decoy(
body in r"[A-Za-z0-9_\-]{1,100}",
) {
let token = format!("0.{}", body);
prop_assert_eq!(classify(&token, "turnstile"), DecoyVerdict::Decoy);
}
#[test]
fn scale_padded_filler_decoys_caught(
len in 220usize..400,
fill in b'a'..=b'z',
) {
let body: String = std::iter::repeat(fill as char).take(len).collect();
let token = format!("0.{}", body);
prop_assert_eq!(classify(&token, "turnstile"), DecoyVerdict::Decoy);
}
#[test]
fn scale_html_injection_always_decoy(
prefix in r"[01]\.",
body_a in r"[A-Za-z0-9_\-]{100,200}",
body_b in r"[A-Za-z0-9_\-]{50,100}",
) {
let token = format!("{}{}<script>{}</script>", prefix, body_a, body_b);
prop_assert_eq!(classify(&token, "turnstile"), DecoyVerdict::Decoy);
}
#[test]
fn scale_whitespace_always_decoy(
prefix in r"[01]\.",
body_a in r"[A-Za-z0-9_\-]{100,200}",
body_b in r"[A-Za-z0-9_\-]{50,100}",
) {
let token = format!("{}{}\n{}", prefix, body_a, body_b);
prop_assert_eq!(classify(&token, "turnstile"), DecoyVerdict::Decoy);
}
}