use async_trait::async_trait;
use moka::future::Cache;
use regex::Regex;
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
use std::time::Duration;
use tracing::instrument;
use crate::config::Config;
use crate::error::{Actionable, OxidizedError};
pub const DEFAULT_CONNECT_TIMEOUT_SECS: u64 = 10;
pub const DEFAULT_REQUEST_TIMEOUT_SECS: u64 = 30;
pub const NODES_CACHE_TTL_SECS: u64 = 300;
pub const CONFIG_CACHE_TTL_SECS: u64 = 120;
pub const STATS_CACHE_TTL_SECS: u64 = 30;
pub const MAX_RETRY_ATTEMPTS: u8 = 3;
pub const RETRY_DELAYS_MS: [u64; 2] = [200, 800];
static TD_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"<td>([^<]+)</td>").expect("TD_REGEX is a valid pattern"));
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LastBackup {
pub start: Option<String>,
pub end: Option<String>,
pub status: Option<String>,
pub time: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Node {
pub name: String,
pub full_name: String,
pub ip: String,
pub group: String,
pub model: String,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub last_status: Option<String>,
pub time: Option<String>,
pub mtime: Option<String>,
#[serde(default)]
pub last: Option<LastBackup>,
}
impl Node {
pub fn effective_status(&self) -> Option<&str> {
self.status
.as_deref()
.or_else(|| self.last.as_ref().and_then(|l| l.status.as_deref()))
}
pub fn effective_time(&self) -> Option<&str> {
self.time.as_deref().or_else(|| {
self.last
.as_ref()
.and_then(|l| l.end.as_deref().or(l.start.as_deref()))
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionAuthor {
pub name: String,
pub email: String,
pub time: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeVersion {
pub oid: String,
pub date: String,
#[serde(default)]
pub time: Option<String>,
pub author: VersionAuthor,
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Stats {
pub total_nodes: Option<u32>,
pub success_count: Option<u32>,
pub failure_count: Option<u32>,
pub last_run: Option<String>,
}
impl Stats {
pub fn from_nodes(nodes: &[Node]) -> Self {
let total = nodes.len() as u32;
let success = nodes
.iter()
.filter(|n| n.effective_status() == Some("success"))
.count() as u32;
let failure = total - success;
let last_run = nodes
.iter()
.filter_map(|n| n.effective_time())
.filter(|t| *t != "never")
.max()
.map(String::from);
Stats {
total_nodes: Some(total),
success_count: Some(success),
failure_count: Some(failure),
last_run,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheMetadata {
pub cache_hit: bool,
}
impl CacheMetadata {
pub fn hit() -> Self {
Self { cache_hit: true }
}
pub fn miss() -> Self {
Self { cache_hit: false }
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CachedNodes {
pub nodes: Vec<Node>,
pub metadata: CacheMetadata,
}
#[derive(Debug, Clone, Serialize)]
pub struct CachedNode {
pub node: Node,
pub metadata: CacheMetadata,
}
#[derive(Debug, Clone, Serialize)]
pub struct CachedConfig {
pub config: String,
pub metadata: CacheMetadata,
}
#[derive(Debug, Clone, Serialize)]
pub struct CachedStats {
pub stats: Stats,
pub metadata: CacheMetadata,
}
#[async_trait]
pub trait OxidizedBackend: Send + Sync {
async fn get_nodes(&self) -> Result<(Vec<Node>, CacheMetadata), OxidizedError>;
async fn get_node(&self, name: &str) -> Result<(Node, CacheMetadata), OxidizedError>;
async fn get_node_config(&self, name: &str) -> Result<(String, CacheMetadata), OxidizedError>;
async fn get_node_versions(&self, name: &str) -> Result<Vec<NodeVersion>, OxidizedError>;
async fn get_node_version(&self, name: &str, oid: &str) -> Result<String, OxidizedError>;
async fn get_stats(&self) -> Result<(Stats, CacheMetadata), OxidizedError>;
async fn trigger_backup(&self, node: &str) -> Result<(), OxidizedError>;
async fn prioritize_node(&self, node: &str) -> Result<(), OxidizedError>;
async fn reload_sources(&self) -> Result<(), OxidizedError>;
async fn conf_search(&self, pattern: &str) -> Result<Vec<String>, OxidizedError>;
}
#[derive(Clone)]
pub struct BasicAuth {
username: String,
password: String,
}
impl BasicAuth {
pub fn new(username: String, password: String) -> Self {
Self { username, password }
}
}
#[derive(Clone)]
pub struct OxidizedClient {
client: Client,
base_url: String,
auth: Option<BasicAuth>,
custom_headers: Vec<(String, String)>,
nodes_cache: Cache<(), Vec<Node>>,
config_cache: Cache<String, String>,
stats_cache: Cache<(), Stats>,
node_cache: Cache<String, Node>,
}
impl OxidizedClient {
pub fn try_new(config: &Config) -> Result<Self, OxidizedError> {
let is_https = config.oxidized_url.starts_with("https://");
let skip_ssl_verify = is_https && !config.ssl_verify;
let client = Client::builder()
.connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
.timeout(Duration::from_secs(DEFAULT_REQUEST_TIMEOUT_SECS))
.danger_accept_invalid_certs(skip_ssl_verify)
.build()
.map_err(|e| {
tracing::error!(error = %e, "Failed to build HTTP client");
OxidizedError::ConfigError(crate::config::ConfigError::InvalidUrl(format!(
"HTTP client build failed: {}",
e
)))
})?;
let auth = match (&config.oxidized_user, &config.oxidized_password) {
(Some(user), Some(pass)) => Some(BasicAuth::new(user.clone(), pass.clone())),
_ => None,
};
let nodes_cache = Cache::builder()
.time_to_live(Duration::from_secs(NODES_CACHE_TTL_SECS))
.build();
let config_cache = Cache::builder()
.time_to_live(Duration::from_secs(CONFIG_CACHE_TTL_SECS))
.build();
let stats_cache = Cache::builder()
.time_to_live(Duration::from_secs(STATS_CACHE_TTL_SECS))
.build();
let node_cache = Cache::builder()
.time_to_live(Duration::from_secs(NODES_CACHE_TTL_SECS))
.build();
Ok(Self {
client,
base_url: config.oxidized_url.clone(),
auth,
custom_headers: config.custom_headers.clone(),
nodes_cache,
config_cache,
stats_cache,
node_cache,
})
}
#[cfg(test)]
pub fn new(config: &Config) -> Self {
Self::try_new(config).expect("Failed to create OxidizedClient")
}
async fn execute_with_retry<T, F, Fut>(&self, operation: F) -> Result<T, OxidizedError>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<T, OxidizedError>>,
{
let delays = [
Duration::from_millis(RETRY_DELAYS_MS[0]),
Duration::from_millis(RETRY_DELAYS_MS[1]),
];
let mut last_error: Option<OxidizedError> = None;
for attempt in 0..MAX_RETRY_ATTEMPTS {
match operation().await {
Ok(value) => return Ok(value),
Err(e) if e.is_transient() && attempt < MAX_RETRY_ATTEMPTS - 1 => {
let delay = delays[attempt as usize];
tracing::warn!(
attempt = attempt + 1,
max_attempts = MAX_RETRY_ATTEMPTS,
delay_ms = delay.as_millis() as u64,
error_type = %e.error_type(),
"Request failed, retrying"
);
tokio::time::sleep(delay).await;
last_error = Some(e);
}
Err(e) => {
if attempt > 0 {
tracing::error!(
attempts = attempt + 1,
error_type = %e.error_type(),
"Request failed after all retries"
);
}
return Err(e);
}
}
}
match last_error {
Some(error) => {
tracing::error!(
attempts = MAX_RETRY_ATTEMPTS,
error_type = %error.error_type(),
"Request failed after all retries"
);
Err(error)
}
None => {
tracing::error!(
attempts = MAX_RETRY_ATTEMPTS,
"Retry loop completed without error (unexpected state)"
);
Err(OxidizedError::HttpError {
status_code: 500,
context: "Retry loop completed in unexpected state".to_string(),
})
}
}
}
pub async fn invalidate_node(&self, name: &str) {
self.config_cache.invalidate(name).await;
self.node_cache.invalidate(name).await;
}
pub async fn invalidate_all_nodes(&self) {
self.nodes_cache.invalidate_all();
self.config_cache.invalidate_all();
self.node_cache.invalidate_all();
self.stats_cache.invalidate_all();
}
fn build_request(&self, endpoint: &str) -> reqwest::RequestBuilder {
let url = format!("{}{}", self.base_url, endpoint);
let mut request = self.client.get(&url);
for (name, value) in &self.custom_headers {
request = request.header(name.as_str(), value.as_str());
}
if let Some(auth) = &self.auth {
let has_custom_auth = self
.custom_headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("Authorization"));
if !has_custom_auth {
request = request.basic_auth(&auth.username, Some(&auth.password));
}
}
request
.header("Accept", "application/json")
.header("Content-Type", "application/json")
}
async fn handle_json_response<T: serde::de::DeserializeOwned>(
&self,
response: Result<reqwest::Response, reqwest::Error>,
context: &str,
) -> Result<T, OxidizedError> {
let response = self.handle_request_error(response)?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| OxidizedError::ApiUnreachable {
source: e,
attempt: 1,
last_success: None,
})?;
if let Some(err) = self.check_node_not_found_body(&body, context) {
return Err(err);
}
self.check_status(status, context)?;
serde_json::from_str::<T>(&body).map_err(|e| OxidizedError::ParseError {
context: context.to_string(),
source: e,
})
}
async fn handle_text_response(
&self,
response: Result<reqwest::Response, reqwest::Error>,
context: &str,
) -> Result<String, OxidizedError> {
let response = self.handle_request_error(response)?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| OxidizedError::ApiUnreachable {
source: e,
attempt: 1,
last_success: None,
})?;
if let Some(err) = self.check_node_not_found_body(&body, context) {
return Err(err);
}
self.check_status(status, context)?;
Ok(body)
}
async fn handle_empty_response(
&self,
response: Result<reqwest::Response, reqwest::Error>,
context: &str,
) -> Result<(), OxidizedError> {
let response = self.handle_request_error(response)?;
let status = response.status();
let body = response
.text()
.await
.map_err(|e| OxidizedError::ApiUnreachable {
source: e,
attempt: 1,
last_success: None,
})?;
if let Some(err) = self.check_node_not_found_body(&body, context) {
return Err(err);
}
self.check_status(status, context)?;
Ok(())
}
fn build_post_request(&self, endpoint: &str) -> reqwest::RequestBuilder {
let url = format!("{}{}", self.base_url, endpoint);
let mut request = self.client.post(&url);
for (name, value) in &self.custom_headers {
request = request.header(name.as_str(), value.as_str());
}
if let Some(auth) = &self.auth {
let has_custom_auth = self
.custom_headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("Authorization"));
if !has_custom_auth {
request = request.basic_auth(&auth.username, Some(&auth.password));
}
}
request
}
pub fn parse_conf_search_html(html: &str) -> Vec<String> {
let mut nodes: Vec<String> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for cap in TD_REGEX.captures_iter(html) {
if let Some(node_match) = cap.get(1) {
let node = node_match.as_str().trim();
if node.is_empty() || node.to_lowercase().contains("group") {
continue;
}
if seen.insert(node.to_string()) {
nodes.push(node.to_string());
}
}
}
nodes
}
fn check_node_not_found_body(&self, body: &str, context: &str) -> Option<OxidizedError> {
if body.contains("unable to find '") {
let node_name = body
.split("unable to find '")
.nth(1)
.and_then(|s| s.split('\'').next())
.unwrap_or(context);
return Some(OxidizedError::NodeNotFound(node_name.to_string(), vec![]));
}
if body.contains("Oxidized::NodeNotFound at /node/") {
let node_name = body
.split("Oxidized::NodeNotFound at /node/")
.nth(1)
.and_then(|s| {
if let Some(json_pos) = s.find(".json") {
let before_json = &s[..json_pos];
before_json.rfind('/').map(|pos| &before_json[pos + 1..])
} else {
None
}
})
.unwrap_or(context);
return Some(OxidizedError::NodeNotFound(node_name.to_string(), vec![]));
}
None
}
fn handle_request_error(
&self,
response: Result<reqwest::Response, reqwest::Error>,
) -> Result<reqwest::Response, OxidizedError> {
response.map_err(|e| OxidizedError::ApiUnreachable {
source: e,
attempt: 1,
last_success: None,
})
}
fn check_status(&self, status: StatusCode, context: &str) -> Result<(), OxidizedError> {
if status == StatusCode::NOT_FOUND {
return Err(OxidizedError::NodeNotFound(context.to_string(), vec![]));
}
if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN {
return Err(OxidizedError::AuthFailed);
}
if status.is_server_error() {
return Err(OxidizedError::HttpError {
status_code: status.as_u16(),
context: context.to_string(),
});
}
if status.is_client_error() {
return Err(OxidizedError::HttpError {
status_code: status.as_u16(),
context: context.to_string(),
});
}
Ok(())
}
}
#[async_trait]
impl OxidizedBackend for OxidizedClient {
#[instrument(skip(self), fields(url = %self.base_url))]
async fn get_nodes(&self) -> Result<(Vec<Node>, CacheMetadata), OxidizedError> {
if let Some(cached) = self.nodes_cache.get(&()).await {
tracing::debug!("Cache hit for nodes list");
return Ok((cached, CacheMetadata::hit()));
}
tracing::debug!("Cache miss for nodes list, fetching from API");
let nodes: Vec<Node> = self
.execute_with_retry(|| async {
let response = self.build_request("/nodes.json").send().await;
self.handle_json_response(response, "node list").await
})
.await?;
self.nodes_cache.insert((), nodes.clone()).await;
Ok((nodes, CacheMetadata::miss()))
}
#[instrument(skip(self), fields(url = %self.base_url, node = %name))]
async fn get_node(&self, name: &str) -> Result<(Node, CacheMetadata), OxidizedError> {
if let Some(cached) = self.node_cache.get(name).await {
tracing::debug!(node = %name, "Cache hit for node");
return Ok((cached, CacheMetadata::hit()));
}
tracing::debug!(node = %name, "Cache miss for node, fetching from API");
let endpoint = format!("/node/show/{}.json", urlencoding::encode(name));
let node: Node = self
.execute_with_retry(|| async {
let response = self.build_request(&endpoint).send().await;
self.handle_json_response(response, name).await
})
.await?;
self.node_cache.insert(name.to_string(), node.clone()).await;
Ok((node, CacheMetadata::miss()))
}
#[instrument(skip(self), fields(url = %self.base_url, node = %name))]
async fn get_node_config(&self, name: &str) -> Result<(String, CacheMetadata), OxidizedError> {
if let Some(cached) = self.config_cache.get(name).await {
tracing::debug!(node = %name, "Cache hit for config");
return Ok((cached, CacheMetadata::hit()));
}
tracing::debug!(node = %name, "Cache miss for config, fetching from API");
let endpoint = format!("/node/fetch/{}", urlencoding::encode(name));
let config = self
.execute_with_retry(|| async {
let response = self.build_request(&endpoint).send().await;
self.handle_text_response(response, name).await
})
.await?;
self.config_cache
.insert(name.to_string(), config.clone())
.await;
Ok((config, CacheMetadata::miss()))
}
#[instrument(skip(self), fields(url = %self.base_url, node = %name))]
async fn get_node_versions(&self, name: &str) -> Result<Vec<NodeVersion>, OxidizedError> {
let (node, _) = self.get_node(name).await?;
let endpoint = format!(
"/node/version.json?node_full={}",
urlencoding::encode(&node.full_name)
);
self.execute_with_retry(|| async {
let response = self.build_request(&endpoint).send().await;
self.handle_json_response(response, name).await
})
.await
}
#[instrument(skip(self), fields(url = %self.base_url, node = %name, oid = %oid))]
async fn get_node_version(&self, name: &str, oid: &str) -> Result<String, OxidizedError> {
let (node, _) = self.get_node(name).await?;
let endpoint = format!(
"/node/version/view.json?node={}&group={}&oid={}",
urlencoding::encode(name),
urlencoding::encode(&node.group),
oid
);
let context = format!("{}@{}", name, oid);
let lines: Vec<String> = self
.execute_with_retry(|| async {
let response = self.build_request(&endpoint).send().await;
self.handle_json_response(response, &context).await
})
.await?;
Ok(lines.join(""))
}
#[instrument(skip(self), fields(url = %self.base_url))]
async fn get_stats(&self) -> Result<(Stats, CacheMetadata), OxidizedError> {
if let Some(cached) = self.stats_cache.get(&()).await {
tracing::debug!("Cache hit for stats");
return Ok((cached, CacheMetadata::hit()));
}
tracing::debug!("Cache miss for stats, computing from nodes list");
let (nodes, _) = self.get_nodes().await?;
let stats = Stats::from_nodes(&nodes);
self.stats_cache.insert((), stats.clone()).await;
Ok((stats, CacheMetadata::miss()))
}
#[instrument(skip(self), fields(url = %self.base_url, node = %node))]
async fn trigger_backup(&self, node: &str) -> Result<(), OxidizedError> {
let endpoint = format!("/node/next/{}.json", urlencoding::encode(node));
let result = self
.execute_with_retry(|| async {
let response = self.build_request(&endpoint).send().await;
self.handle_empty_response(response, node).await
})
.await;
if result.is_ok() {
self.invalidate_node(node).await;
}
result
}
#[instrument(skip(self), fields(url = %self.base_url, node = %node))]
async fn prioritize_node(&self, node: &str) -> Result<(), OxidizedError> {
let endpoint = format!("/node/next/{}.json", urlencoding::encode(node));
let result = self
.execute_with_retry(|| async {
let response = self.build_request(&endpoint).send().await;
self.handle_empty_response(response, node).await
})
.await;
if result.is_ok() {
self.invalidate_node(node).await;
}
result
}
#[instrument(skip(self), fields(url = %self.base_url))]
async fn reload_sources(&self) -> Result<(), OxidizedError> {
let result = self
.execute_with_retry(|| async {
let response = self.build_request("/reload?format=json").send().await;
self.handle_empty_response(response, "reload").await
})
.await;
if result.is_ok() {
self.invalidate_all_nodes().await;
}
result
}
#[instrument(skip(self), fields(url = %self.base_url))]
async fn conf_search(&self, pattern: &str) -> Result<Vec<String>, OxidizedError> {
if pattern.is_empty() {
tracing::debug!("Empty pattern, skipping conf_search");
return Ok(vec![]);
}
let response = self
.build_post_request("/nodes/conf_search")
.form(&[("search_in_conf_textbox", pattern)])
.send()
.await;
let body = match response {
Ok(resp) => {
if !resp.status().is_success() {
tracing::debug!(
status = %resp.status(),
"conf_search API returned non-success status, falling back"
);
return Ok(vec![]);
}
match resp.text().await {
Ok(text) => text,
Err(e) => {
tracing::debug!(error = %e, "Failed to read conf_search response body");
return Ok(vec![]);
}
}
}
Err(e) => {
tracing::debug!(error = %e, "conf_search API unavailable, falling back");
return Ok(vec![]);
}
};
let nodes = Self::parse_conf_search_html(&body);
tracing::debug!(
pattern = %pattern,
nodes_found = nodes.len(),
"conf_search completed"
);
Ok(nodes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_node_deserialize() {
let json = r#"{
"name": "SW-Core-01",
"full_name": "SW-Core-01.network.local",
"ip": "192.168.1.1",
"group": "switches",
"model": "cisco-ios",
"status": "success",
"last_status": "success",
"time": "2025-01-15 10:30:00 UTC",
"mtime": "2025-01-15 10:25:00 UTC"
}"#;
let node: Node = serde_json::from_str(json).expect("Should deserialize Node");
assert_eq!(node.name, "SW-Core-01");
assert_eq!(node.full_name, "SW-Core-01.network.local");
assert_eq!(node.ip, "192.168.1.1");
assert_eq!(node.group, "switches");
assert_eq!(node.model, "cisco-ios");
assert_eq!(node.status, Some("success".to_string()));
assert_eq!(node.last_status, Some("success".to_string()));
assert_eq!(node.time, Some("2025-01-15 10:30:00 UTC".to_string()));
assert_eq!(node.mtime, Some("2025-01-15 10:25:00 UTC".to_string()));
}
#[test]
fn test_node_deserialize_without_optional_fields() {
let json = r#"{
"name": "SW-Core-01",
"full_name": "SW-Core-01.network.local",
"ip": "192.168.1.1",
"group": "switches",
"model": "cisco-ios",
"status": "never",
"last_status": "never"
}"#;
let node: Node =
serde_json::from_str(json).expect("Should deserialize Node without optionals");
assert_eq!(node.name, "SW-Core-01");
assert_eq!(node.time, None);
assert_eq!(node.mtime, None);
}
#[test]
fn test_node_deserialize_oxidized_035_format() {
let json = r#"{
"name": "Palais-Tech",
"full_name": "mikrotik/Palais-Tech",
"ip": "10.255.42.25",
"group": "mikrotik",
"model": "RouterOS",
"status": "success",
"time": "2025-12-23 09:01:42 UTC",
"mtime": "2025-12-23 09:01:42 UTC"
}"#;
let node: Node =
serde_json::from_str(json).expect("Should deserialize Oxidized 0.35.0 Node");
assert_eq!(node.name, "Palais-Tech");
assert_eq!(node.full_name, "mikrotik/Palais-Tech");
assert_eq!(node.group, "mikrotik");
assert_eq!(node.model, "RouterOS");
assert_eq!(node.status, Some("success".to_string()));
assert_eq!(node.last_status, None);
assert_eq!(node.time, Some("2025-12-23 09:01:42 UTC".to_string()));
}
#[test]
fn test_node_version_deserialize() {
let json = r#"{
"oid": "abc123def456",
"date": "2025-01-15 10:30:00 UTC",
"time": "2025-01-15 10:30:00 UTC",
"author": {
"name": "oxidized",
"email": "oxidized@example.com",
"time": "2025-01-15 10:30:00 UTC"
},
"message": "update SW-Core-01"
}"#;
let version: NodeVersion =
serde_json::from_str(json).expect("Should deserialize NodeVersion");
assert_eq!(version.oid, "abc123def456");
assert_eq!(version.date, "2025-01-15 10:30:00 UTC");
assert_eq!(version.time, Some("2025-01-15 10:30:00 UTC".to_string()));
assert_eq!(version.author.name, "oxidized");
assert_eq!(version.author.email, "oxidized@example.com");
assert_eq!(version.message, "update SW-Core-01");
}
#[test]
fn test_stats_deserialize() {
let json = r#"{
"total_nodes": 150,
"success_count": 145,
"failure_count": 5,
"last_run": "2025-01-15 10:30:00 UTC"
}"#;
let stats: Stats = serde_json::from_str(json).expect("Should deserialize Stats");
assert_eq!(stats.total_nodes, Some(150));
assert_eq!(stats.success_count, Some(145));
assert_eq!(stats.failure_count, Some(5));
assert_eq!(stats.last_run, Some("2025-01-15 10:30:00 UTC".to_string()));
}
#[test]
fn test_stats_deserialize_partial() {
let json = r#"{
"total_nodes": 10
}"#;
let stats: Stats = serde_json::from_str(json).expect("Should deserialize partial Stats");
assert_eq!(stats.total_nodes, Some(10));
assert_eq!(stats.success_count, None);
}
#[test]
fn test_stats_from_nodes() {
let nodes = vec![
Node {
name: "node1".to_string(),
full_name: "group/node1".to_string(),
ip: "10.0.0.1".to_string(),
group: "test".to_string(),
model: "cisco".to_string(),
status: Some("success".to_string()),
last_status: None,
time: Some("2025-12-23 09:01:42 UTC".to_string()),
mtime: None,
last: None,
},
Node {
name: "node2".to_string(),
full_name: "group/node2".to_string(),
ip: "10.0.0.2".to_string(),
group: "test".to_string(),
model: "cisco".to_string(),
status: Some("success".to_string()),
last_status: None,
time: Some("2025-12-23 09:00:00 UTC".to_string()),
mtime: None,
last: None,
},
Node {
name: "node3".to_string(),
full_name: "group/node3".to_string(),
ip: "10.0.0.3".to_string(),
group: "test".to_string(),
model: "cisco".to_string(),
status: Some("failure".to_string()),
last_status: None,
time: Some("2025-12-22 10:00:00 UTC".to_string()),
mtime: None,
last: None,
},
Node {
name: "node4".to_string(),
full_name: "group/node4".to_string(),
ip: "10.0.0.4".to_string(),
group: "test".to_string(),
model: "cisco".to_string(),
status: Some("never".to_string()),
last_status: None,
time: Some("never".to_string()),
mtime: None,
last: None,
},
];
let stats = Stats::from_nodes(&nodes);
assert_eq!(stats.total_nodes, Some(4));
assert_eq!(stats.success_count, Some(2));
assert_eq!(stats.failure_count, Some(2)); assert_eq!(stats.last_run, Some("2025-12-23 09:01:42 UTC".to_string()));
}
#[test]
fn test_stats_from_nodes_empty() {
let nodes: Vec<Node> = vec![];
let stats = Stats::from_nodes(&nodes);
assert_eq!(stats.total_nodes, Some(0));
assert_eq!(stats.success_count, Some(0));
assert_eq!(stats.failure_count, Some(0));
assert_eq!(stats.last_run, None);
}
#[test]
fn test_client_new_without_auth() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
assert_eq!(client.base_url, "http://localhost:8888");
assert!(client.auth.is_none());
}
#[test]
fn test_client_new_with_auth() {
let config = Config {
oxidized_url: "https://oxidized.example.com".to_string(),
oxidized_user: Some("admin".to_string()),
oxidized_password: Some("secret".to_string()),
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
assert_eq!(client.base_url, "https://oxidized.example.com");
assert!(client.auth.is_some());
}
#[test]
fn test_client_new_with_partial_auth_no_password() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: Some("admin".to_string()),
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
assert!(client.auth.is_none());
}
#[test]
fn test_client_new_with_partial_auth_no_user() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: Some("secret".to_string()),
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
assert!(client.auth.is_none());
}
#[test]
fn test_check_status_not_found() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let result = client.check_status(StatusCode::NOT_FOUND, "test-node");
assert!(result.is_err());
match result.unwrap_err() {
OxidizedError::NodeNotFound(name, _) => {
assert_eq!(name, "test-node");
}
_ => panic!("Expected NodeNotFound error"),
}
}
#[test]
fn test_check_node_not_found_body_oxidized_035() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let body = "Oxidized::NodeNotFound: unable to find 'NONEXISTENT' (Oxidized::NodeNotFound)";
let result = client.check_node_not_found_body(body, "context");
assert!(result.is_some());
match result.unwrap() {
OxidizedError::NodeNotFound(name, _) => {
assert_eq!(name, "NONEXISTENT");
}
_ => panic!("Expected NodeNotFound error"),
}
}
#[test]
fn test_check_node_not_found_body_no_match() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let body = r#"{"name": "SW-Core-01", "status": "success"}"#;
let result = client.check_node_not_found_body(body, "context");
assert!(result.is_none());
}
#[test]
fn test_check_node_not_found_body_html_format() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let body = r#"<!DOCTYPE html>
<html>
<head>
<title>Oxidized::NodeNotFound at /node/show/test-node-123.json</title>
</head>
<body>...</body>
</html>"#;
let result = client.check_node_not_found_body(body, "context");
assert!(result.is_some());
match result.unwrap() {
OxidizedError::NodeNotFound(name, _) => {
assert_eq!(name, "test-node-123");
}
_ => panic!("Expected NodeNotFound error"),
}
}
#[test]
fn test_check_status_unauthorized() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let result = client.check_status(StatusCode::UNAUTHORIZED, "test");
assert!(result.is_err());
match result.unwrap_err() {
OxidizedError::AuthFailed => {}
_ => panic!("Expected AuthFailed error"),
}
}
#[test]
fn test_check_status_forbidden() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let result = client.check_status(StatusCode::FORBIDDEN, "test");
assert!(result.is_err());
match result.unwrap_err() {
OxidizedError::AuthFailed => {}
_ => panic!("Expected AuthFailed error"),
}
}
#[test]
fn test_check_status_success() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let result = client.check_status(StatusCode::OK, "test");
assert!(result.is_ok());
}
#[test]
fn test_basic_auth_new() {
let auth = BasicAuth::new("user".to_string(), "pass".to_string());
assert_eq!(auth.username, "user");
assert_eq!(auth.password, "pass");
}
#[test]
fn test_node_list_deserialize() {
let json = r#"[
{
"name": "SW-Core-01",
"full_name": "SW-Core-01.network.local",
"ip": "192.168.1.1",
"group": "switches",
"model": "cisco-ios",
"status": "success",
"last_status": "success"
},
{
"name": "RTR-Edge-01",
"full_name": "RTR-Edge-01.network.local",
"ip": "192.168.1.2",
"group": "routers",
"model": "junos",
"status": "failure",
"last_status": "success"
}
]"#;
let nodes: Vec<Node> = serde_json::from_str(json).expect("Should deserialize node list");
assert_eq!(nodes.len(), 2);
assert_eq!(nodes[0].name, "SW-Core-01");
assert_eq!(nodes[1].name, "RTR-Edge-01");
}
#[test]
fn test_node_deserialize_from_fixture() {
let json = std::fs::read_to_string("fixtures/node.json").expect("Should read fixture file");
let node: Node = serde_json::from_str(&json).expect("Should deserialize Node from fixture");
assert_eq!(node.name, "SW-Core-01");
assert_eq!(node.full_name, "SW-Core-01.network.local");
assert_eq!(node.ip, "192.168.1.1");
assert_eq!(node.group, "switches");
assert_eq!(node.model, "cisco-ios");
assert_eq!(node.status, Some("success".to_string()));
}
#[test]
fn test_nodes_list_deserialize_from_fixture() {
let json =
std::fs::read_to_string("fixtures/nodes.json").expect("Should read fixture file");
let nodes: Vec<Node> =
serde_json::from_str(&json).expect("Should deserialize nodes from fixture");
assert_eq!(nodes.len(), 5);
assert_eq!(nodes[0].name, "SW-Core-01");
assert_eq!(nodes[2].name, "RTR-Edge-01");
assert_eq!(nodes[2].status, Some("failure".to_string()));
assert_eq!(nodes[4].name, "AP-Floor3-01");
assert_eq!(nodes[4].time, None);
assert_eq!(nodes[4].mtime, None);
}
#[test]
fn test_stats_deserialize_from_fixture() {
let json =
std::fs::read_to_string("fixtures/stats.json").expect("Should read fixture file");
let stats: Stats =
serde_json::from_str(&json).expect("Should deserialize Stats from fixture");
assert_eq!(stats.total_nodes, Some(150));
assert_eq!(stats.success_count, Some(142));
assert_eq!(stats.failure_count, Some(5));
assert!(stats.last_run.is_some());
}
#[test]
fn test_versions_deserialize_from_fixture() {
let json =
std::fs::read_to_string("fixtures/versions.json").expect("Should read fixture file");
let versions: Vec<NodeVersion> =
serde_json::from_str(&json).expect("Should deserialize versions from fixture");
assert_eq!(versions.len(), 5);
assert_eq!(versions[0].oid, "abc123def456789012345678901234567890abcd");
assert_eq!(versions[0].author.name, "oxidized");
assert_eq!(versions[0].author.email, "oxidized@example.com");
assert!(versions[0].message.contains("SW-Core-01"));
}
#[test]
fn test_check_status_server_error() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let result = client.check_status(StatusCode::INTERNAL_SERVER_ERROR, "test");
assert!(result.is_err());
match result.unwrap_err() {
OxidizedError::HttpError {
status_code,
context,
} => {
assert_eq!(status_code, 500);
assert_eq!(context, "test");
}
_ => panic!("Expected HttpError"),
}
}
#[test]
fn test_check_status_bad_gateway() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let result = client.check_status(StatusCode::BAD_GATEWAY, "proxy");
assert!(result.is_err());
match result.unwrap_err() {
OxidizedError::HttpError { status_code, .. } => {
assert_eq!(status_code, 502);
}
_ => panic!("Expected HttpError"),
}
}
#[test]
fn test_check_status_bad_request() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let result = client.check_status(StatusCode::BAD_REQUEST, "invalid");
assert!(result.is_err());
match result.unwrap_err() {
OxidizedError::HttpError { status_code, .. } => {
assert_eq!(status_code, 400);
}
_ => panic!("Expected HttpError"),
}
}
#[test]
#[allow(clippy::assertions_on_constants)]
fn test_timeout_constants_are_reasonable() {
assert!(
DEFAULT_CONNECT_TIMEOUT_SECS < DEFAULT_REQUEST_TIMEOUT_SECS,
"Connect timeout should be less than request timeout"
);
assert!(
DEFAULT_CONNECT_TIMEOUT_SECS >= 5,
"Connect timeout should be at least 5 seconds"
);
assert!(
DEFAULT_REQUEST_TIMEOUT_SECS >= 15,
"Request timeout should be at least 15 seconds"
);
assert!(
DEFAULT_REQUEST_TIMEOUT_SECS <= 60,
"Request timeout should not exceed 60 seconds"
);
}
#[test]
fn test_client_uses_timeout_constants() {
assert_eq!(
DEFAULT_CONNECT_TIMEOUT_SECS, 10,
"Connect timeout should be 10s"
);
assert_eq!(
DEFAULT_REQUEST_TIMEOUT_SECS, 30,
"Request timeout should be 30s"
);
}
#[test]
fn test_nodes_cache_ttl_is_5_minutes() {
assert_eq!(
NODES_CACHE_TTL_SECS, 300,
"Nodes cache TTL should be 5 minutes (300 seconds)"
);
}
#[test]
fn test_config_cache_ttl_is_2_minutes() {
assert_eq!(
CONFIG_CACHE_TTL_SECS, 120,
"Config cache TTL should be 2 minutes (120 seconds)"
);
}
#[test]
fn test_stats_cache_ttl_is_30_seconds() {
assert_eq!(
STATS_CACHE_TTL_SECS, 30,
"Stats cache TTL should be 30 seconds"
);
}
#[test]
fn test_max_retry_attempts_is_3() {
assert_eq!(
MAX_RETRY_ATTEMPTS, 3,
"Max retry attempts should be 3 (initial + 2 retries)"
);
}
#[test]
fn test_retry_delays_are_exponential() {
assert_eq!(
RETRY_DELAYS_MS,
[200, 800],
"Retry delays should be [200ms, 800ms]"
);
assert!(
RETRY_DELAYS_MS[1] > RETRY_DELAYS_MS[0],
"Second delay should be greater than first"
);
}
#[test]
fn test_cache_metadata_hit() {
let meta = CacheMetadata::hit();
assert!(
meta.cache_hit,
"CacheMetadata::hit() should set cache_hit to true"
);
}
#[test]
fn test_cache_metadata_miss() {
let meta = CacheMetadata::miss();
assert!(
!meta.cache_hit,
"CacheMetadata::miss() should set cache_hit to false"
);
}
#[test]
fn test_cache_metadata_serializes_correctly() {
let hit = CacheMetadata::hit();
let json = serde_json::to_string(&hit).expect("Should serialize CacheMetadata");
assert!(
json.contains("\"cache_hit\":true"),
"Should serialize cache_hit field"
);
let miss = CacheMetadata::miss();
let json = serde_json::to_string(&miss).expect("Should serialize CacheMetadata");
assert!(
json.contains("\"cache_hit\":false"),
"Should serialize cache_hit field"
);
}
#[test]
fn test_cached_nodes_serializes() {
let nodes = vec![Node {
name: "SW-01".to_string(),
full_name: "SW-01.local".to_string(),
ip: "10.0.0.1".to_string(),
group: "switches".to_string(),
model: "cisco".to_string(),
status: Some("success".to_string()),
last_status: Some("success".to_string()),
time: None,
mtime: None,
last: None,
}];
let cached = CachedNodes {
nodes,
metadata: CacheMetadata::hit(),
};
let json = serde_json::to_string(&cached).expect("Should serialize CachedNodes");
assert!(json.contains("\"nodes\""), "Should contain nodes field");
assert!(
json.contains("\"metadata\""),
"Should contain metadata field"
);
assert!(
json.contains("\"cache_hit\":true"),
"Should indicate cache hit"
);
}
#[test]
fn test_cached_config_serializes() {
let cached = CachedConfig {
config: "hostname SW-01\n".to_string(),
metadata: CacheMetadata::miss(),
};
let json = serde_json::to_string(&cached).expect("Should serialize CachedConfig");
assert!(json.contains("\"config\""), "Should contain config field");
assert!(
json.contains("\"cache_hit\":false"),
"Should indicate cache miss"
);
}
#[test]
fn test_cached_stats_serializes() {
let stats = Stats {
total_nodes: Some(100),
success_count: Some(95),
failure_count: Some(5),
last_run: Some("2025-01-15".to_string()),
};
let cached = CachedStats {
stats,
metadata: CacheMetadata::hit(),
};
let json = serde_json::to_string(&cached).expect("Should serialize CachedStats");
assert!(json.contains("\"stats\""), "Should contain stats field");
assert!(
json.contains("\"total_nodes\":100"),
"Should contain stats data"
);
}
#[tokio::test]
async fn test_retry_succeeds_on_first_attempt() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let result: Result<String, OxidizedError> = client
.execute_with_retry(|| async { Ok("success".to_string()) })
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "success");
}
#[tokio::test]
async fn test_retry_non_transient_error_fails_immediately() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
use std::sync::atomic::{AtomicU8, Ordering};
let attempt_count = std::sync::Arc::new(AtomicU8::new(0));
let counter = attempt_count.clone();
let result: Result<String, OxidizedError> = client
.execute_with_retry(|| {
let counter = counter.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
Err(OxidizedError::NodeNotFound("test".to_string(), vec![]))
}
})
.await;
assert!(result.is_err());
assert_eq!(
attempt_count.load(Ordering::SeqCst),
1,
"Non-transient error should not retry"
);
}
#[tokio::test]
async fn test_retry_transient_error_retries_up_to_max() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
use std::sync::atomic::{AtomicU8, Ordering};
let attempt_count = std::sync::Arc::new(AtomicU8::new(0));
let counter = attempt_count.clone();
let result: Result<String, OxidizedError> = client
.execute_with_retry(|| {
let counter = counter.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
Err(OxidizedError::HttpError {
status_code: 503,
context: "test".to_string(),
})
}
})
.await;
assert!(result.is_err());
assert_eq!(
attempt_count.load(Ordering::SeqCst),
MAX_RETRY_ATTEMPTS,
"Should retry up to max attempts for transient errors"
);
}
#[tokio::test]
async fn test_retry_succeeds_after_transient_failure() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
use std::sync::atomic::{AtomicU8, Ordering};
let attempt_count = std::sync::Arc::new(AtomicU8::new(0));
let counter = attempt_count.clone();
let result: Result<String, OxidizedError> = client
.execute_with_retry(|| {
let counter = counter.clone();
async move {
let attempt = counter.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
Err(OxidizedError::HttpError {
status_code: 500,
context: "test".to_string(),
})
} else {
Ok("success after retry".to_string())
}
}
})
.await;
assert!(result.is_ok());
assert_eq!(result.unwrap(), "success after retry");
assert_eq!(
attempt_count.load(Ordering::SeqCst),
2,
"Should succeed on second attempt"
);
}
#[test]
fn test_client_initializes_all_caches() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
tokio::runtime::Runtime::new().unwrap().block_on(async {
assert!(
client.nodes_cache.get(&()).await.is_none(),
"nodes_cache should be empty"
);
assert!(
client.node_cache.get("test").await.is_none(),
"node_cache should be empty"
);
assert!(
client.config_cache.get("test").await.is_none(),
"config_cache should be empty"
);
assert!(
client.stats_cache.get(&()).await.is_none(),
"stats_cache should be empty"
);
});
}
#[tokio::test]
async fn test_invalidate_node_clears_node_and_config_cache() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let node = Node {
name: "test-node".to_string(),
full_name: "test-node.local".to_string(),
ip: "10.0.0.1".to_string(),
group: "test".to_string(),
model: "test".to_string(),
status: Some("success".to_string()),
last_status: Some("success".to_string()),
time: None,
mtime: None,
last: None,
};
client
.node_cache
.insert("test-node".to_string(), node)
.await;
client
.config_cache
.insert("test-node".to_string(), "config data".to_string())
.await;
assert!(client.node_cache.get("test-node").await.is_some());
assert!(client.config_cache.get("test-node").await.is_some());
client.invalidate_node("test-node").await;
assert!(
client.node_cache.get("test-node").await.is_none(),
"node_cache should be invalidated"
);
assert!(
client.config_cache.get("test-node").await.is_none(),
"config_cache should be invalidated"
);
}
#[tokio::test]
async fn test_invalidate_node_does_not_affect_other_nodes() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let node1 = Node {
name: "node1".to_string(),
full_name: "node1.local".to_string(),
ip: "10.0.0.1".to_string(),
group: "test".to_string(),
model: "test".to_string(),
status: Some("success".to_string()),
last_status: Some("success".to_string()),
time: None,
mtime: None,
last: None,
};
let node2 = Node {
name: "node2".to_string(),
full_name: "node2.local".to_string(),
ip: "10.0.0.2".to_string(),
group: "test".to_string(),
model: "test".to_string(),
status: Some("success".to_string()),
last_status: Some("success".to_string()),
time: None,
mtime: None,
last: None,
};
client.node_cache.insert("node1".to_string(), node1).await;
client.node_cache.insert("node2".to_string(), node2).await;
client.invalidate_node("node1").await;
assert!(
client.node_cache.get("node1").await.is_none(),
"node1 should be invalidated"
);
assert!(
client.node_cache.get("node2").await.is_some(),
"node2 should remain cached"
);
}
#[tokio::test]
async fn test_invalidate_all_nodes_clears_all_caches() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let node = Node {
name: "test".to_string(),
full_name: "test.local".to_string(),
ip: "10.0.0.1".to_string(),
group: "test".to_string(),
model: "test".to_string(),
status: Some("success".to_string()),
last_status: Some("success".to_string()),
time: None,
mtime: None,
last: None,
};
let stats = Stats {
total_nodes: Some(10),
success_count: Some(10),
failure_count: Some(0),
last_run: None,
};
client.nodes_cache.insert((), vec![node.clone()]).await;
client.node_cache.insert("test".to_string(), node).await;
client
.config_cache
.insert("test".to_string(), "config".to_string())
.await;
client.stats_cache.insert((), stats).await;
assert!(client.nodes_cache.get(&()).await.is_some());
assert!(client.node_cache.get("test").await.is_some());
assert!(client.config_cache.get("test").await.is_some());
assert!(client.stats_cache.get(&()).await.is_some());
client.invalidate_all_nodes().await;
assert!(
client.nodes_cache.get(&()).await.is_none(),
"nodes_cache should be invalidated"
);
assert!(
client.node_cache.get("test").await.is_none(),
"node_cache should be invalidated"
);
assert!(
client.config_cache.get("test").await.is_none(),
"config_cache should be invalidated"
);
assert!(
client.stats_cache.get(&()).await.is_none(),
"stats_cache should be invalidated"
);
}
#[test]
fn test_parse_conf_search_html_real_oxidized_response() {
let html = r#"
<table class='table' id='versionsTable'>
<tbody>
<tr>
<td>PDC-SW-ETG2-B</td>
<td><a href='/node/fetch/PDC-SW-ETG2-B'><i class='bi bi-cloud-download'></i></a></td>
</tr>
<tr>
<td>SW-Core-01</td>
<td><a href='/node/fetch/SW-Core-01'><i class='bi bi-cloud-download'></i></a></td>
</tr>
<tr>
<td>RTR-Edge-02</td>
<td><a href='/node/fetch/RTR-Edge-02'><i class='bi bi-cloud-download'></i></a></td>
</tr>
</tbody>
</table>
"#;
let nodes = OxidizedClient::parse_conf_search_html(html);
assert_eq!(nodes.len(), 3);
assert!(nodes.contains(&"PDC-SW-ETG2-B".to_string()));
assert!(nodes.contains(&"SW-Core-01".to_string()));
assert!(nodes.contains(&"RTR-Edge-02".to_string()));
}
#[test]
fn test_parse_conf_search_html_empty_table() {
let html = r#"
<table class='table' id='versionsTable'>
<tbody>
</tbody>
</table>
"#;
let nodes = OxidizedClient::parse_conf_search_html(html);
assert!(nodes.is_empty(), "Empty table should return empty list");
}
#[test]
fn test_parse_conf_search_html_malformed_html() {
let html = "<html><body>No table here</body></html>";
let nodes = OxidizedClient::parse_conf_search_html(html);
assert!(nodes.is_empty(), "Malformed HTML should return empty list");
}
#[test]
fn test_parse_conf_search_html_filters_group_entries() {
let html = r#"
<table>
<tr><td>my-group</td></tr>
<tr><td>SW-Core-01</td></tr>
<tr><td>group-routers</td></tr>
<tr><td>RTR-Edge-01</td></tr>
</table>
"#;
let nodes = OxidizedClient::parse_conf_search_html(html);
assert_eq!(nodes.len(), 2);
assert!(nodes.contains(&"SW-Core-01".to_string()));
assert!(nodes.contains(&"RTR-Edge-01".to_string()));
assert!(!nodes.iter().any(|n| n.to_lowercase().contains("group")));
}
#[test]
fn test_parse_conf_search_html_filters_empty_strings() {
let html = r#"
<table>
<tr><td></td></tr>
<tr><td> </td></tr>
<tr><td>SW-Core-01</td></tr>
</table>
"#;
let nodes = OxidizedClient::parse_conf_search_html(html);
assert_eq!(nodes.len(), 1);
assert_eq!(nodes[0], "SW-Core-01");
}
#[test]
fn test_parse_conf_search_html_deduplicates() {
let html = r#"
<table>
<tr><td>SW-Core-01</td></tr>
<tr><td>RTR-Edge-01</td></tr>
<tr><td>SW-Core-01</td></tr>
<tr><td>SW-Core-01</td></tr>
</table>
"#;
let nodes = OxidizedClient::parse_conf_search_html(html);
assert_eq!(nodes.len(), 2, "Should deduplicate nodes");
assert!(nodes.contains(&"SW-Core-01".to_string()));
assert!(nodes.contains(&"RTR-Edge-01".to_string()));
}
#[test]
fn test_parse_conf_search_html_trims_whitespace() {
let html = r#"
<table>
<tr><td> SW-Core-01 </td></tr>
<tr><td>
RTR-Edge-01
</td></tr>
</table>
"#;
let nodes = OxidizedClient::parse_conf_search_html(html);
assert_eq!(nodes.len(), 2);
assert!(
nodes.contains(&"SW-Core-01".to_string()),
"Should trim leading/trailing whitespace"
);
assert!(
nodes.contains(&"RTR-Edge-01".to_string()),
"Should trim newlines and whitespace"
);
}
#[test]
fn test_parse_conf_search_html_no_matches() {
let html = r#"
<div class="alert alert-info">
No results found for pattern 'nonexistent'
</div>
"#;
let nodes = OxidizedClient::parse_conf_search_html(html);
assert!(nodes.is_empty(), "No matches should return empty list");
}
#[tokio::test]
async fn test_conf_search_empty_pattern_returns_empty() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
let result = client.conf_search("").await;
assert!(result.is_ok(), "Empty pattern should not error");
assert!(
result.unwrap().is_empty(),
"Empty pattern should return empty list"
);
}
#[test]
fn test_client_applies_ssl_verify_false() {
let config = Config {
oxidized_url: "https://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: false,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
assert_eq!(client.base_url, "https://localhost:8888");
}
#[test]
fn test_client_applies_ssl_verify_true() {
let config = Config {
oxidized_url: "https://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
assert_eq!(client.base_url, "https://localhost:8888");
}
#[test]
fn test_client_stores_custom_headers() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![("X-Api-Key".to_string(), "secret123".to_string())],
};
let client = OxidizedClient::new(&config);
assert_eq!(client.custom_headers.len(), 1);
assert_eq!(client.custom_headers[0].0, "X-Api-Key");
assert_eq!(client.custom_headers[0].1, "secret123");
}
#[test]
fn test_client_stores_multiple_custom_headers() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![
("X-Api-Key".to_string(), "secret123".to_string()),
("X-Custom".to_string(), "value".to_string()),
],
};
let client = OxidizedClient::new(&config);
assert_eq!(client.custom_headers.len(), 2);
}
#[test]
fn test_client_empty_custom_headers() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: true,
custom_headers: vec![],
};
let client = OxidizedClient::new(&config);
assert!(client.custom_headers.is_empty());
}
#[test]
fn test_client_with_auth_and_no_custom_auth() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: Some("admin".to_string()),
oxidized_password: Some("secret".to_string()),
ssl_verify: true,
custom_headers: vec![("X-Api-Key".to_string(), "apikey".to_string())],
};
let client = OxidizedClient::new(&config);
assert!(client.auth.is_some());
assert!(
!client
.custom_headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("Authorization"))
);
}
#[test]
fn test_client_with_auth_and_custom_auth() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: Some("admin".to_string()),
oxidized_password: Some("secret".to_string()),
ssl_verify: true,
custom_headers: vec![
("Authorization".to_string(), "Bearer token123".to_string()),
("X-Api-Key".to_string(), "apikey".to_string()),
],
};
let client = OxidizedClient::new(&config);
assert!(client.auth.is_some());
assert!(
client
.custom_headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("Authorization"))
);
}
#[test]
fn test_client_with_custom_auth_case_insensitive() {
let config = Config {
oxidized_url: "http://localhost:8888".to_string(),
oxidized_user: Some("admin".to_string()),
oxidized_password: Some("secret".to_string()),
ssl_verify: true,
custom_headers: vec![("authorization".to_string(), "Bearer token".to_string())],
};
let client = OxidizedClient::new(&config);
assert!(
client
.custom_headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("Authorization"))
);
}
#[test]
fn test_client_combined_ssl_and_headers() {
let config = Config {
oxidized_url: "https://oxidized.example.com".to_string(),
oxidized_user: None,
oxidized_password: None,
ssl_verify: false,
custom_headers: vec![
("X-Api-Key".to_string(), "secret".to_string()),
("X-Custom".to_string(), "value".to_string()),
],
};
let client = OxidizedClient::new(&config);
assert_eq!(client.base_url, "https://oxidized.example.com");
assert_eq!(client.custom_headers.len(), 2);
}
#[test]
fn test_urlencoding_node_name_with_space() {
let name = "router 1";
let encoded = urlencoding::encode(name);
assert_eq!(encoded, "router%201");
}
#[test]
fn test_urlencoding_node_name_with_slash() {
let name = "dc/switch-1";
let encoded = urlencoding::encode(name);
assert_eq!(encoded, "dc%2Fswitch-1");
}
#[test]
fn test_urlencoding_node_name_with_special_chars() {
let name = "router@dc1#01";
let encoded = urlencoding::encode(name);
assert_eq!(encoded, "router%40dc1%2301");
}
#[test]
fn test_urlencoding_node_name_utf8() {
let name = "routeur-été";
let encoded = urlencoding::encode(name);
assert_eq!(encoded, "routeur-%C3%A9t%C3%A9");
}
#[test]
fn test_urlencoding_node_name_plain() {
let name = "switch-core-01";
let encoded = urlencoding::encode(name);
assert_eq!(encoded, "switch-core-01");
}
#[test]
fn test_urlencoding_decode_roundtrip() {
let names = vec![
"router 1",
"dc/switch-1",
"routeur-été",
"node@group#123",
"plain-name",
];
for name in names {
let encoded = urlencoding::encode(name);
let decoded = urlencoding::decode(&encoded).expect("Should decode");
assert_eq!(decoded, name, "Roundtrip failed for: {}", name);
}
}
#[test]
fn test_endpoint_format_with_encoded_name() {
let name = "router 1";
let endpoint = format!("/node/show/{}.json", urlencoding::encode(name));
assert_eq!(endpoint, "/node/show/router%201.json");
}
#[test]
fn test_endpoint_format_with_encoded_group_and_name() {
let name = "switch 1";
let group = "dc/core";
let oid = "abc123";
let endpoint = format!(
"/node/version/view.json?node={}&group={}&oid={}",
urlencoding::encode(name),
urlencoding::encode(group),
oid
);
assert_eq!(
endpoint,
"/node/version/view.json?node=switch%201&group=dc%2Fcore&oid=abc123"
);
}
}