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() {
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"
);
}
#[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"));
}