From 8f6570e385ccf7b251a0aeed5097e3c03af53020 Mon Sep 17 00:00:00 2001 From: DragonSlayer_14 Date: Sun, 13 Sep 2026 18:28:51 +0200 Subject: [PATCH] =?UTF-8?q?Unit-/Integrationstests=20f=C3=BCr=20Parsing-?= =?UTF-8?q?=20und=20Konfigurationslogik=20erg=C3=A4nzen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_0118Wbg95ADSDynYfci2qUjK --- tests/cache_ttl.rs | 28 ++++++++++++ tests/config_overrides.rs | 80 ++++++++++++++++++++++++++++++++++ tests/ip_neigh_parsing.rs | 52 ++++++++++++++++++++++ tests/json_output.rs | 90 +++++++++++++++++++++++++++++++++++++++ tests/mac_address.rs | 65 ++++++++++++++++++++++++++++ tests/nmap_parsing.rs | 74 ++++++++++++++++++++++++++++++++ tests/subnet_parsing.rs | 40 +++++++++++++++++ 7 files changed, 429 insertions(+) create mode 100644 tests/cache_ttl.rs create mode 100644 tests/config_overrides.rs create mode 100644 tests/ip_neigh_parsing.rs create mode 100644 tests/json_output.rs create mode 100644 tests/mac_address.rs create mode 100644 tests/nmap_parsing.rs create mode 100644 tests/subnet_parsing.rs diff --git a/tests/cache_ttl.rs b/tests/cache_ttl.rs new file mode 100644 index 0000000..c13424d --- /dev/null +++ b/tests/cache_ttl.rs @@ -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)); +} diff --git a/tests/config_overrides.rs b/tests/config_overrides.rs new file mode 100644 index 0000000..34b13c3 --- /dev/null +++ b/tests/config_overrides.rs @@ -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); +} diff --git a/tests/ip_neigh_parsing.rs b/tests/ip_neigh_parsing.rs new file mode 100644 index 0000000..55d50ae --- /dev/null +++ b/tests/ip_neigh_parsing.rs @@ -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()); +} diff --git a/tests/json_output.rs b/tests/json_output.rs new file mode 100644 index 0000000..d12441b --- /dev/null +++ b/tests/json_output.rs @@ -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()); +} diff --git a/tests/mac_address.rs b/tests/mac_address.rs new file mode 100644 index 0000000..f26c534 --- /dev/null +++ b/tests/mac_address.rs @@ -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()); +} diff --git a/tests/nmap_parsing.rs b/tests/nmap_parsing.rs new file mode 100644 index 0000000..4f5ec73 --- /dev/null +++ b/tests/nmap_parsing.rs @@ -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()); +} diff --git a/tests/subnet_parsing.rs b/tests/subnet_parsing.rs new file mode 100644 index 0000000..eeaea2a --- /dev/null +++ b/tests/subnet_parsing.rs @@ -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()); +}