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:
2026-09-13 18:28:51 +02:00
co-authored by Claude Sonnet 5
parent 1ec2b92c87
commit 8f6570e385
7 changed files with 429 additions and 0 deletions
+28
View File
@@ -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));
}
+80
View File
@@ -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);
}
+52
View File
@@ -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());
}
+90
View File
@@ -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());
}
+65
View File
@@ -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());
}
+74
View File
@@ -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());
}
+40
View File
@@ -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());
}