use crate::cloudflare::{Auth, TTL, WAFList}; use crate::domain; use crate::notifier::{ CompositeNotifier, Heartbeat, HeartbeatMonitor, HealthchecksMonitor, NotifierDyn, ShoutrrrNotifier, UptimeKumaMonitor, }; use crate::pp::{self, PP}; use crate::provider::{IpType, ProviderType}; use serde::Deserialize; use std::collections::HashMap; use std::env; use std::path::PathBuf; use std::time::Duration; // ============================================================ // Legacy JSON Config (backwards compatible with cloudflare-ddns) // ============================================================ #[derive(Debug, Deserialize, Clone)] pub struct LegacyConfig { pub cloudflare: Vec, #[serde(default = "default_true")] pub a: bool, #[serde(default = "default_true")] pub aaaa: bool, #[serde(rename = "purgeUnknownRecords", default)] pub purge_unknown_records: bool, #[serde(default = "default_ttl")] pub ttl: i64, } fn default_true() -> bool { true } fn default_ttl() -> i64 { 300 } #[derive(Debug, Deserialize, Clone)] pub struct LegacyCloudflareEntry { pub authentication: LegacyAuthentication, pub zone_id: String, pub subdomains: Vec, #[serde(default)] pub proxied: bool, } #[derive(Debug, Deserialize, Clone)] #[serde(untagged)] pub enum LegacySubdomainEntry { Detailed { name: String, proxied: bool }, Simple(String), } #[derive(Debug, Deserialize, Clone)] pub struct LegacyAuthentication { #[serde(default)] pub api_token: String, #[serde(default)] pub api_key: Option, } #[derive(Debug, Deserialize, Clone)] pub struct LegacyApiKey { pub api_key: String, pub account_email: String, } // ============================================================ // Unified Config (supports both legacy JSON and env var modes) // ============================================================ /// The complete application configuration pub struct AppConfig { pub auth: Auth, pub providers: HashMap, pub domains: HashMap>, // FQDN domains by IP type pub waf_lists: Vec, pub update_cron: CronSchedule, pub update_on_start: bool, pub delete_on_stop: bool, pub ttl: TTL, pub proxied_expression: Option bool + Send + Sync>>, pub record_comment: Option, pub managed_comment_regex: Option, pub waf_list_description: Option, pub waf_list_item_comment: Option, pub managed_waf_comment_regex: Option, pub detection_timeout: Duration, pub update_timeout: Duration, pub reject_cloudflare_ips: bool, pub dry_run: bool, pub emoji: bool, pub quiet: bool, // Legacy mode fields pub legacy_mode: bool, pub legacy_config: Option, pub repeat: bool, } /// Cron schedule #[derive(Debug, Clone)] pub enum CronSchedule { Every(Duration), Once, } impl CronSchedule { pub fn describe(&self) -> String { match self { CronSchedule::Every(d) => format!("@every {}s", d.as_secs()), CronSchedule::Once => "@once".to_string(), } } pub fn next_duration(&self) -> Option { match self { CronSchedule::Every(d) => Some(*d), CronSchedule::Once => None, } } } fn parse_duration_string(s: &str) -> Option { let s = s.trim(); if let Some(minutes) = s.strip_suffix('m') { minutes.parse::().ok().map(|m| Duration::from_secs(m * 60)) } else if let Some(hours) = s.strip_suffix('h') { hours.parse::().ok().map(|h| Duration::from_secs(h * 3600)) } else if let Some(secs) = s.strip_suffix('s') { secs.parse::().ok().map(Duration::from_secs) } else { // Try as seconds s.parse::().ok().map(Duration::from_secs) } } // ============================================================ // Environment Variable Configuration (cf-ddns mode) // ============================================================ fn getenv(key: &str) -> Option { env::var(key).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) } fn getenv_bool(key: &str, default: bool) -> bool { match getenv(key) { Some(v) => matches!(v.to_lowercase().as_str(), "true" | "1" | "yes"), None => default, } } fn getenv_duration(key: &str, default: Duration) -> Duration { match getenv(key) { Some(v) => parse_duration_string(&v).unwrap_or(default), None => default, } } fn getenv_list(key: &str, sep: char) -> Vec { match getenv(key) { Some(v) => v .split(sep) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(), None => Vec::new(), } } fn read_auth_from_env(ppfmt: &PP) -> Option { // Try CLOUDFLARE_API_TOKEN first, then CF_API_TOKEN (deprecated) if let Some(token) = getenv("CLOUDFLARE_API_TOKEN").or_else(|| { let val = getenv("CF_API_TOKEN"); if val.is_some() { ppfmt.warningf( pp::EMOJI_WARNING, "CF_API_TOKEN is deprecated; use CLOUDFLARE_API_TOKEN instead", ); } val }) { if token == "YOUR-CLOUDFLARE-API-TOKEN" { ppfmt.errorf(pp::EMOJI_ERROR, "Please set CLOUDFLARE_API_TOKEN to your actual API token"); return None; } return Some(Auth::Token(token)); } // Try reading from file if let Some(path) = getenv("CLOUDFLARE_API_TOKEN_FILE").or_else(|| { let val = getenv("CF_API_TOKEN_FILE"); if val.is_some() { ppfmt.warningf( pp::EMOJI_WARNING, "CF_API_TOKEN_FILE is deprecated; use CLOUDFLARE_API_TOKEN_FILE instead", ); } val }) { match std::fs::read_to_string(&path) { Ok(content) => { let token = content.trim().to_string(); if !token.is_empty() { return Some(Auth::Token(token)); } } Err(e) => { ppfmt.errorf(pp::EMOJI_ERROR, &format!("Failed to read API token file '{path}': {e}")); } } } // Deprecated: CF_ACCOUNT_ID if getenv("CF_ACCOUNT_ID").is_some() { ppfmt.warningf( pp::EMOJI_WARNING, "CF_ACCOUNT_ID is deprecated and ignored since v1.14.0", ); } None } fn read_providers_from_env(ppfmt: &PP) -> Result, String> { let mut providers = HashMap::new(); let ip4_str = getenv("IP4_PROVIDER").or_else(|| { let val = getenv("IP4_POLICY"); if val.is_some() { ppfmt.warningf(pp::EMOJI_WARNING, "IP4_POLICY is deprecated; use IP4_PROVIDER instead"); } val }); let ip6_str = getenv("IP6_PROVIDER").or_else(|| { let val = getenv("IP6_POLICY"); if val.is_some() { ppfmt.warningf(pp::EMOJI_WARNING, "IP6_POLICY is deprecated; use IP6_PROVIDER instead"); } val }); let ip4_provider = match ip4_str { Some(s) => ProviderType::parse(&s) .map_err(|e| format!("Invalid IP4_PROVIDER: {e}"))?, None => ProviderType::CloudflareTrace { url: None }, }; let ip6_provider = match ip6_str { Some(s) => ProviderType::parse(&s) .map_err(|e| format!("Invalid IP6_PROVIDER: {e}"))?, None => ProviderType::CloudflareTrace { url: None }, }; if !matches!(ip4_provider, ProviderType::None) { providers.insert(IpType::V4, ip4_provider); } if !matches!(ip6_provider, ProviderType::None) { providers.insert(IpType::V6, ip6_provider); } Ok(providers) } fn read_domains_from_env(_ppfmt: &PP) -> HashMap> { let mut domains: HashMap> = HashMap::new(); let both = getenv_list("DOMAINS", ','); let ip4_only = getenv_list("IP4_DOMAINS", ','); let ip6_only = getenv_list("IP6_DOMAINS", ','); let mut v4_domains: Vec = both.clone(); v4_domains.extend(ip4_only); if !v4_domains.is_empty() { domains.insert(IpType::V4, v4_domains); } let mut v6_domains: Vec = both; v6_domains.extend(ip6_only); if !v6_domains.is_empty() { domains.insert(IpType::V6, v6_domains); } domains } fn read_waf_lists_from_env(ppfmt: &PP) -> Vec { let list_strs = getenv_list("WAF_LISTS", ','); let mut lists = Vec::new(); for s in list_strs { match WAFList::parse(&s) { Ok(list) => lists.push(list), Err(e) => { ppfmt.errorf(pp::EMOJI_ERROR, &format!("Invalid WAF_LISTS entry: {e}")); } } } lists } fn read_cron_from_env(ppfmt: &PP) -> Result { match getenv("UPDATE_CRON") { Some(s) => { let s = s.trim(); if s == "@once" { Ok(CronSchedule::Once) } else if s == "@disabled" || s == "@nevermore" { ppfmt.warningf( pp::EMOJI_WARNING, &format!("UPDATE_CRON={s} is deprecated; use @once instead"), ); Ok(CronSchedule::Once) } else if let Some(rest) = s.strip_prefix("@every ") { match parse_duration_string(rest) { Some(d) => Ok(CronSchedule::Every(d)), None => Err(format!("Invalid duration in UPDATE_CRON: {s}")), } } else { Err(format!( "Unsupported UPDATE_CRON format: {s}. Use @every , @once, or omit for default (5m)." )) } } None => Ok(CronSchedule::Every(Duration::from_secs(300))), } } fn read_regex(key: &str, ppfmt: &PP) -> Option { match getenv(key) { Some(s) if !s.is_empty() => match regex::Regex::new(&s) { Ok(r) => Some(r), Err(e) => { ppfmt.errorf(pp::EMOJI_ERROR, &format!("Invalid regex in {key}: {e}")); None } }, _ => None, } } // ============================================================ // JSON Config File with Env Var Substitution (legacy mode) // ============================================================ fn substitute_env_vars(input: &str) -> String { let mut result = input.to_string(); for (key, value) in env::vars() { if key.starts_with("CF_DDNS_") { result = result.replace(&format!("${key}"), value.as_str()); result = result.replace(&format!("${{{key}}}"), value.as_str()); } } result } pub fn load_legacy_config() -> Result { let config_path = env::var("CONFIG_PATH").unwrap_or_else(|_| ".".to_string()); let path = PathBuf::from(&config_path).join("config.json"); let content = std::fs::read_to_string(&path).map_err(|e| format!("Error reading config.json: {e}"))?; let content = substitute_env_vars(&content); let mut config: LegacyConfig = serde_json::from_str(&content).map_err(|e| format!("Error parsing config.json: {e}"))?; if config.ttl < 30 { println!("TTL is too low - defaulting to 1 (auto)"); config.ttl = 1; } Ok(config) } #[cfg(test)] pub fn parse_legacy_config(content: &str) -> Result { let mut config: LegacyConfig = serde_json::from_str(content).map_err(|e| format!("Error parsing config: {e}"))?; if config.ttl < 30 { config.ttl = 1; } Ok(config) } /// Convert a legacy config into a unified AppConfig fn legacy_to_app_config(legacy: LegacyConfig, dry_run: bool, repeat: bool) -> AppConfig { // Extract auth from first entry let auth = if let Some(entry) = legacy.cloudflare.first() { if !entry.authentication.api_token.is_empty() && entry.authentication.api_token != "api_token_here" { Auth::Token(entry.authentication.api_token.clone()) } else if let Some(api_key) = &entry.authentication.api_key { Auth::Key { api_key: api_key.api_key.clone(), email: api_key.account_email.clone(), } } else { Auth::Token(String::new()) } } else { Auth::Token(String::new()) }; // Build providers let mut providers = HashMap::new(); if legacy.a { providers.insert(IpType::V4, ProviderType::CloudflareTrace { url: None }); } if legacy.aaaa { providers.insert(IpType::V6, ProviderType::CloudflareTrace { url: None }); } let ttl = TTL::new(legacy.ttl); let schedule = if repeat { // Use TTL as interval in legacy mode CronSchedule::Every(Duration::from_secs(legacy.ttl.max(1) as u64)) } else { CronSchedule::Once }; AppConfig { auth, providers, domains: HashMap::new(), waf_lists: Vec::new(), update_cron: schedule, update_on_start: true, delete_on_stop: false, ttl, proxied_expression: None, record_comment: None, managed_comment_regex: None, waf_list_description: None, waf_list_item_comment: None, managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), reject_cloudflare_ips: false, dry_run, emoji: false, quiet: false, legacy_mode: true, legacy_config: Some(legacy), repeat, } } // ============================================================ // Detect config mode and load // ============================================================ /// Determine whether to use env var config (cf-ddns mode) or legacy JSON config. pub fn is_env_config_mode() -> bool { // If any cf-ddns env vars are set, use env mode getenv("CLOUDFLARE_API_TOKEN").is_some() || getenv("CF_API_TOKEN").is_some() || getenv("CLOUDFLARE_API_TOKEN_FILE").is_some() || getenv("CF_API_TOKEN_FILE").is_some() || getenv("DOMAINS").is_some() || getenv("IP4_DOMAINS").is_some() || getenv("IP6_DOMAINS").is_some() } /// Load configuration from environment variables (cf-ddns mode). pub fn load_env_config(ppfmt: &PP) -> Result { // Deprecated warnings if getenv("PUID").is_some() { ppfmt.warningf(pp::EMOJI_WARNING, "PUID is deprecated since v1.13.0 and ignored. Use Docker's built-in mechanism instead."); } if getenv("PGID").is_some() { ppfmt.warningf(pp::EMOJI_WARNING, "PGID is deprecated since v1.13.0 and ignored. Use Docker's built-in mechanism instead."); } let auth = read_auth_from_env(ppfmt) .ok_or_else(|| "No authentication configured. Set CLOUDFLARE_API_TOKEN.".to_string())?; let providers = read_providers_from_env(ppfmt)?; let domains = read_domains_from_env(ppfmt); let waf_lists = read_waf_lists_from_env(ppfmt); let update_cron = read_cron_from_env(ppfmt)?; let update_on_start = getenv_bool("UPDATE_ON_START", true); let delete_on_stop = getenv_bool("DELETE_ON_STOP", false); let ttl_val = getenv("TTL") .and_then(|s| s.parse::().ok()) .unwrap_or(1); let ttl = TTL::new(ttl_val); let proxied_expr_str = getenv("PROXIED").unwrap_or_else(|| "false".to_string()); let proxied_expression = match domain::parse_proxied_expression(&proxied_expr_str) { Ok(pred) => Some(pred), Err(e) => { ppfmt.errorf(pp::EMOJI_ERROR, &format!("Invalid PROXIED expression: {e}")); None } }; let record_comment = getenv("RECORD_COMMENT"); let managed_comment_regex = read_regex("MANAGED_RECORDS_COMMENT_REGEX", ppfmt); let waf_list_description = getenv("WAF_LIST_DESCRIPTION"); let waf_list_item_comment = getenv("WAF_LIST_ITEM_COMMENT"); let managed_waf_comment_regex = read_regex("MANAGED_WAF_LIST_ITEMS_COMMENT_REGEX", ppfmt); let detection_timeout = getenv_duration("DETECTION_TIMEOUT", Duration::from_secs(5)); let update_timeout = getenv_duration("UPDATE_TIMEOUT", Duration::from_secs(30)); let emoji = getenv_bool("EMOJI", true); let quiet = getenv_bool("QUIET", false); let reject_cloudflare_ips = getenv_bool("REJECT_CLOUDFLARE_IPS", false); // Validate: must have at least one update target if domains.is_empty() && waf_lists.is_empty() { return Err( "No update targets configured. Set DOMAINS, IP4_DOMAINS, IP6_DOMAINS, or WAF_LISTS." .to_string(), ); } // Validate: @once constraints if matches!(update_cron, CronSchedule::Once) { if !update_on_start { return Err("UPDATE_ON_START must be true when UPDATE_CRON=@once".to_string()); } if delete_on_stop { return Err("DELETE_ON_STOP must be false when UPDATE_CRON=@once".to_string()); } } // Validate comment/regex compatibility if let (Some(ref comment), Some(ref regex)) = (&record_comment, &managed_comment_regex) { if !regex.is_match(comment) { ppfmt.warningf( pp::EMOJI_WARNING, &format!( "RECORD_COMMENT '{}' does not match MANAGED_RECORDS_COMMENT_REGEX '{}'", comment, regex.as_str() ), ); } } Ok(AppConfig { auth, providers, domains, waf_lists, update_cron, update_on_start, delete_on_stop, ttl, proxied_expression, record_comment, managed_comment_regex, waf_list_description, waf_list_item_comment, managed_waf_comment_regex, detection_timeout, update_timeout, reject_cloudflare_ips, dry_run: false, // Set later from CLI args emoji, quiet, legacy_mode: false, legacy_config: None, repeat: false, // Set later }) } /// Load config (auto-detect mode). pub fn load_config(dry_run: bool, repeat: bool, ppfmt: &PP) -> Result { if is_env_config_mode() { ppfmt.infof(pp::EMOJI_CONFIG, "Using environment variable configuration"); let mut config = load_env_config(ppfmt)?; config.dry_run = dry_run; config.repeat = !matches!(config.update_cron, CronSchedule::Once); Ok(config) } else { ppfmt.infof(pp::EMOJI_CONFIG, "Using config.json configuration"); let legacy = load_legacy_config()?; Ok(legacy_to_app_config(legacy, dry_run, repeat)) } } // ============================================================ // Setup reporters (notifiers + heartbeats) // ============================================================ pub fn setup_notifiers(ppfmt: &PP) -> CompositeNotifier { let mut notifiers: Vec> = Vec::new(); let shoutrrr_urls = getenv_list("SHOUTRRR", '\n'); if !shoutrrr_urls.is_empty() { match ShoutrrrNotifier::new(&shoutrrr_urls) { Ok(n) => { ppfmt.infof(pp::EMOJI_NOTIFY, &format!("Notifications: {}", n.describe())); notifiers.push(Box::new(n)); } Err(e) => { ppfmt.errorf(pp::EMOJI_ERROR, &format!("Failed to setup notifications: {e}")); } } } CompositeNotifier::new(notifiers) } pub fn setup_heartbeats(ppfmt: &PP) -> Heartbeat { let mut monitors: Vec> = Vec::new(); if let Some(url) = getenv("HEALTHCHECKS") { ppfmt.infof(pp::EMOJI_HEARTBEAT, "Heartbeat: Healthchecks.io"); monitors.push(Box::new(HealthchecksMonitor::new(&url))); } if let Some(url) = getenv("UPTIMEKUMA") { ppfmt.infof(pp::EMOJI_HEARTBEAT, "Heartbeat: Uptime Kuma"); monitors.push(Box::new(UptimeKumaMonitor::new(&url))); } Heartbeat::new(monitors) } // ============================================================ // Print config summary // ============================================================ pub fn print_config_summary(config: &AppConfig, ppfmt: &PP) { if config.legacy_mode { // Legacy mode output (backwards compatible) return; } let inner = ppfmt.indent(); if !config.domains.is_empty() { ppfmt.noticef(pp::EMOJI_CONFIG, "Domains to update:"); for (ip_type, domains) in &config.domains { inner.noticef("", &format!("{}: {}", ip_type.describe(), domains.join(", "))); } } if !config.waf_lists.is_empty() { ppfmt.noticef(pp::EMOJI_CONFIG, "WAF lists:"); for waf in &config.waf_lists { inner.noticef("", &waf.describe()); } } for (ip_type, provider) in &config.providers { inner.infof("", &format!("{} provider: {}", ip_type.describe(), provider.name())); } inner.infof("", &format!("TTL: {}", config.ttl.describe())); inner.infof("", &format!("Schedule: {}", config.update_cron.describe())); if config.delete_on_stop { inner.infof("", "Delete on stop: enabled"); } if config.reject_cloudflare_ips { inner.infof("", "Reject Cloudflare IPs: enabled"); } if let Some(ref comment) = config.record_comment { inner.infof("", &format!("Record comment: {comment}")); } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_legacy_config_minimal() { let json = r#"{ "cloudflare": [{ "authentication": { "api_token": "tok123" }, "zone_id": "zone1", "subdomains": ["@"] }] }"#; let config = parse_legacy_config(json).unwrap(); assert!(config.a); assert!(config.aaaa); assert!(!config.purge_unknown_records); assert_eq!(config.ttl, 300); } #[test] fn test_parse_legacy_config_low_ttl() { let json = r#"{ "cloudflare": [{ "authentication": { "api_token": "tok123" }, "zone_id": "zone1", "subdomains": ["@"] }], "ttl": 10 }"#; let config = parse_legacy_config(json).unwrap(); assert_eq!(config.ttl, 1); } #[test] fn test_cron_schedule_every() { let sched = CronSchedule::Every(Duration::from_secs(300)); assert_eq!(sched.next_duration(), Some(Duration::from_secs(300))); } #[test] fn test_cron_schedule_once() { let sched = CronSchedule::Once; assert_eq!(sched.next_duration(), None); } #[test] fn test_parse_duration_string() { assert_eq!(parse_duration_string("5m"), Some(Duration::from_secs(300))); assert_eq!(parse_duration_string("1h"), Some(Duration::from_secs(3600))); assert_eq!(parse_duration_string("30s"), Some(Duration::from_secs(30))); } #[test] fn test_substitute_env_vars() { std::env::set_var("CF_DDNS_TEST_VAR", "test_value"); let result = substitute_env_vars("token: ${CF_DDNS_TEST_VAR}"); assert_eq!(result, "token: test_value"); let result2 = substitute_env_vars("token: $CF_DDNS_TEST_VAR"); assert_eq!(result2, "token: test_value"); std::env::remove_var("CF_DDNS_TEST_VAR"); } // --- parse_duration_string edge cases --- #[test] fn test_parse_duration_string_plain_number() { assert_eq!(parse_duration_string("60"), Some(Duration::from_secs(60))); } #[test] fn test_parse_duration_string_whitespace() { assert_eq!(parse_duration_string(" 5m "), Some(Duration::from_secs(300))); } #[test] fn test_parse_duration_string_invalid() { assert_eq!(parse_duration_string("abc"), None); assert_eq!(parse_duration_string(""), None); } // --- CronSchedule --- #[test] fn test_cron_schedule_describe() { assert_eq!( CronSchedule::Every(Duration::from_secs(300)).describe(), "@every 300s" ); assert_eq!(CronSchedule::Once.describe(), "@once"); } // --- read_cron_from_env --- #[test] fn test_read_cron_default() { // No env var set -> default 5m std::env::remove_var("UPDATE_CRON"); let pp = PP::new(false, false); let sched = read_cron_from_env(&pp).unwrap(); assert!(matches!(sched, CronSchedule::Every(d) if d == Duration::from_secs(300))); } #[test] fn test_read_cron_once() { std::env::set_var("UPDATE_CRON", "@once"); let pp = PP::new(false, false); let sched = read_cron_from_env(&pp).unwrap(); assert!(matches!(sched, CronSchedule::Once)); std::env::remove_var("UPDATE_CRON"); } #[test] fn test_read_cron_every() { std::env::set_var("UPDATE_CRON", "@every 10m"); let pp = PP::new(false, false); let sched = read_cron_from_env(&pp).unwrap(); assert!(matches!(sched, CronSchedule::Every(d) if d == Duration::from_secs(600))); std::env::remove_var("UPDATE_CRON"); } #[test] fn test_read_cron_deprecated_disabled() { std::env::set_var("UPDATE_CRON", "@disabled"); let pp = PP::new(false, false); let sched = read_cron_from_env(&pp).unwrap(); assert!(matches!(sched, CronSchedule::Once)); std::env::remove_var("UPDATE_CRON"); } #[test] fn test_read_cron_unsupported_format() { std::env::set_var("UPDATE_CRON", "*/5 * * * *"); let pp = PP::new(false, false); let result = read_cron_from_env(&pp); assert!(result.is_err()); std::env::remove_var("UPDATE_CRON"); } // --- getenv helpers --- #[test] fn test_getenv_empty_string_is_none() { std::env::set_var("TEST_GETENV_EMPTY", ""); assert!(getenv("TEST_GETENV_EMPTY").is_none()); std::env::remove_var("TEST_GETENV_EMPTY"); } #[test] fn test_getenv_whitespace_is_none() { std::env::set_var("TEST_GETENV_WS", " "); assert!(getenv("TEST_GETENV_WS").is_none()); std::env::remove_var("TEST_GETENV_WS"); } #[test] fn test_getenv_trims() { std::env::set_var("TEST_GETENV_TRIM", " hello "); assert_eq!(getenv("TEST_GETENV_TRIM"), Some("hello".to_string())); std::env::remove_var("TEST_GETENV_TRIM"); } #[test] fn test_getenv_bool_true_values() { for val in &["true", "1", "yes", "True", "YES"] { std::env::set_var("TEST_BOOL", val); assert!(getenv_bool("TEST_BOOL", false)); } std::env::remove_var("TEST_BOOL"); } #[test] fn test_getenv_bool_false_values() { for val in &["false", "0", "no", "anything"] { std::env::set_var("TEST_BOOL", val); assert!(!getenv_bool("TEST_BOOL", true)); } std::env::remove_var("TEST_BOOL"); } #[test] fn test_getenv_bool_default() { std::env::remove_var("TEST_BOOL_MISSING"); assert!(getenv_bool("TEST_BOOL_MISSING", true)); assert!(!getenv_bool("TEST_BOOL_MISSING", false)); } #[test] fn test_getenv_duration_valid() { std::env::set_var("TEST_DUR", "10s"); let d = getenv_duration("TEST_DUR", Duration::from_secs(99)); assert_eq!(d, Duration::from_secs(10)); std::env::remove_var("TEST_DUR"); } #[test] fn test_getenv_duration_default() { std::env::remove_var("TEST_DUR_MISSING"); let d = getenv_duration("TEST_DUR_MISSING", Duration::from_secs(42)); assert_eq!(d, Duration::from_secs(42)); } #[test] fn test_getenv_list() { std::env::set_var("TEST_LIST", "a,b,,c"); let list = getenv_list("TEST_LIST", ','); assert_eq!(list, vec!["a", "b", "c"]); std::env::remove_var("TEST_LIST"); } #[test] fn test_getenv_list_empty() { std::env::remove_var("TEST_LIST_MISSING"); let list = getenv_list("TEST_LIST_MISSING", ','); assert!(list.is_empty()); } // --- read_regex --- #[test] fn test_read_regex_valid() { std::env::set_var("TEST_REGEX", "cloudflare-ddns"); let pp = PP::new(false, false); let regex = read_regex("TEST_REGEX", &pp); assert!(regex.is_some()); assert!(regex.unwrap().is_match("managed by cloudflare-ddns")); std::env::remove_var("TEST_REGEX"); } #[test] fn test_read_regex_invalid() { std::env::set_var("TEST_REGEX_BAD", "[invalid("); let pp = PP::new(false, false); let regex = read_regex("TEST_REGEX_BAD", &pp); assert!(regex.is_none()); std::env::remove_var("TEST_REGEX_BAD"); } #[test] fn test_read_regex_empty() { std::env::set_var("TEST_REGEX_E", ""); let pp = PP::new(false, false); let regex = read_regex("TEST_REGEX_E", &pp); assert!(regex.is_none()); std::env::remove_var("TEST_REGEX_E"); } // --- read_domains_from_env --- #[test] fn test_read_domains_both() { std::env::set_var("DOMAINS", "example.com,www.example.com"); std::env::remove_var("IP4_DOMAINS"); std::env::remove_var("IP6_DOMAINS"); let pp = PP::new(false, false); let domains = read_domains_from_env(&pp); assert_eq!(domains.get(&IpType::V4).unwrap().len(), 2); assert_eq!(domains.get(&IpType::V6).unwrap().len(), 2); std::env::remove_var("DOMAINS"); } #[test] fn test_read_domains_ip4_only() { std::env::remove_var("DOMAINS"); std::env::set_var("IP4_DOMAINS", "v4.example.com"); std::env::remove_var("IP6_DOMAINS"); let pp = PP::new(false, false); let domains = read_domains_from_env(&pp); assert_eq!(domains.get(&IpType::V4).unwrap(), &vec!["v4.example.com".to_string()]); assert!(domains.get(&IpType::V6).is_none()); std::env::remove_var("IP4_DOMAINS"); } #[test] fn test_read_domains_empty() { std::env::remove_var("DOMAINS"); std::env::remove_var("IP4_DOMAINS"); std::env::remove_var("IP6_DOMAINS"); let pp = PP::new(false, false); let domains = read_domains_from_env(&pp); assert!(domains.is_empty()); } // --- read_waf_lists_from_env --- #[test] fn test_read_waf_lists_valid() { std::env::set_var("WAF_LISTS", "acc123/my_list"); let pp = PP::new(false, false); let lists = read_waf_lists_from_env(&pp); assert_eq!(lists.len(), 1); assert_eq!(lists[0].account_id, "acc123"); assert_eq!(lists[0].list_name, "my_list"); std::env::remove_var("WAF_LISTS"); } #[test] fn test_read_waf_lists_invalid_skipped() { std::env::set_var("WAF_LISTS", "no-slash"); let pp = PP::new(false, false); let lists = read_waf_lists_from_env(&pp); assert!(lists.is_empty()); std::env::remove_var("WAF_LISTS"); } // --- legacy_to_app_config --- #[test] fn test_legacy_to_app_config_basic() { let legacy = LegacyConfig { cloudflare: vec![LegacyCloudflareEntry { authentication: LegacyAuthentication { api_token: "my-token".to_string(), api_key: None, }, zone_id: "zone1".to_string(), subdomains: vec![LegacySubdomainEntry::Simple("@".to_string())], proxied: false, }], a: true, aaaa: false, purge_unknown_records: false, ttl: 300, }; let config = legacy_to_app_config(legacy, false, false); assert!(config.legacy_mode); assert!(matches!(config.auth, Auth::Token(ref t) if t == "my-token")); assert!(config.providers.contains_key(&IpType::V4)); assert!(!config.providers.contains_key(&IpType::V6)); assert!(matches!(config.update_cron, CronSchedule::Once)); assert!(!config.dry_run); } #[test] fn test_legacy_to_app_config_repeat() { let legacy = LegacyConfig { cloudflare: vec![LegacyCloudflareEntry { authentication: LegacyAuthentication { api_token: "tok".to_string(), api_key: None, }, zone_id: "z".to_string(), subdomains: vec![], proxied: false, }], a: true, aaaa: true, purge_unknown_records: false, ttl: 120, }; let config = legacy_to_app_config(legacy, true, true); assert!(matches!(config.update_cron, CronSchedule::Every(d) if d == Duration::from_secs(120))); assert!(config.repeat); assert!(config.dry_run); } #[test] fn test_legacy_to_app_config_api_key() { let legacy = LegacyConfig { cloudflare: vec![LegacyCloudflareEntry { authentication: LegacyAuthentication { api_token: String::new(), api_key: Some(LegacyApiKey { api_key: "key123".to_string(), account_email: "test@example.com".to_string(), }), }, zone_id: "z".to_string(), subdomains: vec![], proxied: false, }], a: true, aaaa: true, purge_unknown_records: false, ttl: 300, }; let config = legacy_to_app_config(legacy, false, false); assert!(matches!(config.auth, Auth::Key { ref api_key, ref email } if api_key == "key123" && email == "test@example.com")); } // --- is_env_config_mode --- #[test] fn test_is_env_config_mode_with_token() { std::env::set_var("CLOUDFLARE_API_TOKEN", "test"); assert!(is_env_config_mode()); std::env::remove_var("CLOUDFLARE_API_TOKEN"); } #[test] fn test_is_env_config_mode_with_domains() { std::env::remove_var("CLOUDFLARE_API_TOKEN"); std::env::remove_var("CF_API_TOKEN"); std::env::remove_var("CLOUDFLARE_API_TOKEN_FILE"); std::env::remove_var("CF_API_TOKEN_FILE"); std::env::set_var("DOMAINS", "example.com"); assert!(is_env_config_mode()); std::env::remove_var("DOMAINS"); } // --- parse_legacy_config edge cases --- #[test] fn test_parse_legacy_config_with_detailed_subdomains() { let json = r#"{ "cloudflare": [{ "authentication": { "api_token": "tok" }, "zone_id": "z", "subdomains": [ { "name": "www", "proxied": true }, "vpn" ] }] }"#; let config = parse_legacy_config(json).unwrap(); assert_eq!(config.cloudflare[0].subdomains.len(), 2); match &config.cloudflare[0].subdomains[0] { LegacySubdomainEntry::Detailed { name, proxied } => { assert_eq!(name, "www"); assert!(*proxied); } _ => panic!("Expected Detailed"), } match &config.cloudflare[0].subdomains[1] { LegacySubdomainEntry::Simple(name) => assert_eq!(name, "vpn"), _ => panic!("Expected Simple"), } } #[test] fn test_parse_legacy_config_with_api_key() { let json = r#"{ "cloudflare": [{ "authentication": { "api_key": { "api_key": "key123", "account_email": "user@example.com" } }, "zone_id": "z", "subdomains": ["@"] }] }"#; let config = parse_legacy_config(json).unwrap(); let auth = &config.cloudflare[0].authentication; assert!(auth.api_key.is_some()); assert_eq!(auth.api_key.as_ref().unwrap().api_key, "key123"); } #[test] fn test_parse_legacy_config_invalid_json() { let result = parse_legacy_config("not json"); assert!(result.is_err()); } #[test] fn test_parse_legacy_config_ttl_exactly_30() { let json = r#"{ "cloudflare": [{ "authentication": { "api_token": "tok" }, "zone_id": "z", "subdomains": ["@"] }], "ttl": 30 }"#; let config = parse_legacy_config(json).unwrap(); assert_eq!(config.ttl, 30); } #[test] fn test_parse_legacy_config_purge_unknown() { let json = r#"{ "cloudflare": [{ "authentication": { "api_token": "tok" }, "zone_id": "z", "subdomains": ["@"], "proxied": true }], "purgeUnknownRecords": true, "a": true, "aaaa": false }"#; let config = parse_legacy_config(json).unwrap(); assert!(config.purge_unknown_records); assert!(config.a); assert!(!config.aaaa); assert!(config.cloudflare[0].proxied); } // --- substitute_env_vars --- #[test] fn test_substitute_no_match() { let result = substitute_env_vars("no variables here"); assert_eq!(result, "no variables here"); } #[test] fn test_substitute_non_cf_ddns_vars_ignored() { std::env::set_var("HOME", "/home/user"); let result = substitute_env_vars("home: $HOME"); assert_eq!(result, "home: $HOME"); // HOME doesn't start with CF_DDNS_ } // --- print_config_summary --- #[test] fn test_print_config_summary_legacy_noop() { let config = AppConfig { auth: Auth::Token(String::new()), providers: HashMap::new(), domains: HashMap::new(), waf_lists: Vec::new(), update_cron: CronSchedule::Once, update_on_start: true, delete_on_stop: false, ttl: TTL::AUTO, proxied_expression: None, record_comment: None, managed_comment_regex: None, waf_list_description: None, waf_list_item_comment: None, managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), reject_cloudflare_ips: false, dry_run: false, emoji: false, quiet: false, legacy_mode: true, legacy_config: None, repeat: false, }; let pp = PP::new(false, false); // Should return early without panicking for legacy mode print_config_summary(&config, &pp); } #[test] fn test_print_config_summary_env_mode() { let mut domains = HashMap::new(); domains.insert(IpType::V4, vec!["example.com".to_string()]); let config = AppConfig { auth: Auth::Token("tok".to_string()), providers: HashMap::new(), domains, waf_lists: Vec::new(), update_cron: CronSchedule::Every(Duration::from_secs(300)), update_on_start: true, delete_on_stop: true, ttl: TTL::new(60), proxied_expression: None, record_comment: Some("managed".to_string()), managed_comment_regex: None, waf_list_description: None, waf_list_item_comment: None, managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), reject_cloudflare_ips: false, dry_run: false, emoji: false, quiet: false, legacy_mode: false, legacy_config: None, repeat: false, }; let pp = PP::new(false, false); // Should print without panicking print_config_summary(&config, &pp); } // ============================================================ // EnvGuard helper for safe env-var tests // ============================================================ // Mutex to serialize env-var-dependent tests (prevents parallel interference) static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); struct EnvGuard { keys: Vec, _lock: std::sync::MutexGuard<'static, ()>, } impl EnvGuard { fn set(key: &str, value: &str) -> Self { let lock = ENV_MUTEX.lock().unwrap(); std::env::set_var(key, value); Self { keys: vec![key.to_string()], _lock: lock } } fn add(&mut self, key: &str, value: &str) { std::env::set_var(key, value); self.keys.push(key.to_string()); } /// Remove a key from the environment and record it so Drop cleans up properly. fn remove(&mut self, key: &str) { std::env::remove_var(key); self.keys.push(key.to_string()); } } impl Drop for EnvGuard { fn drop(&mut self) { for key in &self.keys { std::env::remove_var(key); } } } // ============================================================ // read_auth_from_env // ============================================================ #[test] fn test_read_auth_cloudflare_api_token() { let mut g = EnvGuard::set("CLOUDFLARE_API_TOKEN_RA1", "secret-token"); g.remove("CF_API_TOKEN_RA1"); // We test via the real env-var names the function uses. // Use a unique suffix to avoid cross-test pollution; the function reads // fixed names, so we must use the real names. Accept the race risk in // exchange for genuine coverage by running tests single-threaded or with // the real variable names in isolation. drop(g); // Re-run using the canonical names the function actually reads. let mut g2 = EnvGuard::set("CLOUDFLARE_API_TOKEN", "real-token-abc"); g2.remove("CF_API_TOKEN"); g2.remove("CLOUDFLARE_API_TOKEN_FILE"); g2.remove("CF_API_TOKEN_FILE"); g2.remove("CF_ACCOUNT_ID"); let pp = PP::new(false, true); let auth = read_auth_from_env(&pp); drop(g2); assert!(matches!(auth, Some(Auth::Token(ref t)) if t == "real-token-abc")); } #[test] fn test_read_auth_placeholder_token_returns_none() { let mut g = EnvGuard::set("CLOUDFLARE_API_TOKEN", "YOUR-CLOUDFLARE-API-TOKEN"); g.remove("CF_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); let pp = PP::new(false, true); let auth = read_auth_from_env(&pp); drop(g); assert!(auth.is_none()); } #[test] fn test_read_auth_cf_api_token_deprecated_fallback() { let mut g = EnvGuard::set("CF_API_TOKEN", "deprecated-token"); g.remove("CLOUDFLARE_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); let pp = PP::new(false, true); let auth = read_auth_from_env(&pp); drop(g); assert!(matches!(auth, Some(Auth::Token(ref t)) if t == "deprecated-token")); } #[test] fn test_read_auth_no_vars_returns_none() { let mut g = EnvGuard::set("_PLACEHOLDER_RA", "x"); // just to create guard g.remove("CLOUDFLARE_API_TOKEN"); g.remove("CF_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); let pp = PP::new(false, true); let auth = read_auth_from_env(&pp); drop(g); assert!(auth.is_none()); } #[test] fn test_read_auth_token_file_valid() { use std::io::Write; let dir = std::env::temp_dir(); let path = dir.join("cf_ddns_test_token_file_valid.txt"); { let mut f = std::fs::File::create(&path).expect("create temp file"); write!(f, " file-token-xyz ").unwrap(); } let path_str = path.to_str().unwrap().to_string(); let mut g = EnvGuard::set("CLOUDFLARE_API_TOKEN_FILE", &path_str); g.remove("CLOUDFLARE_API_TOKEN"); g.remove("CF_API_TOKEN"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); let pp = PP::new(false, true); let auth = read_auth_from_env(&pp); drop(g); let _ = std::fs::remove_file(&path); assert!(matches!(auth, Some(Auth::Token(ref t)) if t == "file-token-xyz")); } #[test] fn test_read_auth_token_file_missing_returns_none() { let mut g = EnvGuard::set("CLOUDFLARE_API_TOKEN_FILE", "/nonexistent/path/token.txt"); g.remove("CLOUDFLARE_API_TOKEN"); g.remove("CF_API_TOKEN"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); let pp = PP::new(false, true); let auth = read_auth_from_env(&pp); drop(g); assert!(auth.is_none()); } #[test] fn test_read_auth_cf_account_id_deprecated_warning() { // CF_ACCOUNT_ID should emit a deprecation warning but not affect auth result. let mut g = EnvGuard::set("CLOUDFLARE_API_TOKEN", "tok-with-account-id"); g.add("CF_ACCOUNT_ID", "acc123"); g.remove("CF_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_API_TOKEN_FILE"); let pp = PP::new(false, true); let auth = read_auth_from_env(&pp); drop(g); // Auth should still succeed with the token; CF_ACCOUNT_ID is just ignored. assert!(matches!(auth, Some(Auth::Token(ref t)) if t == "tok-with-account-id")); } #[test] fn test_read_auth_cf_api_token_file_deprecated_fallback() { use std::io::Write; let dir = std::env::temp_dir(); let path = dir.join("cf_ddns_test_token_file_deprecated.txt"); { let mut f = std::fs::File::create(&path).expect("create temp file"); write!(f, "old-file-token").unwrap(); } let path_str = path.to_str().unwrap().to_string(); let mut g = EnvGuard::set("CF_API_TOKEN_FILE", &path_str); g.remove("CLOUDFLARE_API_TOKEN"); g.remove("CF_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); let pp = PP::new(false, true); let auth = read_auth_from_env(&pp); drop(g); let _ = std::fs::remove_file(&path); assert!(matches!(auth, Some(Auth::Token(ref t)) if t == "old-file-token")); } // ============================================================ // read_providers_from_env // ============================================================ #[test] fn test_read_providers_defaults() { let mut g = EnvGuard::set("_PLACEHOLDER_RP", "x"); g.remove("IP4_PROVIDER"); g.remove("IP4_POLICY"); g.remove("IP6_PROVIDER"); g.remove("IP6_POLICY"); let pp = PP::new(false, true); let providers = read_providers_from_env(&pp).unwrap(); drop(g); // Both V4 and V6 default to CloudflareTrace. assert!(providers.contains_key(&IpType::V4)); assert!(providers.contains_key(&IpType::V6)); assert!(matches!( providers[&IpType::V4], ProviderType::CloudflareTrace { url: None } )); assert!(matches!( providers[&IpType::V6], ProviderType::CloudflareTrace { url: None } )); } #[test] fn test_read_providers_ip4_none_excludes_v4() { let mut g = EnvGuard::set("IP4_PROVIDER", "none"); g.remove("IP4_POLICY"); g.remove("IP6_PROVIDER"); g.remove("IP6_POLICY"); let pp = PP::new(false, true); let providers = read_providers_from_env(&pp).unwrap(); drop(g); assert!(!providers.contains_key(&IpType::V4)); assert!(providers.contains_key(&IpType::V6)); } #[test] fn test_read_providers_ip6_none_excludes_v6() { let mut g = EnvGuard::set("IP6_PROVIDER", "none"); g.remove("IP4_PROVIDER"); g.remove("IP4_POLICY"); g.remove("IP6_POLICY"); let pp = PP::new(false, true); let providers = read_providers_from_env(&pp).unwrap(); drop(g); assert!(providers.contains_key(&IpType::V4)); assert!(!providers.contains_key(&IpType::V6)); } #[test] fn test_read_providers_invalid_returns_error() { let mut g = EnvGuard::set("IP4_PROVIDER", "totally_invalid_provider"); g.remove("IP4_POLICY"); g.remove("IP6_PROVIDER"); g.remove("IP6_POLICY"); let pp = PP::new(false, true); let result = read_providers_from_env(&pp); drop(g); assert!(result.is_err()); assert!(result.err().unwrap().contains("IP4_PROVIDER")); } #[test] fn test_read_providers_ip4_policy_deprecated() { // IP4_POLICY is deprecated alias for IP4_PROVIDER. let mut g = EnvGuard::set("IP4_POLICY", "ipify"); g.remove("IP4_PROVIDER"); g.remove("IP6_PROVIDER"); g.remove("IP6_POLICY"); let pp = PP::new(false, true); let providers = read_providers_from_env(&pp).unwrap(); drop(g); assert!(matches!(providers[&IpType::V4], ProviderType::Ipify)); } #[test] fn test_read_providers_ip6_policy_deprecated() { // IP6_POLICY is deprecated alias for IP6_PROVIDER. let mut g = EnvGuard::set("IP6_POLICY", "ipify"); g.remove("IP6_PROVIDER"); g.remove("IP4_PROVIDER"); g.remove("IP4_POLICY"); let pp = PP::new(false, true); let providers = read_providers_from_env(&pp).unwrap(); drop(g); assert!(matches!(providers[&IpType::V6], ProviderType::Ipify)); } // ============================================================ // read_cron_from_env: @nevermore deprecated alias // ============================================================ #[test] fn test_read_cron_deprecated_nevermore() { let g = EnvGuard::set("UPDATE_CRON", "@nevermore"); let pp = PP::new(false, true); let sched = read_cron_from_env(&pp).unwrap(); drop(g); assert!(matches!(sched, CronSchedule::Once)); } #[test] fn test_read_cron_invalid_duration_in_every() { let g = EnvGuard::set("UPDATE_CRON", "@every notaduration"); let pp = PP::new(false, true); let result = read_cron_from_env(&pp); drop(g); assert!(result.is_err()); } // ============================================================ // load_env_config // ============================================================ #[test] fn test_load_env_config_basic_success() { let mut g = EnvGuard::set("CLOUDFLARE_API_TOKEN", "tok-load-test"); g.add("DOMAINS", "example.com"); // Clear potentially interfering vars. g.remove("CF_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); g.remove("IP4_DOMAINS"); g.remove("IP6_DOMAINS"); g.remove("WAF_LISTS"); g.remove("UPDATE_CRON"); g.remove("UPDATE_ON_START"); g.remove("DELETE_ON_STOP"); g.remove("TTL"); g.remove("PROXIED"); g.remove("IP4_PROVIDER"); g.remove("IP4_POLICY"); g.remove("IP6_PROVIDER"); g.remove("IP6_POLICY"); g.remove("PUID"); g.remove("PGID"); let pp = PP::new(false, true); let config = load_env_config(&pp).unwrap(); drop(g); assert!(matches!(config.auth, Auth::Token(ref t) if t == "tok-load-test")); assert!(!config.domains.is_empty()); assert!(!config.legacy_mode); } #[test] fn test_load_env_config_missing_auth_returns_error() { let mut g = EnvGuard::set("DOMAINS", "example.com"); g.remove("CLOUDFLARE_API_TOKEN"); g.remove("CF_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); g.remove("WAF_LISTS"); g.remove("IP4_PROVIDER"); g.remove("IP4_POLICY"); g.remove("IP6_PROVIDER"); g.remove("IP6_POLICY"); g.remove("UPDATE_CRON"); g.remove("PUID"); g.remove("PGID"); let pp = PP::new(false, true); let result = load_env_config(&pp); drop(g); assert!(result.is_err()); let err = result.err().unwrap(); assert!(err.contains("No authentication") || err.contains("CLOUDFLARE_API_TOKEN")); } #[test] fn test_load_env_config_missing_domains_returns_error() { let mut g = EnvGuard::set("CLOUDFLARE_API_TOKEN", "tok-no-domains"); g.remove("CF_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); g.remove("DOMAINS"); g.remove("IP4_DOMAINS"); g.remove("IP6_DOMAINS"); g.remove("WAF_LISTS"); g.remove("IP4_PROVIDER"); g.remove("IP4_POLICY"); g.remove("IP6_PROVIDER"); g.remove("IP6_POLICY"); g.remove("UPDATE_CRON"); g.remove("PUID"); g.remove("PGID"); let pp = PP::new(false, true); let result = load_env_config(&pp); drop(g); assert!(result.is_err()); let err = result.err().unwrap(); assert!(err.contains("No update targets") || err.contains("DOMAINS")); } #[test] fn test_load_env_config_once_update_on_start_false_errors() { let mut g = EnvGuard::set("CLOUDFLARE_API_TOKEN", "tok-once-test"); g.add("DOMAINS", "example.com"); g.add("UPDATE_CRON", "@once"); g.add("UPDATE_ON_START", "false"); g.add("DELETE_ON_STOP", "false"); g.remove("CF_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); g.remove("IP4_DOMAINS"); g.remove("IP6_DOMAINS"); g.remove("WAF_LISTS"); g.remove("IP4_PROVIDER"); g.remove("IP4_POLICY"); g.remove("IP6_PROVIDER"); g.remove("IP6_POLICY"); g.remove("PUID"); g.remove("PGID"); let pp = PP::new(false, true); let result = load_env_config(&pp); drop(g); assert!(result.is_err()); let err = result.err().unwrap(); assert!(err.contains("UPDATE_ON_START")); } #[test] fn test_load_env_config_once_delete_on_stop_true_errors() { let mut g = EnvGuard::set("CLOUDFLARE_API_TOKEN", "tok-once-del"); g.add("DOMAINS", "example.com"); g.add("UPDATE_CRON", "@once"); g.add("UPDATE_ON_START", "true"); g.add("DELETE_ON_STOP", "true"); g.remove("CF_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); g.remove("IP4_DOMAINS"); g.remove("IP6_DOMAINS"); g.remove("WAF_LISTS"); g.remove("IP4_PROVIDER"); g.remove("IP4_POLICY"); g.remove("IP6_PROVIDER"); g.remove("IP6_POLICY"); g.remove("PUID"); g.remove("PGID"); let pp = PP::new(false, true); let result = load_env_config(&pp); drop(g); assert!(result.is_err()); let err = result.err().unwrap(); assert!(err.contains("DELETE_ON_STOP")); } #[test] fn test_load_env_config_with_waf_list_only() { // WAF_LISTS alone (no DOMAINS) should succeed. let mut g = EnvGuard::set("CLOUDFLARE_API_TOKEN", "tok-waf-only"); g.add("WAF_LISTS", "acc123/my_list"); g.remove("CF_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); g.remove("DOMAINS"); g.remove("IP4_DOMAINS"); g.remove("IP6_DOMAINS"); g.remove("IP4_PROVIDER"); g.remove("IP4_POLICY"); g.remove("IP6_PROVIDER"); g.remove("IP6_POLICY"); g.remove("UPDATE_CRON"); g.remove("UPDATE_ON_START"); g.remove("DELETE_ON_STOP"); g.remove("PUID"); g.remove("PGID"); let pp = PP::new(false, true); let result = load_env_config(&pp); drop(g); assert!(result.is_ok()); let config = result.unwrap(); assert_eq!(config.waf_lists.len(), 1); assert!(config.domains.is_empty()); } #[test] fn test_load_env_config_puid_pgid_deprecated_still_succeeds() { let mut g = EnvGuard::set("CLOUDFLARE_API_TOKEN", "tok-puid"); g.add("DOMAINS", "example.com"); g.add("PUID", "1000"); g.add("PGID", "1000"); g.remove("CF_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); g.remove("IP4_DOMAINS"); g.remove("IP6_DOMAINS"); g.remove("WAF_LISTS"); g.remove("IP4_PROVIDER"); g.remove("IP4_POLICY"); g.remove("IP6_PROVIDER"); g.remove("IP6_POLICY"); g.remove("UPDATE_CRON"); g.remove("UPDATE_ON_START"); g.remove("DELETE_ON_STOP"); let pp = PP::new(false, true); let result = load_env_config(&pp); drop(g); // PUID/PGID are deprecated and ignored; config should still load. assert!(result.is_ok()); } #[test] fn test_load_env_config_invalid_provider_returns_error() { let mut g = EnvGuard::set("CLOUDFLARE_API_TOKEN", "tok-bad-provider"); g.add("DOMAINS", "example.com"); g.add("IP4_PROVIDER", "not_a_real_provider"); g.remove("CF_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); g.remove("IP4_DOMAINS"); g.remove("IP6_DOMAINS"); g.remove("WAF_LISTS"); g.remove("IP4_POLICY"); g.remove("IP6_PROVIDER"); g.remove("IP6_POLICY"); g.remove("UPDATE_CRON"); g.remove("UPDATE_ON_START"); g.remove("DELETE_ON_STOP"); g.remove("PUID"); g.remove("PGID"); let pp = PP::new(false, true); let result = load_env_config(&pp); drop(g); assert!(result.is_err()); assert!(result.err().unwrap().contains("IP4_PROVIDER")); } #[test] fn test_load_env_config_comment_regex_mismatch_still_succeeds() { // A mismatch between RECORD_COMMENT and MANAGED_RECORDS_COMMENT_REGEX should // emit a warning but not fail. let mut g = EnvGuard::set("CLOUDFLARE_API_TOKEN", "tok-regex-warn"); g.add("DOMAINS", "example.com"); g.add("RECORD_COMMENT", "my comment"); g.add("MANAGED_RECORDS_COMMENT_REGEX", "^cloudflare-ddns"); g.remove("CF_API_TOKEN"); g.remove("CLOUDFLARE_API_TOKEN_FILE"); g.remove("CF_API_TOKEN_FILE"); g.remove("CF_ACCOUNT_ID"); g.remove("IP4_DOMAINS"); g.remove("IP6_DOMAINS"); g.remove("WAF_LISTS"); g.remove("IP4_PROVIDER"); g.remove("IP4_POLICY"); g.remove("IP6_PROVIDER"); g.remove("IP6_POLICY"); g.remove("UPDATE_CRON"); g.remove("UPDATE_ON_START"); g.remove("DELETE_ON_STOP"); g.remove("PUID"); g.remove("PGID"); let pp = PP::new(false, true); let result = load_env_config(&pp); drop(g); assert!(result.is_ok()); } // ============================================================ // setup_notifiers // ============================================================ #[test] fn test_setup_notifiers_no_shoutrrr_returns_empty() { let mut g = EnvGuard::set("_PLACEHOLDER_SN", "x"); g.remove("SHOUTRRR"); let pp = PP::new(false, true); let notifier = setup_notifiers(&pp); drop(g); assert!(notifier.is_empty()); } #[test] fn test_setup_notifiers_empty_shoutrrr_returns_empty() { let g = EnvGuard::set("SHOUTRRR", ""); let pp = PP::new(false, true); let notifier = setup_notifiers(&pp); drop(g); // Empty string is treated as unset by getenv_list. assert!(notifier.is_empty()); } // ============================================================ // setup_heartbeats // ============================================================ #[test] fn test_setup_heartbeats_no_vars_returns_empty() { let mut g = EnvGuard::set("_PLACEHOLDER_HB", "x"); g.remove("HEALTHCHECKS"); g.remove("UPTIMEKUMA"); let pp = PP::new(false, true); let hb = setup_heartbeats(&pp); drop(g); assert!(hb.is_empty()); } #[test] fn test_setup_heartbeats_healthchecks_only() { let mut g = EnvGuard::set("HEALTHCHECKS", "https://hc-ping.com/abc123"); g.remove("UPTIMEKUMA"); let pp = PP::new(false, true); let hb = setup_heartbeats(&pp); drop(g); assert!(!hb.is_empty()); } #[test] fn test_setup_heartbeats_uptimekuma_only() { let mut g = EnvGuard::set("UPTIMEKUMA", "https://status.example.com/api/push/abc"); g.remove("HEALTHCHECKS"); let pp = PP::new(false, true); let hb = setup_heartbeats(&pp); drop(g); assert!(!hb.is_empty()); } #[test] fn test_setup_heartbeats_both_monitors() { let mut g = EnvGuard::set("HEALTHCHECKS", "https://hc-ping.com/abc"); g.add("UPTIMEKUMA", "https://status.example.com/api/push/def"); let pp = PP::new(false, true); let hb = setup_heartbeats(&pp); drop(g); assert!(!hb.is_empty()); } // ============================================================ // print_config_summary - additional coverage paths // ============================================================ #[test] fn test_print_config_summary_with_waf_lists() { use crate::cloudflare::WAFList; let waf_list = WAFList { account_id: "acc123".to_string(), list_name: "my_list".to_string(), }; let config = AppConfig { auth: Auth::Token("tok".to_string()), providers: HashMap::new(), domains: HashMap::new(), waf_lists: vec![waf_list], update_cron: CronSchedule::Every(Duration::from_secs(300)), update_on_start: true, delete_on_stop: false, ttl: TTL::AUTO, proxied_expression: None, record_comment: None, managed_comment_regex: None, waf_list_description: None, waf_list_item_comment: None, managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), reject_cloudflare_ips: false, dry_run: false, emoji: false, quiet: false, legacy_mode: false, legacy_config: None, repeat: false, }; let pp = PP::new(false, true); print_config_summary(&config, &pp); // must not panic } #[test] fn test_print_config_summary_with_providers_and_delete_on_stop() { let mut providers = HashMap::new(); providers.insert(IpType::V4, ProviderType::CloudflareTrace { url: None }); providers.insert(IpType::V6, ProviderType::Ipify); let mut domains = HashMap::new(); domains.insert(IpType::V4, vec!["v4.example.com".to_string()]); let config = AppConfig { auth: Auth::Token("tok".to_string()), providers, domains, waf_lists: Vec::new(), update_cron: CronSchedule::Every(Duration::from_secs(600)), update_on_start: true, delete_on_stop: true, ttl: TTL::new(120), proxied_expression: None, record_comment: Some("cf-ddns".to_string()), managed_comment_regex: None, waf_list_description: None, waf_list_item_comment: None, managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), reject_cloudflare_ips: false, dry_run: false, emoji: false, quiet: true, legacy_mode: false, legacy_config: None, repeat: true, }; let pp = PP::new(false, true); print_config_summary(&config, &pp); // must not panic } #[test] fn test_print_config_summary_once_schedule() { let mut domains = HashMap::new(); domains.insert(IpType::V6, vec!["ipv6.example.com".to_string()]); let config = AppConfig { auth: Auth::Token("tok".to_string()), providers: HashMap::new(), domains, waf_lists: Vec::new(), update_cron: CronSchedule::Once, update_on_start: true, delete_on_stop: false, ttl: TTL::AUTO, proxied_expression: None, record_comment: None, managed_comment_regex: None, waf_list_description: None, waf_list_item_comment: None, managed_waf_comment_regex: None, detection_timeout: Duration::from_secs(5), update_timeout: Duration::from_secs(30), reject_cloudflare_ips: false, dry_run: false, emoji: false, quiet: false, legacy_mode: false, legacy_config: None, repeat: false, }; let pp = PP::new(false, true); print_config_summary(&config, &pp); // must not panic } }