use crate::Error;
use crate::Request;
use crate::Response;
use reqwest::Url;
mod form;
use form::Form;
#[cfg_attr(docsrs, allow(rustdoc::missing_doc_code_examples))]
#[derive(Debug)]
pub struct Client {
client: reqwest::Client,
url: Url,
}
#[cfg_attr(docsrs, allow(rustdoc::missing_doc_code_examples))]
impl Default for Client {
fn default() -> Client {
Client::new()
}
}
#[cfg_attr(docsrs, allow(rustdoc::missing_doc_code_examples))]
impl Client {
#[allow(unknown_lints)]
#[cfg_attr(docsrs, allow(rustdoc::bare_urls))]
pub fn new() -> Client {
Client {
client: reqwest::Client::new(),
url: Url::parse(crate::VERIFY_URL).expect("API url string corrupt"),
}
}
pub fn new_with(url: &str) -> Result<Client, url::ParseError> {
Ok(Client {
client: reqwest::Client::new(),
url: Url::parse(url)?,
})
}
pub fn set_url(mut self, url: &str) -> Result<Self, Error> {
self.url = Url::parse(url)?;
Ok(self)
}
#[allow(dead_code)]
#[cfg_attr(
feature = "trace",
tracing::instrument(
name = "Request verification from captval.",
skip(self),
level = "debug"
)
)]
pub async fn verify(self, request: Request) -> Result<Response, Error> {
let form: Form = request.into();
#[cfg(feature = "trace")]
tracing::debug!(
"The form to submit to Captval API: {:?}",
serde_urlencoded::to_string(&form).unwrap_or_else(|_| "form corrupted".to_owned())
);
let response = self
.client
.post(self.url.clone())
.form(&form)
.send()
.await?
.json::<Response>()
.await?;
#[cfg(feature = "trace")]
tracing::debug!("The response is: {:?}", response);
response.check_error()?;
Ok(response)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Code, Error};
use chrono::{TimeDelta, Utc};
use claims::{assert_err, assert_ok};
use rand::distr::Alphanumeric;
use rand::{rng, Rng};
use serde_json::json;
use std::iter;
#[cfg(feature = "trace")]
use tracing_test::traced_test;
use wiremock::matchers::{body_string, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn random_string(characters: usize) -> String {
let mut rng = rng();
iter::repeat(())
.map(|()| rng.sample(Alphanumeric))
.map(char::from)
.take(characters)
.collect()
}
#[tokio::test]
#[cfg_attr(feature = "trace", traced_test)]
async fn captval_mock_verify() {
let token = random_string(100);
let secret = format!("0x{}", hex::encode(random_string(20)));
let request = Request::new_from_response(&secret, &token).unwrap();
let expected_body = format!("response={}&secret={}", &token, &secret);
let timestamp = Utc::now()
.checked_sub_signed(TimeDelta::try_minutes(10).unwrap())
.unwrap()
.to_rfc3339();
let response_template = ResponseTemplate::new(200).set_body_json(json!({
"success": true,
"challenge_ts": timestamp,
"hostname": "test-host",
}));
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/siteverify"))
.and(body_string(&expected_body))
.respond_with(response_template)
.mount(&mock_server)
.await;
let uri = format!("{}{}", mock_server.uri(), "/siteverify");
let client = Client::new_with(&uri).unwrap();
let response = client.verify(request).await;
assert_ok!(&response);
let response = response.unwrap();
assert!(&response.success());
assert_eq!(&response.timestamp().unwrap(), ×tamp);
#[cfg(feature = "trace")]
assert!(logs_contain("Captval API"));
#[cfg(feature = "trace")]
assert!(logs_contain("The response is"));
}
#[tokio::test]
#[cfg_attr(feature = "trace", traced_test)]
async fn captval_mock_verify_not_found() {
let token = random_string(100);
let secret = format!("0x{}", hex::encode(random_string(20)));
let request = Request::new_from_response(&secret, &token).unwrap();
let expected_body = format!("response={}&secret={}", &token, &secret);
let response_template = ResponseTemplate::new(404);
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/siteverify"))
.and(body_string(&expected_body))
.respond_with(response_template)
.mount(&mock_server)
.await;
let uri = format!("{}{}", mock_server.uri(), "/siteverify");
let client = Client::new_with(&uri).unwrap();
let response = client.verify(request).await;
assert_err!(&response);
}
#[tokio::test]
#[cfg_attr(feature = "trace", traced_test)]
async fn captval_mock_verify_client_response() {
let token = random_string(100);
let secret = format!("0x{}", hex::encode(random_string(20)));
let request = Request::new_from_response(&secret, &token).unwrap();
let expected_body = format!("response={}&secret={}", &token, &secret);
let timestamp = Utc::now()
.checked_sub_signed(TimeDelta::try_minutes(10).unwrap())
.unwrap()
.to_rfc3339();
let response_template = ResponseTemplate::new(200).set_body_json(json!({
"success": true,
"challenge_ts": timestamp,
"hostname": "test-host",
}));
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/siteverify"))
.and(body_string(&expected_body))
.respond_with(response_template)
.mount(&mock_server)
.await;
let uri = format!("{}{}", mock_server.uri(), "/siteverify");
let client = Client::new_with(&uri).unwrap();
#[allow(deprecated)]
let response = client.verify(request).await;
assert_ok!(&response);
let response = response.unwrap();
assert!(&response.success());
assert_eq!(&response.timestamp().unwrap(), ×tamp);
#[cfg(feature = "trace")]
assert!(logs_contain("Captval API"));
#[cfg(feature = "trace")]
assert!(logs_contain("The response is"));
}
#[tokio::test]
#[cfg_attr(feature = "trace", traced_test)]
async fn captval_mock_verify_client_response_not_found() {
let token = random_string(100);
let secret = format!("0x{}", hex::encode(random_string(20)));
let request = Request::new_from_response(&secret, &token).unwrap();
let expected_body = format!("response={}&secret={}", &token, &secret);
let response_template = ResponseTemplate::new(404);
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/siteverify"))
.and(body_string(&expected_body))
.respond_with(response_template)
.mount(&mock_server)
.await;
let uri = format!("{}{}", mock_server.uri(), "/siteverify");
let client = Client::new_with(&uri).unwrap();
#[allow(deprecated)]
let response = client.verify(request).await;
assert_err!(&response);
}
#[tokio::test]
#[cfg_attr(feature = "trace", traced_test)]
async fn captval_mock_with_remoteip() {
let token = random_string(100);
let secret = format!("0x{}", hex::encode(random_string(20)));
let remoteip = mockd::internet::ipv4_address();
let request = Request::new_from_response(&secret, &token)
.unwrap()
.set_remoteip(&remoteip)
.unwrap();
let expected_body = format!(
"response={}&remoteip={}&secret={}",
&token, &remoteip, &secret
);
let timestamp = Utc::now()
.checked_sub_signed(TimeDelta::try_minutes(10).unwrap())
.unwrap()
.to_rfc3339();
let response_template = ResponseTemplate::new(200).set_body_json(json!({
"success": true,
"challenge_ts": timestamp,
"hostname": "test-host",
}));
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/siteverify"))
.and(body_string(&expected_body))
.respond_with(response_template)
.mount(&mock_server)
.await;
let uri = format!("{}{}", mock_server.uri(), "/siteverify");
let client = Client::new_with(&uri).unwrap();
let response = client.verify(request).await;
assert_ok!(&response);
let response = response.unwrap();
assert!(&response.success());
assert_eq!(&response.timestamp().unwrap(), ×tamp);
#[cfg(feature = "trace")]
assert!(logs_contain("Captval API"));
#[cfg(feature = "trace")]
assert!(logs_contain("The response is"));
}
#[tokio::test]
#[cfg_attr(feature = "trace", traced_test)]
async fn captval_mock_with_sitekey() {
let token = random_string(100);
let secret = format!("0x{}", hex::encode(random_string(20)));
let sitekey = mockd::unique::uuid_v4();
let request = Request::new_from_response(&secret, &token)
.unwrap()
.set_sitekey(&sitekey)
.unwrap();
let expected_body = format!(
"response={}&sitekey={}&secret={}",
&token, &sitekey, &secret
);
let timestamp = Utc::now()
.checked_sub_signed(TimeDelta::try_minutes(10).unwrap())
.unwrap()
.to_rfc3339();
let response_template = ResponseTemplate::new(200).set_body_json(json!({
"success": true,
"challenge_ts": timestamp,
"hostname": "test-host",
}));
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/siteverify"))
.and(body_string(&expected_body))
.respond_with(response_template)
.mount(&mock_server)
.await;
let uri = format!("{}{}", mock_server.uri(), "/siteverify");
let client = Client::new_with(&uri).unwrap();
let response = client.verify(request).await;
assert_ok!(&response);
let response = response.unwrap();
assert!(&response.success());
assert_eq!(&response.timestamp().unwrap(), ×tamp);
#[cfg(feature = "trace")]
assert!(logs_contain("Captval API"));
#[cfg(feature = "trace")]
assert!(logs_contain("The response is"));
}
#[test]
fn test_success_response() {
let api_response = json!({
"success": true,
"challenge_ts": "2020-11-11T23:27:00Z",
"hostname": "my-host.ie",
"credit": true,
"error-codes": [],
"score": null,
"score_reason": [],
});
let response: Response = serde_json::from_value(api_response).unwrap();
assert!(response.success());
assert_eq!(
response.timestamp(),
Some("2020-11-11T23:27:00Z".to_owned())
);
assert_eq!(response.hostname(), Some("my-host.ie".to_owned()));
}
#[test]
fn test_error_response() {
let api_response = json!({
"success": false,
"challenge_ts": null,
"hostname": null,
"credit": null,
"error-codes": ["missing-input-secret", "foo"],
"score": null,
"score_reason": [],
});
let response: Response = serde_json::from_value(api_response).unwrap();
assert!(!response.success());
assert!(response.error_codes().is_some());
if let Some(hash_set) = response.error_codes() {
assert_eq!(hash_set.len(), 2);
assert!(hash_set.contains(&Code::MissingSecret));
assert!(hash_set.contains(&Code::Unknown("foo".to_owned())));
}
}
#[test]
fn test_captval_client_default_initialization() {
let client = Client::default();
assert!(matches!(client, Client { .. }));
}
#[test]
fn test_captval_client_default_calls_new() {
let client = Client::default();
let expected_value = Url::parse(crate::VERIFY_URL).unwrap();
assert!(client.url == expected_value);
}
#[test]
fn test_set_url_with_valid_url() {
let client = Client::default();
let result = client.set_url("https://example.com");
assert!(result.is_ok());
assert_eq!(result.unwrap().url.as_str(), "https://example.com/");
}
#[test]
fn test_set_url_with_invalid_url() {
let client = Client::default();
let result = client.set_url("invalid-url");
assert!(result.is_err());
match result {
Err(Error::Url(_)) => (),
_ => panic!("Expected UrlParseError"),
}
}
#[test]
fn test_set_url_with_empty_string() {
let client = Client::default();
let result = client.set_url("");
assert!(result.is_err());
match result {
Err(Error::Url(_)) => (),
_ => panic!("Expected UrlParseError"),
}
}
}