use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use axum::http::{Request, Response, StatusCode};
use tower::{Layer, Service};
use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CaptchaProviderKind {
#[default]
Turnstile,
HCaptcha,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct BotProtectionConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub provider: CaptchaProviderKind,
#[serde(default)]
pub site_key: Option<String>,
#[serde(default)]
pub secret_key: Option<String>,
#[serde(default)]
pub form_field: Option<String>,
#[serde(default)]
pub dev_bypass: bool,
}
impl BotProtectionConfig {
#[must_use]
pub fn effective_form_field(&self) -> &str {
self.form_field.as_deref().unwrap_or(match self.provider {
CaptchaProviderKind::Turnstile => "cf-turnstile-response",
CaptchaProviderKind::HCaptcha => "h-captcha-response",
})
}
}
pub trait CaptchaProvider: Send + Sync + 'static {
fn verify<'a>(&'a self, token: &'a str) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>>;
fn form_field_name(&self) -> &'static str;
fn requires_token(&self) -> bool {
true
}
#[cfg(feature = "maud")]
fn widget_markup(&self, site_key: &str) -> maud::Markup;
}
#[cfg(feature = "http-client")]
pub struct TurnstileProvider {
secret_key: String,
client: reqwest::Client,
}
#[cfg(feature = "http-client")]
impl TurnstileProvider {
pub fn new(secret_key: impl Into<String>) -> Self {
Self {
secret_key: secret_key.into(),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("failed to build reqwest client"),
}
}
}
#[cfg(feature = "http-client")]
impl CaptchaProvider for TurnstileProvider {
fn verify<'a>(&'a self, token: &'a str) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>> {
Box::pin(async move {
let params = [("secret", self.secret_key.as_str()), ("response", token)];
match self
.client
.post("https://challenges.cloudflare.com/turnstile/v0/siteverify")
.form(¶ms)
.send()
.await
{
Ok(resp) => {
let json: serde_json::Value =
resp.json().await.unwrap_or(serde_json::Value::Null);
json.get("success")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
Err(_) => false,
}
})
}
fn form_field_name(&self) -> &'static str {
"cf-turnstile-response"
}
#[cfg(feature = "maud")]
fn widget_markup(&self, site_key: &str) -> maud::Markup {
maud::html! {
div .cf-turnstile data-sitekey=(site_key) {}
script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async="true" defer="true" {}
}
}
}
#[cfg(feature = "http-client")]
pub struct HCaptchaProvider {
secret_key: String,
client: reqwest::Client,
}
#[cfg(feature = "http-client")]
impl HCaptchaProvider {
pub fn new(secret_key: impl Into<String>) -> Self {
Self {
secret_key: secret_key.into(),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.expect("failed to build reqwest client"),
}
}
}
#[cfg(feature = "http-client")]
impl CaptchaProvider for HCaptchaProvider {
fn verify<'a>(&'a self, token: &'a str) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>> {
Box::pin(async move {
let params = [("secret", self.secret_key.as_str()), ("response", token)];
match self
.client
.post("https://api.hcaptcha.com/siteverify")
.form(¶ms)
.send()
.await
{
Ok(resp) => {
let json: serde_json::Value =
resp.json().await.unwrap_or(serde_json::Value::Null);
json.get("success")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
Err(_) => false,
}
})
}
fn form_field_name(&self) -> &'static str {
"h-captcha-response"
}
#[cfg(feature = "maud")]
fn widget_markup(&self, site_key: &str) -> maud::Markup {
maud::html! {
div .h-captcha data-sitekey=(site_key) {}
script src="https://js.hcaptcha.com/1/api.js" async="true" defer="true" {}
}
}
}
pub struct AlwaysPassProvider;
#[cfg(not(feature = "http-client"))]
pub(crate) struct AlwaysFailProvider;
#[cfg(not(feature = "http-client"))]
impl CaptchaProvider for AlwaysFailProvider {
fn verify<'a>(&'a self, _token: &'a str) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>> {
Box::pin(std::future::ready(false))
}
fn form_field_name(&self) -> &'static str {
"cf-turnstile-response"
}
#[cfg(feature = "maud")]
fn widget_markup(&self, _site_key: &str) -> maud::Markup {
maud::html! {}
}
}
impl CaptchaProvider for AlwaysPassProvider {
fn verify<'a>(&'a self, _token: &'a str) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>> {
Box::pin(std::future::ready(true))
}
fn form_field_name(&self) -> &'static str {
"cf-turnstile-response"
}
fn requires_token(&self) -> bool {
false
}
#[cfg(feature = "maud")]
fn widget_markup(&self, site_key: &str) -> maud::Markup {
maud::html! {
div .cf-turnstile data-sitekey=(site_key) {}
}
}
}
pub struct TestCaptchaProvider {
valid_token: String,
}
impl TestCaptchaProvider {
pub fn new(valid_token: impl Into<String>) -> Self {
Self {
valid_token: valid_token.into(),
}
}
}
impl CaptchaProvider for TestCaptchaProvider {
fn verify<'a>(&'a self, token: &'a str) -> Pin<Box<dyn Future<Output = bool> + Send + 'a>> {
let is_valid = token == self.valid_token;
Box::pin(std::future::ready(is_valid))
}
fn form_field_name(&self) -> &'static str {
"cf-turnstile-response"
}
#[cfg(feature = "maud")]
fn widget_markup(&self, site_key: &str) -> maud::Markup {
maud::html! {
input type="hidden" name="cf-turnstile-response" value=(site_key);
}
}
}
#[derive(Clone)]
struct BotProtectionSettings {
provider: Arc<dyn CaptchaProvider>,
dev_bypass: bool,
form_field: String,
max_scan_bytes: usize,
exempt_paths: Vec<String>,
}
#[derive(Clone)]
pub struct BotProtectionLayer {
settings: Arc<BotProtectionSettings>,
}
impl BotProtectionLayer {
pub fn new(provider: Arc<dyn CaptchaProvider>) -> Self {
let form_field = provider.form_field_name().to_owned();
Self {
settings: Arc::new(BotProtectionSettings {
provider,
dev_bypass: false,
form_field,
max_scan_bytes: 2 * 1024 * 1024,
exempt_paths: Vec::new(),
}),
}
}
pub fn from_config(config: &BotProtectionConfig) -> Self {
let provider: Arc<dyn CaptchaProvider> = if !config.enabled || config.dev_bypass {
Arc::new(AlwaysPassProvider)
} else {
let secret = config.secret_key.clone().unwrap_or_default();
if secret.is_empty() {
tracing::warn!(
"bot_protection: enabled is true and dev_bypass is false, but secret_key is \
missing or empty — all CAPTCHA verifications will fail!"
);
}
match config.provider {
#[cfg(feature = "http-client")]
CaptchaProviderKind::Turnstile => Arc::new(TurnstileProvider::new(secret)),
#[cfg(feature = "http-client")]
CaptchaProviderKind::HCaptcha => {
if config.form_field.is_some() {
tracing::warn!(
"bot_protection: hCaptcha does not support the form_field override — \
the widget always submits as \"h-captcha-response\"; \
set form_field only when using Turnstile"
);
}
Arc::new(HCaptchaProvider::new(secret))
}
#[cfg(not(feature = "http-client"))]
_ => {
tracing::warn!(
"bot_protection: http-client feature is disabled; \
CAPTCHA verification is unavailable — all protected form \
submissions will be rejected (fail closed)"
);
Arc::new(AlwaysFailProvider)
}
}
};
let form_field = config.effective_form_field().to_owned();
Self {
settings: Arc::new(BotProtectionSettings {
provider,
dev_bypass: config.dev_bypass,
form_field,
max_scan_bytes: 2 * 1024 * 1024,
exempt_paths: Vec::new(),
}),
}
}
#[must_use]
pub fn with_form_field(mut self, field: impl Into<String>) -> Self {
let settings = Arc::make_mut(&mut self.settings);
settings.form_field = field.into();
self
}
#[must_use]
pub fn with_max_scan_bytes(mut self, bytes: usize) -> Self {
let settings = Arc::make_mut(&mut self.settings);
settings.max_scan_bytes = bytes;
self
}
#[must_use]
pub fn with_exempt_paths(mut self, paths: Vec<String>) -> Self {
let settings = Arc::make_mut(&mut self.settings);
settings.exempt_paths = paths;
self
}
}
impl<S> Layer<S> for BotProtectionLayer {
type Service = BotProtectionService<S>;
fn layer(&self, inner: S) -> Self::Service {
BotProtectionService {
inner,
settings: Arc::clone(&self.settings),
}
}
}
#[derive(Clone)]
pub struct BotProtectionService<S> {
inner: S,
settings: Arc<BotProtectionSettings>,
}
const fn is_safe_method(method: &axum::http::Method) -> bool {
matches!(
*method,
axum::http::Method::GET
| axum::http::Method::HEAD
| axum::http::Method::OPTIONS
| axum::http::Method::TRACE
)
}
async fn extract_token_from_form(
req: &mut Request<axum::body::Body>,
field_name: &str,
max_bytes: usize,
) -> Option<String> {
let content_type = req
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_ascii_lowercase)
.unwrap_or_default();
if !content_type.starts_with("application/x-www-form-urlencoded") {
return None;
}
let body = std::mem::replace(req.body_mut(), axum::body::Body::empty());
let bytes = axum::body::to_bytes(body, max_bytes)
.await
.unwrap_or_default();
let mut token = None;
for (key, value) in url::form_urlencoded::parse(&bytes) {
if key == field_name {
token = Some(value.into_owned());
break;
}
}
*req.body_mut() = axum::body::Body::from(bytes);
token
}
fn bot_protection_problem_response<ResBody: From<String> + Default>(
request_id: Option<String>,
instance: Option<String>,
) -> Response<ResBody> {
let detail = "CAPTCHA token missing or invalid. Please complete the challenge and try again.";
let mut problem = crate::error::problem_details(
StatusCode::BAD_REQUEST,
detail.to_owned(),
None,
Some("https://autumn.dev/problems/bot-protection"),
request_id,
instance,
true,
);
"autumn.bot_protection".clone_into(&mut problem.code);
let body = crate::error::problem_details_to_json_string(&problem);
Response::builder()
.status(StatusCode::BAD_REQUEST)
.header(axum::http::header::CONTENT_TYPE, "application/problem+json")
.body(ResBody::from(body))
.unwrap_or_default()
}
impl<S, ResBody> Service<Request<axum::body::Body>> for BotProtectionService<S>
where
S: Service<Request<axum::body::Body>, Response = Response<ResBody>> + Clone + Send + 'static,
S::Future: Send + 'static,
S::Error: Send + 'static,
ResBody: From<String> + Default + Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: Request<axum::body::Body>) -> Self::Future {
if is_safe_method(req.method()) {
let mut inner = self.inner.clone();
std::mem::swap(&mut self.inner, &mut inner);
return Box::pin(async move { inner.call(req).await });
}
if self.settings.exempt_paths.iter().any(|ep| {
let path = req.uri().path();
let e = ep.as_str();
path == e
|| path.starts_with(e)
&& (e.ends_with('/') || path.as_bytes().get(e.len()) == Some(&b'/'))
}) {
let mut inner = self.inner.clone();
std::mem::swap(&mut self.inner, &mut inner);
return Box::pin(async move { inner.call(req).await });
}
if self.settings.dev_bypass {
let mut inner = self.inner.clone();
std::mem::swap(&mut self.inner, &mut inner);
return Box::pin(async move { inner.call(req).await });
}
let content_type = req
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_ascii_lowercase)
.unwrap_or_default();
if !content_type.starts_with("application/x-www-form-urlencoded") {
let mut inner = self.inner.clone();
std::mem::swap(&mut self.inner, &mut inner);
return Box::pin(async move { inner.call(req).await });
}
let settings = Arc::clone(&self.settings);
let mut inner = self.inner.clone();
std::mem::swap(&mut self.inner, &mut inner);
Box::pin(async move {
let token = if settings.provider.requires_token() {
extract_token_from_form(&mut req, &settings.form_field, settings.max_scan_bytes)
.await
} else {
None
};
let token_str = token.as_deref().unwrap_or("");
let valid = if token_str.is_empty() && settings.provider.requires_token() {
false
} else {
settings.provider.verify(token_str).await
};
if !valid {
let request_id = req
.extensions()
.get::<crate::middleware::RequestId>()
.map(std::string::ToString::to_string);
let instance = Some(req.uri().path().to_owned());
tracing::debug!(
path = %req.uri().path(),
token_present = token.is_some(),
"bot_protection: CAPTCHA token missing or invalid"
);
return Ok(bot_protection_problem_response(request_id, instance));
}
inner.call(req).await
})
}
}
#[cfg(feature = "maud")]
#[must_use]
pub fn bot_protection_widget(config: &BotProtectionConfig) -> maud::Markup {
if config.dev_bypass {
return maud::html! {
input type="hidden" name=(config.effective_form_field()) value="dev-bypass";
};
}
let site_key = match config.site_key.as_deref() {
Some(k) if !k.is_empty() => k,
_ => {
tracing::warn!(
"bot_protection: site_key is not configured; \
rendering no widget — CAPTCHA tokens cannot be generated \
and every form submission will be rejected"
);
return maud::html! {};
}
};
let custom_field = config.form_field.as_deref();
match config.provider {
CaptchaProviderKind::Turnstile => maud::html! {
div .cf-turnstile
data-sitekey=(site_key)
data-response-field-name=[custom_field]
{}
script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async="true" defer="true" {}
},
CaptchaProviderKind::HCaptcha => maud::html! {
div .h-captcha
data-sitekey=(site_key)
{}
script src="https://js.hcaptcha.com/1/api.js" async="true" defer="true" {}
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::Router;
use axum::body::Body;
use axum::routing::post;
use tower::ServiceExt;
async fn ok_handler() -> &'static str {
"ok"
}
fn router_with_layer(layer: BotProtectionLayer) -> Router {
Router::new()
.route("/submit", post(ok_handler))
.layer(layer)
}
#[tokio::test]
async fn always_pass_provider_allows_any_token() {
let provider = Arc::new(AlwaysPassProvider);
assert!(provider.verify("anything").await);
assert!(provider.verify("").await);
}
#[tokio::test]
async fn test_provider_accepts_valid_token() {
let provider = TestCaptchaProvider::new("secret");
assert!(provider.verify("secret").await);
assert!(!provider.verify("wrong").await);
assert!(!provider.verify("").await);
}
#[tokio::test]
async fn missing_token_returns_400() {
let layer = BotProtectionLayer::new(Arc::new(TestCaptchaProvider::new("tok")));
let app = router_with_layer(layer);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from("field=value"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let ct = resp
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or_default();
assert!(ct.contains("application/problem+json"));
}
#[tokio::test]
async fn valid_token_passes_through() {
let layer = BotProtectionLayer::new(Arc::new(TestCaptchaProvider::new("correct")));
let app = router_with_layer(layer);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from("cf-turnstile-response=correct&other=val"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn invalid_token_returns_400() {
let layer = BotProtectionLayer::new(Arc::new(TestCaptchaProvider::new("correct")));
let app = router_with_layer(layer);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from("cf-turnstile-response=wrong"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn dev_bypass_skips_verification() {
let settings = BotProtectionConfig {
dev_bypass: true,
..Default::default()
};
let layer = BotProtectionLayer::from_config(&settings);
let app = router_with_layer(layer);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from("field=value"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn get_request_passes_without_token() {
let layer = BotProtectionLayer::new(Arc::new(TestCaptchaProvider::new("required")));
let app = Router::new()
.route("/page", axum::routing::get(ok_handler))
.layer(layer);
let resp = app
.oneshot(
Request::builder()
.method("GET")
.uri("/page")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[cfg(feature = "maud")]
#[test]
fn widget_turnstile_contains_script_and_div() {
let config = BotProtectionConfig {
enabled: true,
provider: CaptchaProviderKind::Turnstile,
site_key: Some("test-key".to_string()),
..Default::default()
};
let html = bot_protection_widget(&config).into_string();
assert!(html.contains("cf-turnstile"));
assert!(html.contains("test-key"));
assert!(html.contains("challenges.cloudflare.com"));
}
#[cfg(feature = "maud")]
#[test]
fn widget_hcaptcha_contains_script_and_div() {
let config = BotProtectionConfig {
enabled: true,
provider: CaptchaProviderKind::HCaptcha,
site_key: Some("hkey".to_string()),
..Default::default()
};
let html = bot_protection_widget(&config).into_string();
assert!(html.contains("h-captcha"));
assert!(html.contains("hkey"));
assert!(html.contains("js.hcaptcha.com"));
}
#[cfg(feature = "maud")]
#[test]
fn widget_dev_bypass_emits_hidden_input() {
let config = BotProtectionConfig {
enabled: true,
dev_bypass: true,
..Default::default()
};
let html = bot_protection_widget(&config).into_string();
assert!(html.contains("type=\"hidden\""));
assert!(html.contains("dev-bypass"));
}
#[cfg(feature = "maud")]
#[test]
fn widget_renders_when_enabled_false_but_site_key_set() {
let config = BotProtectionConfig {
enabled: false,
provider: CaptchaProviderKind::Turnstile,
site_key: Some("0x4AAAA".to_string()),
..Default::default()
};
let html = bot_protection_widget(&config).into_string();
assert!(html.contains("cf-turnstile"));
assert!(html.contains("0x4AAAA"));
assert!(html.contains("challenges.cloudflare.com"));
}
#[cfg(feature = "maud")]
#[test]
fn widget_empty_when_no_site_key() {
let config = BotProtectionConfig {
enabled: false,
..Default::default()
};
let html = bot_protection_widget(&config).into_string();
assert!(html.is_empty());
}
#[tokio::test]
async fn exempt_path_does_not_bleed_to_adjacent_routes() {
let layer = BotProtectionLayer::new(Arc::new(TestCaptchaProvider::new("required")))
.with_exempt_paths(vec!["/webhook/inbound".to_string()]);
let app = Router::new()
.route("/webhook/inbound", post(ok_handler))
.route("/webhook/inbound-other", post(ok_handler))
.layer(layer);
let exempt = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/webhook/inbound")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from("field=value"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
exempt.status(),
StatusCode::OK,
"exact path should be exempt"
);
let adjacent = app
.oneshot(
Request::builder()
.method("POST")
.uri("/webhook/inbound-other")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from("field=value"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
adjacent.status(),
StatusCode::BAD_REQUEST,
"adjacent route must not be exempt"
);
}
#[tokio::test]
async fn exempt_path_bypasses_captcha() {
let layer = BotProtectionLayer::new(Arc::new(TestCaptchaProvider::new("required")))
.with_exempt_paths(vec!["/webhook/".to_string()]);
let app = Router::new()
.route("/webhook/inbound", post(ok_handler))
.layer(layer);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/webhook/inbound")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from("field=value"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn json_post_passes_without_captcha_token() {
let layer = BotProtectionLayer::new(Arc::new(TestCaptchaProvider::new("required")));
let app = router_with_layer(layer);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "application/json")
.body(Body::from(r#"{"key":"value"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn multipart_post_passes_without_captcha_token() {
let layer = BotProtectionLayer::new(Arc::new(TestCaptchaProvider::new("required")));
let app = router_with_layer(layer);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "multipart/form-data; boundary=----boundary")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[test]
fn effective_form_field_uses_custom_if_set() {
let config = BotProtectionConfig {
form_field: Some("my-captcha".to_string()),
..Default::default()
};
assert_eq!(config.effective_form_field(), "my-captcha");
}
#[test]
fn effective_form_field_defaults_to_turnstile() {
let config = BotProtectionConfig::default();
assert_eq!(config.effective_form_field(), "cf-turnstile-response");
}
#[test]
fn effective_form_field_defaults_to_hcaptcha() {
let config = BotProtectionConfig {
provider: CaptchaProviderKind::HCaptcha,
..Default::default()
};
assert_eq!(config.effective_form_field(), "h-captcha-response");
}
#[tokio::test]
async fn with_form_field_overrides_provider_default() {
let layer = BotProtectionLayer::new(Arc::new(TestCaptchaProvider::new("tok")))
.with_form_field("my-captcha");
let app = router_with_layer(layer);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from("my-captcha=tok"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/submit")
.header("Content-Type", "application/x-www-form-urlencoded")
.body(Body::from("cf-turnstile-response=tok"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
}