use captchaforge::captcha_detect::{CaptchaInfo, DetectedCaptcha};
use captchaforge::solver::{BehavioralCaptchaSolver, CaptchaSolver};
mod common;
async fn clicks(page: &captchaforge::Page) -> Vec<(String, bool)> {
let raw = page
.evaluate("JSON.stringify(window.__clicks || [])")
.await
.ok()
.and_then(|e| e.into_value::<String>().ok())
.unwrap_or_default();
serde_json::from_str::<Vec<serde_json::Value>>(&raw)
.unwrap_or_default()
.into_iter()
.filter_map(|v| {
Some((
v["id"].as_str()?.to_string(),
v["trusted"].as_bool().unwrap_or(false),
))
})
.collect()
}
const RECORDER: &str = r#"<script>
window.__clicks = [];
window.__verifyTrusted = null;
document.querySelectorAll('.cell, .icon-item, .color-item').forEach(function(el){
el.addEventListener('click', function(e){ window.__clicks.push({ id: el.id, trusted: e.isTrusted }); });
});
var vb = document.querySelector('#recaptcha-verify-button, #submit');
if (vb) vb.addEventListener('click', function(e){ window.__verifyTrusted = e.isTrusted; });
</script>"#;
async fn assert_grid_trusted(body_html: &str, want: &[&str], never: &[&str]) {
let html = format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>grid</title></head><body>{body_html}{RECORDER}</body></html>"
);
let browser = common::launch_browser().await;
let addr = common::serve_once(html).await;
let url = format!("http://{addr}/");
let page = browser.new_page(&url).await.expect("navigate");
let info = CaptchaInfo {
kind: DetectedCaptcha::ImageCaptcha,
site_key: None,
page_url: url.clone(),
container_selector: Some("#widget".into()),
};
let _ = BehavioralCaptchaSolver::new().solve(page, &info).await;
let recorded = clicks(page).await;
for w in want {
assert!(
recorded.iter().any(|(id, _)| id == w),
"tile {w} must be clicked; recorded={recorded:?}"
);
}
for n in never {
assert!(
!recorded.iter().any(|(id, _)| id == n),
"non-matching tile {n} must NOT be clicked; recorded={recorded:?}"
);
}
for (id, trusted) in &recorded {
assert!(
*trusted,
"tile {id} click must be isTrusted === true (a same-origin captcha can \
gate on it); recorded={recorded:?}"
);
}
let verify_trusted = page
.evaluate("window.__verifyTrusted")
.await
.ok()
.and_then(|e| e.into_value::<Option<bool>>().ok())
.flatten();
assert_eq!(
verify_trusted,
Some(true),
"verify button click must be isTrusted === true"
);
let _ = browser.close().await;
}
fn cell(id: &str, emoji: &str) -> String {
format!(
r#"<div class="cell" id="{id}" style="display:inline-block;width:64px;height:64px">{emoji}</div>"#
)
}
fn icon(id: &str, emoji: &str) -> String {
format!(
r#"<div class="icon-item" id="{id}" style="display:inline-block;width:64px;height:64px">{emoji}</div>"#
)
}
fn color(id: &str, rgb: &str) -> String {
format!(
r#"<div class="color-item" id="{id}" style="display:inline-block;width:64px;height:64px;background-color:{rgb}"></div>"#
)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn behavioral_image_grid_clicks_correct_tiles_trusted() {
let cells = [
cell("c0", "🚦"),
cell("c1", "🌳"),
cell("c2", "🚦"),
cell("c3", "🍎"),
cell("c4", "🚦"),
cell("c5", "🐶"),
cell("c6", "🚗"),
cell("c7", "🏠"),
cell("c8", "🚦"),
]
.concat();
let body = format!(
r#"<div id="widget"><div class="prompt">Select all traffic lights</div>
<div class="grid">{cells}</div>
<button id="recaptcha-verify-button">Verify</button></div>"#
);
assert_grid_trusted(
&body,
&["c0", "c2", "c4", "c8"],
&["c1", "c3", "c5", "c6", "c7"],
)
.await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn behavioral_icon_grid_clicks_correct_tiles_trusted() {
let icons = [
icon("i0", "🚗"),
icon("i1", "🐶"),
icon("i2", "🚌"),
icon("i3", "🍎"),
icon("i4", "🏍"),
icon("i5", "🌳"),
]
.concat();
let body = format!(
r#"<div id="widget"><div class="prompt">Select all vehicles</div>
<div class="grid">{icons}</div>
<button id="submit">Verify</button></div>"#
);
assert_grid_trusted(&body, &["i0", "i2", "i4"], &["i1", "i3", "i5"]).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn behavioral_color_grid_clicks_correct_tiles_trusted() {
let tiles = [
color("k0", "rgb(220,20,20)"), color("k1", "rgb(20,120,220)"), color("k2", "rgb(220,30,30)"), color("k3", "rgb(30,160,40)"), color("k4", "rgb(200,20,20)"), color("k5", "rgb(240,220,40)"), ]
.concat();
let body = format!(
r#"<div id="widget"><div class="prompt">Select all red tiles</div>
<div class="grid">{tiles}</div>
<button id="submit">Verify</button></div>"#
);
assert_grid_trusted(&body, &["k0", "k2", "k4"], &["k1", "k3", "k5"]).await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn behavioral_captcha_checkbox_click_is_trusted() {
if which::which("firefox").is_err() {
eprintln!("SKIP behavioral_captcha_checkbox_click_is_trusted: firefox not on PATH");
return;
}
let html = r#"<!doctype html><html><head><meta charset="utf-8"><title>cb</title></head><body>
<div id="widget" class="captcha">
<p>Confirm you are human</p>
<input type="checkbox" id="human-cb">
</div>
<script>
window.__cbTrusted = null;
document.getElementById('human-cb').addEventListener('click', function(e){
window.__cbTrusted = e.isTrusted;
if (e.isTrusted) { document.title = 'verified'; }
});
</script></body></html>"#;
let browser = common::launch_browser().await;
let addr = common::serve_once(html.to_string()).await;
let url = format!("http://{addr}/");
let page = browser.new_page(&url).await.expect("navigate");
let untrusted = page
.evaluate(
"(() => { document.getElementById('human-cb').dispatchEvent(new Event('click',{bubbles:true})); return window.__cbTrusted; })()",
)
.await
.expect("control eval")
.into_value::<Option<bool>>()
.ok()
.flatten();
assert_eq!(
untrusted,
Some(false),
"fixture sanity: a synthetic dispatchEvent click must be isTrusted === false"
);
let _ = page.evaluate("window.__cbTrusted = null; void 0").await;
let info = CaptchaInfo {
kind: DetectedCaptcha::CanvasCaptcha,
site_key: None,
page_url: url.clone(),
container_selector: Some("#widget".into()),
};
let _ = BehavioralCaptchaSolver::new().solve(page, &info).await;
let cb_trusted = page
.evaluate("window.__cbTrusted")
.await
.ok()
.and_then(|e| e.into_value::<Option<bool>>().ok())
.flatten();
let title = page
.evaluate("document.title")
.await
.ok()
.and_then(|e| e.into_value::<String>().ok())
.unwrap_or_default();
let _ = browser.close().await;
assert_eq!(
cb_trusted,
Some(true),
"the behavioral solver must deliver a TRUSTED click to the captcha checkbox \
(got {cb_trusted:?}); a synthetic dispatchEvent would be isTrusted === false"
);
assert_eq!(
title, "verified",
"a trusted checkbox click must satisfy the isTrusted-gated widget; got title={title:?}"
);
}