use std::io::{Read, Write};
use std::net::TcpListener;
use std::sync::{Arc, Mutex};
use std::thread;
use http::{Method, Request, StatusCode};
use openauth_core::api::{create_auth_endpoint, response, AuthEndpointOptions, AuthRouter};
use openauth_core::context::create_auth_context;
use openauth_core::error::OpenAuthError;
use openauth_core::options::{AdvancedOptions, IpAddressOptions, OpenAuthOptions};
use openauth_plugins::captcha::{captcha, CaptchaConfigError, CaptchaOptions, CaptchaProvider};
#[test]
fn exposes_captcha_plugin_id() {
assert_eq!(openauth_plugins::captcha::UPSTREAM_PLUGIN_ID, "captcha");
}
#[test]
fn captcha_rejects_empty_secret_key() {
let result = captcha(CaptchaOptions::cloudflare_turnstile(""));
assert!(matches!(result, Err(CaptchaConfigError::MissingSecretKey)));
}
#[test]
fn captcha_options_do_not_serialize_secret_key() -> Result<(), Box<dyn std::error::Error>> {
let plugin = captcha(CaptchaOptions::hcaptcha("secret").site_key("site"))?;
let serialized = plugin
.options
.ok_or("captcha plugin should expose serializable options")?
.to_string();
assert!(!serialized.contains("secret"));
assert!(serialized.contains("hcaptcha"));
Ok(())
}
#[tokio::test]
async fn captcha_ignores_non_protected_endpoints() -> Result<(), Box<dyn std::error::Error>> {
let plugin = captcha(
CaptchaOptions::cloudflare_turnstile("secret")
.site_verify_url_override("http://127.0.0.1:1")
.endpoints(["/sign-up/email"]),
)?;
let router = router(plugin, "/sign-in/email")?;
let response = router
.handle_async(request(
"/sign-in/email",
&[("x-captcha-response", "token")],
)?)
.await?;
assert_eq!(response.status(), StatusCode::OK);
Ok(())
}
#[tokio::test]
async fn captcha_returns_400_when_response_header_is_missing(
) -> Result<(), Box<dyn std::error::Error>> {
let plugin = captcha(CaptchaOptions::cloudflare_turnstile("secret"))?;
let router = router(plugin, "/sign-in/email")?;
let response = router.handle_async(request("/sign-in/email", &[])?).await?;
assert_error(response.status(), response.body(), "MISSING_RESPONSE");
Ok(())
}
#[tokio::test]
async fn cloudflare_turnstile_sends_json_payload_and_allows_success(
) -> Result<(), Box<dyn std::error::Error>> {
let server = JsonServer::spawn(200, r#"{"success":true}"#)?;
let plugin = captcha(
CaptchaOptions::cloudflare_turnstile("secret").site_verify_url_override(server.url()),
)?;
let router = router_trusting_forwarded_for(plugin, "/sign-in/email")?;
let response = router
.handle_async(request(
"/sign-in/email",
&[
("x-captcha-response", "token"),
("x-forwarded-for", "127.0.0.1"),
],
)?)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = server.request_body();
assert!(body.contains(r#""secret":"secret""#));
assert!(body.contains(r#""response":"token""#));
assert!(body.contains(r#""remoteip":"127.0.0.1""#));
Ok(())
}
#[tokio::test]
async fn cloudflare_turnstile_returns_403_when_provider_rejects(
) -> Result<(), Box<dyn std::error::Error>> {
let server = JsonServer::spawn(200, r#"{"success":false}"#)?;
let plugin = captcha(
CaptchaOptions::cloudflare_turnstile("secret").site_verify_url_override(server.url()),
)?;
let router = router(plugin, "/sign-in/email")?;
let response = router
.handle_async(request(
"/sign-in/email",
&[("x-captcha-response", "token")],
)?)
.await?;
assert_error(response.status(), response.body(), "VERIFICATION_FAILED");
Ok(())
}
#[tokio::test]
async fn google_recaptcha_sends_form_payload_with_remote_ip(
) -> Result<(), Box<dyn std::error::Error>> {
let server = JsonServer::spawn(200, r#"{"success":true}"#)?;
let plugin =
captcha(CaptchaOptions::google_recaptcha("secret").site_verify_url_override(server.url()))?;
let router = router_trusting_forwarded_for(plugin, "/sign-in/email")?;
let response = router
.handle_async(request(
"/sign-in/email",
&[
("x-captcha-response", "token"),
("x-forwarded-for", "127.0.0.1"),
],
)?)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = server.request_body();
assert!(body.contains("secret=secret"));
assert!(body.contains("response=token"));
assert!(body.contains("remoteip=127.0.0.1"));
Ok(())
}
#[tokio::test]
async fn google_recaptcha_returns_403_when_score_is_too_low(
) -> Result<(), Box<dyn std::error::Error>> {
let server = JsonServer::spawn(200, r#"{"success":true,"score":0.4}"#)?;
let plugin =
captcha(CaptchaOptions::google_recaptcha("secret").site_verify_url_override(server.url()))?;
let router = router(plugin, "/sign-in/email")?;
let response = router
.handle_async(request(
"/sign-in/email",
&[("x-captcha-response", "token")],
)?)
.await?;
assert_error(response.status(), response.body(), "VERIFICATION_FAILED");
Ok(())
}
#[tokio::test]
async fn hcaptcha_includes_site_key_and_remote_ip() -> Result<(), Box<dyn std::error::Error>> {
let server = JsonServer::spawn(200, r#"{"success":true}"#)?;
let plugin = captcha(
CaptchaOptions::hcaptcha("secret")
.site_key("site")
.site_verify_url_override(server.url()),
)?;
let router = router_trusting_forwarded_for(plugin, "/sign-in/email")?;
let response = router
.handle_async(request(
"/sign-in/email",
&[
("x-captcha-response", "token"),
("x-forwarded-for", "127.0.0.1"),
],
)?)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = server.request_body();
assert!(body.contains("sitekey=site"));
assert!(body.contains("remoteip=127.0.0.1"));
Ok(())
}
#[tokio::test]
async fn captchafox_uses_remote_ip_form_field() -> Result<(), Box<dyn std::error::Error>> {
let server = JsonServer::spawn(200, r#"{"success":true}"#)?;
let plugin = captcha(
CaptchaOptions::captchafox("secret")
.site_key("site")
.site_verify_url_override(server.url()),
)?;
let router = router_trusting_forwarded_for(plugin, "/sign-in/email")?;
let response = router
.handle_async(request(
"/sign-in/email",
&[
("x-captcha-response", "token"),
("x-forwarded-for", "127.0.0.1"),
],
)?)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = server.request_body();
assert!(body.contains("sitekey=site"));
assert!(body.contains("remoteIp=127.0.0.1"));
Ok(())
}
#[tokio::test]
async fn captcha_returns_500_when_provider_is_unavailable() -> Result<(), Box<dyn std::error::Error>>
{
let server = JsonServer::spawn(500, r#"{"error":"server_error"}"#)?;
let plugin = captcha(
CaptchaOptions::with_provider(CaptchaProvider::CloudflareTurnstile, "secret")
.site_verify_url_override(server.url()),
)?;
let router = router(plugin, "/sign-in/email")?;
let response = router
.handle_async(request(
"/sign-in/email",
&[("x-captcha-response", "token")],
)?)
.await?;
assert_error(response.status(), response.body(), "UNKNOWN_ERROR");
Ok(())
}
#[tokio::test]
async fn captcha_returns_500_when_provider_returns_invalid_json(
) -> Result<(), Box<dyn std::error::Error>> {
let server = JsonServer::spawn(200, "not-json")?;
let plugin = captcha(
CaptchaOptions::cloudflare_turnstile("secret").site_verify_url_override(server.url()),
)?;
let router = router(plugin, "/sign-in/email")?;
let response = router
.handle_async(request(
"/sign-in/email",
&[("x-captcha-response", "token")],
)?)
.await?;
assert_error(response.status(), response.body(), "UNKNOWN_ERROR");
Ok(())
}
#[tokio::test]
async fn captcha_returns_500_when_provider_connection_fails(
) -> Result<(), Box<dyn std::error::Error>> {
let plugin = captcha(
CaptchaOptions::cloudflare_turnstile("secret")
.site_verify_url_override("http://127.0.0.1:1"),
)?;
let router = router(plugin, "/sign-in/email")?;
let response = router
.handle_async(request(
"/sign-in/email",
&[("x-captcha-response", "token")],
)?)
.await?;
assert_error(response.status(), response.body(), "UNKNOWN_ERROR");
Ok(())
}
fn router(
plugin: openauth_core::plugin::AuthPlugin,
path: &str,
) -> Result<AuthRouter, OpenAuthError> {
router_with_advanced(plugin, path, AdvancedOptions::default())
}
fn router_trusting_forwarded_for(
plugin: openauth_core::plugin::AuthPlugin,
path: &str,
) -> Result<AuthRouter, OpenAuthError> {
router_with_advanced(
plugin,
path,
AdvancedOptions::default().ip_address(IpAddressOptions::new().headers(["x-forwarded-for"])),
)
}
fn router_with_advanced(
plugin: openauth_core::plugin::AuthPlugin,
path: &str,
advanced: AdvancedOptions,
) -> Result<AuthRouter, OpenAuthError> {
let context = create_auth_context(OpenAuthOptions {
plugins: vec![plugin],
secret: Some("secret-a-at-least-32-chars-long!!".to_owned()),
advanced: AdvancedOptions {
disable_csrf_check: true,
disable_origin_check: true,
..advanced
},
..OpenAuthOptions::default()
})?;
let endpoint = create_auth_endpoint(
path,
Method::POST,
AuthEndpointOptions::new(),
|_context, _request| Box::pin(async { response(StatusCode::OK, b"OK".to_vec()) }),
);
AuthRouter::with_async_endpoints(context, Vec::new(), vec![endpoint])
}
fn request(path: &str, headers: &[(&str, &str)]) -> Result<Request<Vec<u8>>, http::Error> {
let mut builder = Request::builder()
.method(Method::POST)
.uri(format!("http://localhost:3000/api/auth{path}"));
for (name, value) in headers {
builder = builder.header(*name, *value);
}
builder.body(Vec::new())
}
fn assert_error(status: StatusCode, body: &[u8], code: &str) {
assert!(String::from_utf8_lossy(body).contains(&format!(r#""code":"{code}""#)));
match code {
"MISSING_RESPONSE" => assert_eq!(status, StatusCode::BAD_REQUEST),
"VERIFICATION_FAILED" => assert_eq!(status, StatusCode::FORBIDDEN),
"UNKNOWN_ERROR" => assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR),
_ => unreachable!("unexpected code"),
}
}
struct JsonServer {
url: String,
request_body: Arc<Mutex<String>>,
handle: Option<thread::JoinHandle<std::io::Result<()>>>,
}
impl JsonServer {
fn spawn(status: u16, body: &'static str) -> std::io::Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let url = format!("http://{}", listener.local_addr()?);
let request_body = Arc::new(Mutex::new(String::new()));
let body_for_thread = Arc::clone(&request_body);
let handle = thread::spawn(move || -> std::io::Result<()> {
let (mut stream, _) = listener.accept()?;
let mut buffer = [0; 8192];
let read = stream.read(&mut buffer)?;
let request = String::from_utf8_lossy(&buffer[..read]).to_string();
if let Some((_, request_body)) = request.split_once("\r\n\r\n") {
if let Ok(mut body) = body_for_thread.lock() {
*body = request_body.to_owned();
}
}
let response = format!(
"HTTP/1.1 {status} OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes())
});
Ok(Self {
url,
request_body,
handle: Some(handle),
})
}
fn url(&self) -> String {
self.url.clone()
}
fn request_body(&self) -> String {
match self.request_body.lock() {
Ok(body) => body.clone(),
Err(_) => String::new(),
}
}
}
impl Drop for JsonServer {
fn drop(&mut self) {
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
}
}