Unit-/Integrationstests für Parsing- und Konfigurationslogik ergänzen
Deckt MAC-Adress-Parsing, ip-neigh-/nmap-Output-Parsing, Subnetz-Extraktion, Cache-TTL-Grenzfälle, CLI-Overlay-Verhalten und das JSON-Ausgabeschema ab. Alle Tests laufen auf reinen Funktionen ohne Netzwerk-, root- oder nmap-Abhängigkeit, damit die unprivilegierte CI (unit-tests.yaml) grün bleibt. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0118Wbg95ADSDynYfci2qUjK
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
use mac2ip::cache::is_expired;
|
||||
|
||||
#[test]
|
||||
fn fresh_entry_is_not_expired() {
|
||||
assert!(!is_expired(1000, 1000, 1800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_exactly_at_ttl_boundary_is_not_expired() {
|
||||
// now - updated_at == ttl_seconds -> nicht abgelaufen (strikt größer nötig)
|
||||
assert!(!is_expired(1000, 1000 + 1800, 1800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_one_second_past_ttl_is_expired() {
|
||||
assert!(is_expired(1000, 1000 + 1801, 1800));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_ttl_expires_immediately_after_any_elapsed_time() {
|
||||
assert!(is_expired(1000, 1001, 0));
|
||||
assert!(!is_expired(1000, 1000, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_gap_is_expired() {
|
||||
assert!(is_expired(0, 1_000_000, 1800));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use mac2ip::cli::Cli;
|
||||
use mac2ip::config::{AppConfig, apply_cli_overrides};
|
||||
use mac2ip::mac::MacAddress;
|
||||
|
||||
fn base_cli() -> Cli {
|
||||
Cli {
|
||||
mac: MacAddress::parse("aa:bb:cc:dd:ee:ff").unwrap(),
|
||||
json: false,
|
||||
config: None,
|
||||
log_level: None,
|
||||
cache_ttl_seconds: None,
|
||||
cache_db_path: None,
|
||||
nmap_timeout_seconds: None,
|
||||
networks: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_overrides_leaves_config_untouched() {
|
||||
let default_config = AppConfig::default();
|
||||
let mut config = default_config.clone();
|
||||
apply_cli_overrides(&mut config, &base_cli());
|
||||
assert_eq!(config, default_config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_cache_ttl_seconds() {
|
||||
let mut config = AppConfig::default();
|
||||
let mut cli = base_cli();
|
||||
cli.cache_ttl_seconds = Some(60);
|
||||
apply_cli_overrides(&mut config, &cli);
|
||||
assert_eq!(config.cache_ttl_seconds, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_cache_db_path() {
|
||||
let mut config = AppConfig::default();
|
||||
let mut cli = base_cli();
|
||||
cli.cache_db_path = Some(PathBuf::from("/tmp/custom-cache.db"));
|
||||
apply_cli_overrides(&mut config, &cli);
|
||||
assert_eq!(config.cache_db_path, PathBuf::from("/tmp/custom-cache.db"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_nmap_timeout_seconds() {
|
||||
let mut config = AppConfig::default();
|
||||
let mut cli = base_cli();
|
||||
cli.nmap_timeout_seconds = Some(30);
|
||||
apply_cli_overrides(&mut config, &cli);
|
||||
assert_eq!(config.nmap_timeout_seconds, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overrides_networks() {
|
||||
let mut config = AppConfig::default();
|
||||
let mut cli = base_cli();
|
||||
cli.networks = Some(vec!["10.0.0.0/24".to_string()]);
|
||||
apply_cli_overrides(&mut config, &cli);
|
||||
assert_eq!(config.networks, vec!["10.0.0.0/24".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_overrides_only_touch_provided_fields() {
|
||||
let default_config = AppConfig::default();
|
||||
let mut config = default_config.clone();
|
||||
let mut cli = base_cli();
|
||||
cli.cache_ttl_seconds = Some(999);
|
||||
apply_cli_overrides(&mut config, &cli);
|
||||
|
||||
assert_eq!(config.cache_ttl_seconds, 999);
|
||||
assert_eq!(config.cache_db_path, default_config.cache_db_path);
|
||||
assert_eq!(config.log_level, default_config.log_level);
|
||||
assert_eq!(
|
||||
config.nmap_timeout_seconds,
|
||||
default_config.nmap_timeout_seconds
|
||||
);
|
||||
assert_eq!(config.networks, default_config.networks);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use mac2ip::mac::MacAddress;
|
||||
use mac2ip::network::parse_ip_neigh_output;
|
||||
|
||||
fn mac(s: &str) -> MacAddress {
|
||||
MacAddress::parse(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_matching_entry() {
|
||||
let output = "192.168.1.5 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n";
|
||||
let ip = parse_ip_neigh_output(output, &mac("aa:bb:cc:dd:ee:ff")).unwrap();
|
||||
assert_eq!(ip.to_string(), "192.168.1.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picks_correct_entry_among_multiple() {
|
||||
let output = "\
|
||||
192.168.1.4 dev eth0 lladdr 11:22:33:44:55:66 STALE
|
||||
192.168.1.5 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE
|
||||
192.168.1.6 dev eth0 lladdr 77:88:99:aa:bb:cc STALE
|
||||
";
|
||||
let ip = parse_ip_neigh_output(output, &mac("aa:bb:cc:dd:ee:ff")).unwrap();
|
||||
assert_eq!(ip.to_string(), "192.168.1.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_entries_without_lladdr() {
|
||||
let output = "\
|
||||
192.168.1.7 dev eth0 FAILED
|
||||
192.168.1.5 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE
|
||||
";
|
||||
let ip = parse_ip_neigh_output(output, &mac("aa:bb:cc:dd:ee:ff")).unwrap();
|
||||
assert_eq!(ip.to_string(), "192.168.1.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_match() {
|
||||
let output = "192.168.1.4 dev eth0 lladdr 11:22:33:44:55:66 STALE\n";
|
||||
assert!(parse_ip_neigh_output(output, &mac("aa:bb:cc:dd:ee:ff")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tolerates_blank_lines_and_extra_whitespace() {
|
||||
let output = "\n \n192.168.1.5 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE\n\n";
|
||||
let ip = parse_ip_neigh_output(output, &mac("aa:bb:cc:dd:ee:ff")).unwrap();
|
||||
assert_eq!(ip.to_string(), "192.168.1.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_output_returns_none() {
|
||||
assert!(parse_ip_neigh_output("", &mac("aa:bb:cc:dd:ee:ff")).is_none());
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
|
||||
use mac2ip::error::Mac2IpError;
|
||||
use mac2ip::mac::MacAddress;
|
||||
use mac2ip::output::{SuccessJson, failure_json, success_json};
|
||||
use mac2ip::resolver::{MatchSource, ResolveResult};
|
||||
|
||||
fn mac() -> MacAddress {
|
||||
MacAddress::parse("aa:bb:cc:dd:ee:ff").unwrap()
|
||||
}
|
||||
|
||||
fn ip() -> IpAddr {
|
||||
IpAddr::from_str("192.168.1.5").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_json_has_expected_shape_for_cache_source() {
|
||||
let result = ResolveResult {
|
||||
mac: mac(),
|
||||
ip: ip(),
|
||||
source: MatchSource::Cache,
|
||||
};
|
||||
let json = success_json(&result);
|
||||
assert_eq!(
|
||||
json,
|
||||
SuccessJson {
|
||||
status: "ok",
|
||||
mac: "aa:bb:cc:dd:ee:ff".to_string(),
|
||||
ip: "192.168.1.5".to_string(),
|
||||
source: "cache",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_json_has_expected_shape_for_arp_source() {
|
||||
let result = ResolveResult {
|
||||
mac: mac(),
|
||||
ip: ip(),
|
||||
source: MatchSource::Arp,
|
||||
};
|
||||
assert_eq!(success_json(&result).source, "arp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_json_has_expected_shape_for_nmap_source() {
|
||||
let result = ResolveResult {
|
||||
mac: mac(),
|
||||
ip: ip(),
|
||||
source: MatchSource::Nmap,
|
||||
};
|
||||
assert_eq!(success_json(&result).source, "nmap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_json_has_expected_shape() {
|
||||
let err = Mac2IpError::NotFound {
|
||||
mac: mac().to_string(),
|
||||
};
|
||||
let json = failure_json(&mac(), &err);
|
||||
assert_eq!(json.status, "error");
|
||||
assert_eq!(json.mac, "aa:bb:cc:dd:ee:ff");
|
||||
assert_eq!(json.error, err.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_json_serializes_with_stable_field_names() {
|
||||
let result = ResolveResult {
|
||||
mac: mac(),
|
||||
ip: ip(),
|
||||
source: MatchSource::Arp,
|
||||
};
|
||||
let value = serde_json::to_value(success_json(&result)).unwrap();
|
||||
assert_eq!(value["status"], "ok");
|
||||
assert_eq!(value["mac"], "aa:bb:cc:dd:ee:ff");
|
||||
assert_eq!(value["ip"], "192.168.1.5");
|
||||
assert_eq!(value["source"], "arp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_json_serializes_with_stable_field_names() {
|
||||
let err = Mac2IpError::NotFound {
|
||||
mac: mac().to_string(),
|
||||
};
|
||||
let value = serde_json::to_value(failure_json(&mac(), &err)).unwrap();
|
||||
assert_eq!(value["status"], "error");
|
||||
assert_eq!(value["mac"], "aa:bb:cc:dd:ee:ff");
|
||||
assert!(value["error"].is_string());
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
use mac2ip::mac::MacAddress;
|
||||
|
||||
#[test]
|
||||
fn parses_lowercase_colon_separated() {
|
||||
let mac = MacAddress::parse("aa:bb:cc:dd:ee:ff").unwrap();
|
||||
assert_eq!(mac.to_lower_colon(), "aa:bb:cc:dd:ee:ff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_uppercase_colon_separated() {
|
||||
let mac = MacAddress::parse("AA:BB:CC:DD:EE:FF").unwrap();
|
||||
assert_eq!(mac.to_lower_colon(), "aa:bb:cc:dd:ee:ff");
|
||||
assert_eq!(mac.to_upper_colon(), "AA:BB:CC:DD:EE:FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hyphen_separated() {
|
||||
let mac = MacAddress::parse("aa-bb-cc-dd-ee-ff").unwrap();
|
||||
assert_eq!(mac.to_lower_colon(), "aa:bb:cc:dd:ee:ff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_mixed_case() {
|
||||
let mac = MacAddress::parse("Aa:bB:Cc:dD:eE:fF").unwrap();
|
||||
assert_eq!(mac.to_lower_colon(), "aa:bb:cc:dd:ee:ff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_notations_are_equal_after_canonicalization() {
|
||||
let a = MacAddress::parse("AA:BB:CC:DD:EE:FF").unwrap();
|
||||
let b = MacAddress::parse("aa-bb-cc-dd-ee-ff").unwrap();
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_str_matches_parse() {
|
||||
use std::str::FromStr;
|
||||
let mac: MacAddress = "aa:bb:cc:dd:ee:ff".parse().unwrap();
|
||||
assert_eq!(mac, MacAddress::from_str("aa:bb:cc:dd:ee:ff").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_short() {
|
||||
assert!(MacAddress::parse("aa:bb:cc:dd:ee").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_long() {
|
||||
assert!(MacAddress::parse("aa:bb:cc:dd:ee:ff:00").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_hex_characters() {
|
||||
assert!(MacAddress::parse("gg:bb:cc:dd:ee:ff").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_wrong_separator_count() {
|
||||
assert!(MacAddress::parse("aabbccddeeff").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_string() {
|
||||
assert!(MacAddress::parse("").is_err());
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use mac2ip::mac::MacAddress;
|
||||
use mac2ip::network::parse_nmap_output;
|
||||
|
||||
fn mac(s: &str) -> MacAddress {
|
||||
MacAddress::parse(s).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_matching_host_among_multiple_blocks() {
|
||||
let output = "\
|
||||
Nmap scan report for 192.168.1.4
|
||||
Host is up (0.0010s latency).
|
||||
MAC Address: 11:22:33:44:55:66 (Some Vendor)
|
||||
Nmap scan report for 192.168.1.5
|
||||
Host is up (0.0020s latency).
|
||||
MAC Address: AA:BB:CC:DD:EE:FF (Other Vendor)
|
||||
Nmap scan report for 192.168.1.6
|
||||
Host is up (0.0030s latency).
|
||||
MAC Address: 77:88:99:AA:BB:CC (Third Vendor)
|
||||
";
|
||||
let ip = parse_nmap_output(output, &mac("aa:bb:cc:dd:ee:ff")).unwrap();
|
||||
assert_eq!(ip.to_string(), "192.168.1.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_without_mac_line_does_not_leak_into_next_block() {
|
||||
let output = "\
|
||||
Nmap scan report for 192.168.1.4
|
||||
Host is up (0.0010s latency).
|
||||
Nmap scan report for 192.168.1.5
|
||||
Host is up (0.0020s latency).
|
||||
MAC Address: AA:BB:CC:DD:EE:FF (Vendor)
|
||||
";
|
||||
// Even if we searched for a MAC that never appears, the missing MAC line for
|
||||
// 192.168.1.4 must not accidentally get associated with 192.168.1.5's MAC.
|
||||
assert!(parse_nmap_output(output, &mac("11:22:33:44:55:66")).is_none());
|
||||
let ip = parse_nmap_output(output, &mac("aa:bb:cc:dd:ee:ff")).unwrap();
|
||||
assert_eq!(ip.to_string(), "192.168.1.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_hostname_plus_ip_form() {
|
||||
let output = "\
|
||||
Nmap scan report for myhost.lan (192.168.1.5)
|
||||
Host is up (0.0020s latency).
|
||||
MAC Address: AA:BB:CC:DD:EE:FF (Vendor)
|
||||
";
|
||||
let ip = parse_nmap_output(output, &mac("aa:bb:cc:dd:ee:ff")).unwrap();
|
||||
assert_eq!(ip.to_string(), "192.168.1.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_case_insensitive_against_uppercase_nmap_output() {
|
||||
let output = "\
|
||||
Nmap scan report for 192.168.1.5
|
||||
MAC Address: AA:BB:CC:DD:EE:FF (Vendor)
|
||||
";
|
||||
let ip = parse_nmap_output(output, &mac("aa:bb:cc:dd:ee:ff")).unwrap();
|
||||
assert_eq!(ip.to_string(), "192.168.1.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_match() {
|
||||
let output = "\
|
||||
Nmap scan report for 192.168.1.4
|
||||
MAC Address: 11:22:33:44:55:66 (Vendor)
|
||||
";
|
||||
assert!(parse_nmap_output(output, &mac("aa:bb:cc:dd:ee:ff")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_output_returns_none() {
|
||||
assert!(parse_nmap_output("", &mac("aa:bb:cc:dd:ee:ff")).is_none());
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use mac2ip::network::parse_local_subnets;
|
||||
|
||||
#[test]
|
||||
fn extracts_cidrs_from_multiple_interfaces() {
|
||||
let output = "\
|
||||
192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.23
|
||||
10.0.0.0/8 dev wlan0 proto kernel scope link src 10.0.0.5
|
||||
";
|
||||
let subnets = parse_local_subnets(output);
|
||||
assert_eq!(
|
||||
subnets,
|
||||
vec!["192.168.1.0/24".to_string(), "10.0.0.0/8".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_default_route() {
|
||||
let output = "\
|
||||
default via 192.168.1.1 dev eth0
|
||||
192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.23
|
||||
";
|
||||
let subnets = parse_local_subnets(output);
|
||||
assert_eq!(subnets, vec!["192.168.1.0/24".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_loopback_interface() {
|
||||
let output = "\
|
||||
127.0.0.0/8 dev lo proto kernel scope link src 127.0.0.1
|
||||
192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.23
|
||||
";
|
||||
let subnets = parse_local_subnets(output);
|
||||
assert_eq!(subnets, vec!["192.168.1.0/24".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_returns_empty_vec() {
|
||||
let subnets = parse_local_subnets("");
|
||||
assert!(subnets.is_empty());
|
||||
}
|
||||
Reference in New Issue
Block a user