aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.rs75
-rw-r--r--src/dyndns_service.rs6
-rw-r--r--src/dyndns_service/gandi.rs4
-rw-r--r--src/error.rs55
-rw-r--r--src/ip_service.rs132
-rw-r--r--src/main.rs3
-rw-r--r--src/test_macros.rs73
7 files changed, 208 insertions, 140 deletions
diff --git a/src/config.rs b/src/config.rs
index 88fb280..4c8bef1 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,37 +1,33 @@
-use std::{fs, io};
-use std::path::Path;
use crate::dyndns_service::DynDnsProvider;
+use crate::error::{AppError, AppResult};
+use crate::ip_service::IpServiceProvider;
use fqdn::FQDN;
use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr;
use serde_with::serde_as;
+use std::path::Path;
+use std::{fs, io};
use strum::Display;
-use crate::error::{AppError, AppResult};
-use crate::ip_service::IpServiceProvider;
#[derive(Debug, Deserialize, Serialize)]
#[serde(transparent)]
pub struct Config {
- pub networks: Vec<WanConfig>
+ pub networks: Vec<WanConfig>,
}
impl Config {
-
pub fn load<P: AsRef<Path>>(path: P) -> AppResult<Config> {
let path = path.as_ref();
- let content = fs::read_to_string(path)
- .map_err(|e| match e.kind() {
- io::ErrorKind::NotFound => AppError::FileNotFound(path.to_path_buf()),
- _ => AppError::IoError(e),
- })?;
-
- serde_json::from_str(&content)
- .map_err(|e| AppError::ConfigParseError {
- source: e,
- path: path.to_path_buf(),
- })
+ let content = fs::read_to_string(path).map_err(|e| match e.kind() {
+ io::ErrorKind::NotFound => AppError::FileNotFound(path.to_path_buf()),
+ _ => AppError::IoError(e),
+ })?;
+
+ serde_json::from_str(&content).map_err(|e| AppError::ConfigParseError {
+ source: e,
+ path: path.to_path_buf(),
+ })
}
-
}
#[derive(Debug, Deserialize, Serialize)]
@@ -41,14 +37,13 @@ pub struct WanConfig {
pub dns_record: DnsRecord,
pub providers: Vec<DynDnsProvider>,
#[serde(default)]
- pub ip_service: IpServiceProvider
+ pub ip_service: IpServiceProvider,
}
-#[derive(Debug, Display, Deserialize, Serialize)]
-#[derive(PartialEq)]
+#[derive(Debug, Display, Deserialize, Serialize, PartialEq)]
pub enum DnsRecordType {
A,
- AAAA
+ AAAA,
}
#[serde_as]
@@ -71,14 +66,15 @@ fn default_ttl() -> u32 {
#[cfg(test)]
mod tests {
- use std::error::Error;
- use std::fs::{read_dir, File};
- use std::io::BufReader;
use super::*;
- use serde_json::json;
- use std::str::FromStr;
use crate::dyndns_service::DynDnsProvider::GANDI;
use crate::dyndns_service::gandi::Gandi;
+ use crate::{assert_config_parse_error, assert_error, assert_file_not_found, assert_io_error};
+ use serde_json::json;
+ use std::error::Error;
+ use std::fs::{File, read_dir};
+ use std::io::BufReader;
+ use std::str::FromStr;
#[test]
fn check_minimal_config() {
@@ -120,7 +116,7 @@ mod tests {
#[test]
fn check_file_configs() -> Result<(), Box<dyn Error>> {
- let path = std::path::Path::new("test");
+ let path = Path::new("test");
for entry in read_dir(path)? {
let entry = entry?;
let test_file_path = entry.path();
@@ -137,4 +133,27 @@ mod tests {
Ok(())
}
+ #[test]
+ fn check_missing_config() -> Result<(), Box<dyn Error>> {
+ let path = Path::new("test/unknown.yaml");
+
+ assert_file_not_found!(Config::load(path), path);
+ Ok(())
+ }
+
+ #[test]
+ fn check_broken_config() -> Result<(), Box<dyn Error>> {
+ let path = Path::new("test/config.bork");
+
+ assert_config_parse_error!(Config::load(path), path);
+ Ok(())
+ }
+
+ #[test]
+ fn check_inaccessible_config() -> Result<(), Box<dyn Error>> {
+ let path = Path::new("/root/secure-config.json");
+
+ assert_io_error!(Config::load(path));
+ Ok(())
+ }
}
diff --git a/src/dyndns_service.rs b/src/dyndns_service.rs
index d1449f5..18cec3e 100644
--- a/src/dyndns_service.rs
+++ b/src/dyndns_service.rs
@@ -1,14 +1,14 @@
pub mod gandi;
+use crate::dyndns_service::gandi::Gandi;
use reqwest::ClientBuilder;
-use std::error::Error;
use serde::{Deserialize, Serialize};
-use crate::dyndns_service::gandi::Gandi;
+use std::error::Error;
#[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "type")]
pub enum DynDnsProvider {
- GANDI(Gandi)
+ GANDI(Gandi),
}
pub struct DynDnsService {}
diff --git a/src/dyndns_service/gandi.rs b/src/dyndns_service/gandi.rs
index 03ecf6f..8851b2c 100644
--- a/src/dyndns_service/gandi.rs
+++ b/src/dyndns_service/gandi.rs
@@ -4,12 +4,12 @@
// --header 'content-type: application/json' \
// --data "{ \"rrset_ttl\": 300, \"rrset_values\": [\"1.2.3.4\"] }"
+use crate::config::DnsRecordType;
use crate::dyndns_service::DynDnsServiceConfiguration;
use http::Method;
use serde::{Deserialize, Serialize};
use serde_with::DisplayFromStr;
use serde_with::serde_as;
-use crate::config::DnsRecordType;
// See https://api.gandi.net/docs/livedns/ for more info
#[serde_as]
@@ -53,8 +53,8 @@ impl DynDnsServiceConfiguration for Gandi {
#[cfg(test)]
mod tests {
- use serde_json::json;
use super::*;
+ use serde_json::json;
#[test]
fn check_defaults() {
diff --git a/src/error.rs b/src/error.rs
index d7bbfaf..3e05d2a 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -1,8 +1,8 @@
+use reqwest::{Error as ReqwestError, Url};
+use serde_json::Error as JsonError;
use std::fmt;
use std::io;
use std::path::PathBuf;
-use reqwest::Error as ReqwestError;
-use serde_json::Error as JsonError;
pub type AppResult<T> = Result<T, AppError>;
#[derive(Debug)]
@@ -10,38 +10,8 @@ pub enum AppError {
FileNotFound(PathBuf),
IoError(io::Error),
ConfigParseError { source: JsonError, path: PathBuf },
- RequestFailed { url: String, source: ReqwestError },
- InvalidResponse { url: String, reason: String },
- Timeout { url: String },
-}
-
-impl AppError {
- pub fn with_path(self, path: impl Into<PathBuf>) -> Self {
- match self {
- Self::ConfigParseError { source, .. } => Self::ConfigParseError {
- source,
- path: path.into(),
- },
- err => err,
- }
- }
-
- pub fn with_url(self, url: impl Into<String>) -> Self {
- match self {
- Self::RequestFailed { source, .. } => Self::RequestFailed {
- source,
- url: url.into(),
- },
- Self::InvalidResponse { reason, .. } => Self::InvalidResponse {
- reason,
- url: url.into(),
- },
- Self::Timeout { .. } => Self::Timeout {
- url: url.into(),
- },
- err => err,
- }
- }
+ RequestFailed { url: Url, source: ReqwestError },
+ InvalidResponse { url: Url, reason: String },
}
impl fmt::Display for AppError {
@@ -49,12 +19,13 @@ impl fmt::Display for AppError {
match self {
Self::FileNotFound(path) => write!(f, "File not found: {}", path.display()),
Self::IoError(err) => write!(f, "I/O error: {}", err),
- Self::ConfigParseError { path, .. } => write!(f, "Failed to parse config at {}", path.display()),
+ Self::ConfigParseError { path, .. } => {
+ write!(f, "Failed to parse config at {}", path.display())
+ }
Self::RequestFailed { url, .. } => write!(f, "Request to {} failed", url),
Self::InvalidResponse { url, reason } => {
write!(f, "Invalid response from {}: {}", url, reason)
}
- Self::Timeout { url } => write!(f, "Request to {} timed out", url),
}
}
}
@@ -79,10 +50,12 @@ impl From<io::Error> for AppError {
impl From<ReqwestError> for AppError {
fn from(err: ReqwestError) -> Self {
- Self::RequestFailed {
- url: err.url().map_or_else(|| "unknown".to_string(), |u| u.to_string()),
- source: err,
- }
+ let url = match err.url() {
+ Some(url) => url.clone(),
+ None => Url::parse("http://unknown.url").unwrap(),
+ };
+
+ Self::RequestFailed { url, source: err }
}
}
@@ -90,7 +63,7 @@ impl From<JsonError> for AppError {
fn from(err: JsonError) -> Self {
Self::ConfigParseError {
source: err,
- path: PathBuf::from("unknown"), // Default path
+ path: PathBuf::from("unknown"), // Default path
}
}
}
diff --git a/src/ip_service.rs b/src/ip_service.rs
index 1d7f253..fcf3a80 100644
--- a/src/ip_service.rs
+++ b/src/ip_service.rs
@@ -3,9 +3,8 @@ use crate::ip_service::IpServiceProvider::IDENTME;
use http::StatusCode;
use reqwest::{Client, Url};
use serde::{Deserialize, Serialize};
-use serde_with::serde_as;
use serde_with::DisplayFromStr;
-use std::error::Error;
+use serde_with::serde_as;
use std::net::IpAddr;
use std::str::FromStr;
@@ -54,7 +53,7 @@ pub struct IdentMe {
impl IdentMe {
fn convert_string_to_ip_address(input: String, url: &Url) -> AppResult<IpAddr> {
IpAddr::from_str(&input).map_err(|e| AppError::InvalidResponse {
- url: url.to_string(),
+ url: url.clone(),
reason: format!(
"Failed to parse service response [{}] into valid ip address: {}",
input, e
@@ -64,21 +63,21 @@ impl IdentMe {
fn request_failed(&self, e: reqwest::Error) -> AppError {
AppError::RequestFailed {
- url: self.url.to_string(),
+ url: self.url.clone(),
source: e,
}
}
fn invalid_status_code(&self, status: StatusCode) -> AppError {
AppError::InvalidResponse {
- url: self.url.to_string(),
+ url: self.url.clone(),
reason: format!("Status code: {}", status),
}
}
fn missing_text_response(&self, e: reqwest::Error) -> AppError {
AppError::InvalidResponse {
- url: self.url.to_string(),
+ url: self.url.clone(),
reason: format!("Unable to get text from service response: {}", e),
}
}
@@ -121,11 +120,23 @@ impl IpServiceConfiguration for IdentMe {
#[cfg(test)]
mod tests {
use super::*;
- use crate::error::AppError::{InvalidResponse, RequestFailed};
+ use crate::{assert_error, assert_invalid_response, assert_request_failed};
use reqwest::ClientBuilder;
+ use std::error::Error;
+ use std::time::Duration;
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, Respond, ResponseTemplate};
+ trait HasUrl {
+ fn url(&self) -> Url;
+ }
+
+ impl HasUrl for MockServer {
+ fn url(&self) -> Url {
+ Url::from_str(self.uri().as_str()).unwrap()
+ }
+ }
+
async fn setup_ipv4_service(response: impl Respond + 'static) -> MockServer {
let service = MockServer::start().await;
@@ -147,6 +158,10 @@ mod tests {
ResponseTemplate::new(204)
}
+ fn delayed_response(delay: u64) -> impl Respond + 'static {
+ ResponseTemplate::new(204).set_delay(Duration::from_millis(delay))
+ }
+
fn server_error() -> impl Respond + 'static {
ResponseTemplate::new(500)
}
@@ -155,101 +170,89 @@ mod tests {
async fn successful_ipv4_address_resolution() -> Result<(), Box<dyn Error>> {
let expected = IpAddr::from_str("17.5.7.8")?;
let input = plaintext_response(expected.to_string());
- let (ip_service, actual) = run(input).await?;
+ let (ip_service, actual) = run_identme(input).await?;
assert_eq!(1, ip_service.received_requests().await.unwrap().len());
- assert_eq!(actual.unwrap(), expected);
+ assert_eq!(actual?, expected);
Ok(())
}
#[tokio::test]
- async fn failed_address_resolution_no_server() -> Result<(), Box<dyn Error>> {
+ async fn failed_no_reachable_server() -> Result<(), Box<dyn Error>> {
let client = ClientBuilder::new().build()?;
- let server_url = "http://localhost:8765/path";
- let service = IdentMe {
- url: server_url.parse()?,
- };
+ let url = Url::from_str("http://localhost:8765/path")?;
+ let service = IdentMe { url: url.clone() };
let actual = IDENTME(service).resolve(&client).await;
- assert!(actual.is_err());
-
- match actual.err().unwrap() {
- RequestFailed { url, .. } => {
- assert_eq!(url, server_url.to_string());
- }
- _ => {
- panic!("Unexpected error")
- }
- }
+ assert_request_failed!(actual, url);
Ok(())
}
#[tokio::test]
- async fn failed_non_successful_status_code() -> Result<(), Box<dyn Error>> {
+ async fn failed_status_code() -> Result<(), Box<dyn Error>> {
let input = server_error();
- let (ip_service, actual) = run(input).await?;
+ let (ip_service, actual) = run_identme(input).await?;
assert_eq!(1, ip_service.received_requests().await.unwrap().len());
- assert!(actual.is_err());
-
- match actual.err().unwrap() {
- InvalidResponse { url, reason } => {
- assert_eq!(Url::parse(&*url), Url::from_str(&*ip_service.uri()));
- assert_eq!(reason, "Status code: 500 Internal Server Error");
- }
- _ => {
- panic!("Unexpected error")
- }
- }
+ assert_invalid_response!(
+ actual,
+ ip_service.url(),
+ "Status code: 500 Internal Server Error"
+ );
Ok(())
}
#[tokio::test]
- async fn failed_non_successful_empty_response() -> Result<(), Box<dyn Error>> {
+ async fn failed_empty_response() -> Result<(), Box<dyn Error>> {
let input = empty_response();
- let (ip_service, actual) = run(input).await?;
+ let (ip_service, actual) = run_identme(input).await?;
assert_eq!(1, ip_service.received_requests().await.unwrap().len());
- assert!(actual.is_err());
-
- match actual.err().unwrap() {
- InvalidResponse { url, .. } => {
- assert_eq!(Url::parse(&*url), Url::from_str(&*ip_service.uri()));
- }
- _ => {
- panic!("Unexpected error")
- }
- }
+ assert_invalid_response!(
+ actual,
+ ip_service.url(),
+ "Failed to parse service response [] into valid ip address"
+ );
Ok(())
}
#[tokio::test]
- async fn failed_non_successful_not_an_ip_address() -> Result<(), Box<dyn Error>> {
+ async fn failed_not_an_ip_address() -> Result<(), Box<dyn Error>> {
let input = plaintext_response("not-an-ip-address".to_string());
- let (ip_service, actual) = run(input).await?;
+ let (ip_service, actual) = run_identme(input).await?;
assert_eq!(1, ip_service.received_requests().await.unwrap().len());
- assert!(actual.is_err());
-
- match actual.err().unwrap() {
- InvalidResponse { url, .. } => {
- assert_eq!(Url::parse(&*url), Url::from_str(&*ip_service.uri()));
- }
- _ => {
- panic!("Unexpected error")
- }
- }
+ assert_invalid_response!(
+ actual,
+ ip_service.url(),
+ "Failed to parse service response [not-an-ip-address] into valid ip address"
+ );
Ok(())
}
- async fn run(payload: impl Respond + 'static) -> Result<(MockServer, AppResult<IpAddr>), Box<dyn Error>> {
+ #[tokio::test]
+ async fn failed_timeout() -> Result<(), Box<dyn Error>> {
+ let input = delayed_response(200);
+ let (ip_service, actual) = run_identme(input).await?;
+
+ assert_eq!(1, ip_service.received_requests().await.unwrap().len());
+ assert_request_failed!(actual, ip_service.url());
+
+ Ok(())
+ }
+
+ async fn run_identme(
+ payload: impl Respond + 'static,
+ ) -> Result<(MockServer, AppResult<IpAddr>), Box<dyn Error>> {
let ip_service = setup_ipv4_service(payload).await;
- let client = ClientBuilder::new().build()?;
+ let client = ClientBuilder::new()
+ .timeout(Duration::from_millis(100))
+ .build()?;
let service = IdentMe {
url: ip_service.uri().parse()?,
};
@@ -257,7 +260,4 @@ mod tests {
let actual = IDENTME(service).resolve(&client).await;
Ok((ip_service, actual))
}
-
-
-
}
diff --git a/src/main.rs b/src/main.rs
index 06bea8e..57f4c7d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9,6 +9,9 @@ mod dyndns_service;
mod error;
mod ip_service;
+#[cfg(test)]
+mod test_macros;
+
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
diff --git a/src/test_macros.rs b/src/test_macros.rs
new file mode 100644
index 0000000..9f3ff2b
--- /dev/null
+++ b/src/test_macros.rs
@@ -0,0 +1,73 @@
+#[macro_export]
+macro_rules! assert_error {
+ ($result:expr, $pattern:pat => $body:block) => {
+ match $result {
+ Ok(_) => panic!("Expected an error, but got Ok result"),
+ Err(e) => match e {
+ $pattern => $body,
+ other => panic!("Expected {}, but got {:?}", stringify!($pattern), other),
+ },
+ }
+ };
+}
+
+#[macro_export]
+macro_rules! assert_file_not_found {
+ ($result:expr, $expected_path:expr) => {
+ assert_error!($result, AppError::FileNotFound(path) => {
+ assert_eq!(path, $expected_path,
+ "Expected file not found for path {:?}, but got path {:?}",
+ $expected_path, path);
+ });
+ };
+}
+
+#[macro_export]
+macro_rules! assert_io_error {
+ ($result:expr) => {
+ assert_error!($result, AppError::IoError(_) => {});
+ };
+ ($result:expr, $kind:pat) => {
+ assert_error!($result, AppError::IoError(err) => {
+ assert!(matches!(err.kind(), $kind),
+ "Expected IO error of kind {}, but got {:?}",
+ stringify!($kind), err.kind());
+ });
+ };
+}
+
+#[macro_export]
+macro_rules! assert_config_parse_error {
+ ($result:expr, $expected_path:expr) => {
+ assert_error!($result, AppError::ConfigParseError { path, source: _ } => {
+ assert_eq!(path, $expected_path,
+ "Expected config parse error for path {:?}, but got path {:?}",
+ $expected_path, path);
+ });
+ };
+}
+
+#[macro_export]
+macro_rules! assert_request_failed {
+ ($result:expr, $expected_url:expr) => {
+ assert_error!($result, AppError::RequestFailed { url, source: _ } => {
+ assert_eq!(url, $expected_url,
+ "Expected request failed for URL {}, but got URL {}",
+ $expected_url, url);
+ });
+ };
+}
+
+#[macro_export]
+macro_rules! assert_invalid_response {
+ ($result:expr, $expected_url:expr, $reason_prefix:expr) => {
+ assert_error!($result, AppError::InvalidResponse{ url, reason } => {
+ assert_eq!(url, $expected_url,
+ "Expected invalid response for URL {}, but got URL {}",
+ $expected_url, url);
+ assert!(reason.starts_with($reason_prefix),
+ "Expected reason to start with '{}', but got '{}'",
+ $reason_prefix, reason);
+ });
+ };
+}