From b8aecf0a55d3bdd08dbdd87cb42e9c2bf716b887 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 31 Jan 2026 15:26:36 -0600 Subject: [PATCH] more tests --- README.md | 2 +- docker-compose.example.yml | 2 +- src/buffer/mod.rs | 3 - src/buffer/storage.rs | 180 --------- src/main.rs | 131 ++++++- src/metrics/mod.rs | 149 -------- src/snmp/trap.rs | 753 +++++++++++++++++++++++++++++++++++++ src/version.rs | 15 +- 8 files changed, 890 insertions(+), 345 deletions(-) delete mode 100644 src/buffer/mod.rs delete mode 100644 src/buffer/storage.rs delete mode 100644 src/metrics/mod.rs diff --git a/README.md b/README.md index 8c4522a..5d7848c 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ services: image: gmcintire/towerops-agent:latest restart: unless-stopped environment: - - TOWEROPS_API_URL=https://app.towerops.com + - TOWEROPS_API_URL=https://towerops.net - TOWEROPS_AGENT_TOKEN=your-agent-token-here - LOG_LEVEL=info # Optional: Enable SNMP trap listener diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 21fe6b4..0ee185d 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -8,7 +8,7 @@ services: environment: # Required: Your Towerops API URL - - TOWEROPS_API_URL=https://app.towerops.com + - TOWEROPS_API_URL=https://towerops.net # Required: Agent authentication token (from Towerops web UI) # Get this from: Organization > Agents > Create New Agent diff --git a/src/buffer/mod.rs b/src/buffer/mod.rs deleted file mode 100644 index 7968e16..0000000 --- a/src/buffer/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod storage; - -pub use storage::{Storage, StorageError}; diff --git a/src/buffer/storage.rs b/src/buffer/storage.rs deleted file mode 100644 index 6ac52f7..0000000 --- a/src/buffer/storage.rs +++ /dev/null @@ -1,180 +0,0 @@ -use crate::metrics::{Metric, Timestamp}; -use rusqlite::{params, Connection}; -use std::path::Path; -use std::sync::{Arc, Mutex}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum StorageError { - #[error("Database error: {0}")] - Database(#[from] rusqlite::Error), - - #[error("Serialization error: {0}")] - Serialization(#[from] serde_json::Error), - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), -} - -pub type Result = std::result::Result; - -/// SQLite storage for buffering metrics when API is unavailable -#[derive(Clone)] -pub struct Storage { - conn: Arc>, -} - -impl Storage { - /// Create a new storage instance - pub fn new>(path: P) -> Result { - let conn = Connection::open(path)?; - - conn.execute( - "CREATE TABLE IF NOT EXISTS metrics ( - id INTEGER PRIMARY KEY, - metric_type TEXT NOT NULL, - data TEXT NOT NULL, - timestamp TEXT NOT NULL, - sent INTEGER DEFAULT 0, - created_at TEXT DEFAULT CURRENT_TIMESTAMP - )", - [], - )?; - - conn.execute( - "CREATE INDEX IF NOT EXISTS idx_metrics_sent ON metrics(sent, created_at)", - [], - )?; - - conn.execute( - "CREATE TABLE IF NOT EXISTS last_poll_times ( - equipment_id TEXT PRIMARY KEY, - last_poll_time TEXT NOT NULL - )", - [], - )?; - - Ok(Self { - conn: Arc::new(Mutex::new(conn)), - }) - } - - /// Store a metric in the buffer - pub fn store_metric(&self, metric: &Metric) -> Result<()> { - let data = serde_json::to_string(metric)?; - let conn = self.conn.lock().unwrap(); - - conn.execute( - "INSERT INTO metrics (metric_type, data, timestamp) VALUES (?1, ?2, ?3)", - params![metric.metric_type(), data, metric.timestamp().to_rfc3339()], - )?; - - Ok(()) - } - - /// Get pending metrics that haven't been sent yet - pub fn get_pending_metrics(&self, limit: usize) -> Result> { - let conn = self.conn.lock().unwrap(); - - let mut stmt = conn - .prepare("SELECT id, data FROM metrics WHERE sent = 0 ORDER BY created_at LIMIT ?1")?; - - let metrics = stmt - .query_map([limit], |row| { - let id: i64 = row.get(0)?; - let data: String = row.get(1)?; - Ok((id, data)) - })? - .filter_map(|r| r.ok()) - .filter_map(|(id, data)| { - serde_json::from_str::(&data) - .ok() - .map(|metric| (id, metric)) - }) - .collect(); - - Ok(metrics) - } - - /// Mark metrics as sent - pub fn mark_metrics_sent(&self, ids: &[i64]) -> Result<()> { - if ids.is_empty() { - return Ok(()); - } - - let conn = self.conn.lock().unwrap(); - let placeholders = ids.iter().map(|_| "?").collect::>().join(","); - let query = format!("UPDATE metrics SET sent = 1 WHERE id IN ({})", placeholders); - - let params: Vec<_> = ids.iter().map(|id| id as &dyn rusqlite::ToSql).collect(); - conn.execute(&query, params.as_slice())?; - - Ok(()) - } - - /// Clean up old metrics that have been sent - pub fn cleanup_old_metrics(&self) -> Result<()> { - let conn = self.conn.lock().unwrap(); - - conn.execute( - "DELETE FROM metrics WHERE sent = 1 AND created_at < datetime('now', '-24 hours')", - [], - )?; - - Ok(()) - } - - /// Get the last poll time for equipment - #[allow(dead_code)] // Used in full SNMP implementation - pub fn get_last_poll_time(&self, equipment_id: &str) -> Result> { - let conn = self.conn.lock().unwrap(); - - let result: std::result::Result = conn.query_row( - "SELECT last_poll_time FROM last_poll_times WHERE equipment_id = ?1", - params![equipment_id], - |row| row.get(0), - ); - - match result { - Ok(_timestamp_str) => { - // Return a Timestamp - parsing from string not implemented yet - // For now just return None since this function isn't used yet - Ok(None) - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } - - /// Update the last poll time for equipment - pub fn update_last_poll_time(&self, equipment_id: &str) -> Result<()> { - let conn = self.conn.lock().unwrap(); - let now = Timestamp::now().to_rfc3339(); - - conn.execute( - "INSERT OR REPLACE INTO last_poll_times (equipment_id, last_poll_time) VALUES (?1, ?2)", - params![equipment_id, now], - )?; - - Ok(()) - } - - /// Get all last poll times - pub fn get_all_last_poll_times(&self) -> Result> { - let conn = self.conn.lock().unwrap(); - - let mut stmt = conn.prepare("SELECT equipment_id, last_poll_time FROM last_poll_times")?; - - let times: std::collections::HashMap = stmt - .query_map([], |row| { - let equipment_id: String = row.get(0)?; - let _timestamp_str: String = row.get(1)?; - Ok(equipment_id) - })? - .filter_map(|r| r.ok()) - .map(|id| (id, Timestamp::now())) // Simplified - not parsing timestamps yet - .collect(); - - Ok(times) - } -} diff --git a/src/main.rs b/src/main.rs index fc827b9..f29589e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -172,7 +172,7 @@ fn convert_to_websocket_url(url: &str) -> String { #[command(name = "towerops-agent")] #[command(about = "Towerops remote SNMP polling agent", long_about = None)] struct Args { - /// API URL (e.g., wss://app.towerops.com or https://app.towerops.com) + /// API URL (e.g., wss://towerops.net or https://towerops.net) #[arg(long, env = "TOWEROPS_API_URL")] api_url: String, @@ -352,8 +352,8 @@ mod tests { #[test] fn test_convert_https_to_websocket() { assert_eq!( - convert_to_websocket_url("https://app.towerops.com"), - "wss://app.towerops.com" + convert_to_websocket_url("https://towerops.net"), + "wss://towerops.net" ); } @@ -364,16 +364,16 @@ mod tests { "ws://localhost:4000" ); assert_eq!( - convert_to_websocket_url("wss://app.towerops.com"), - "wss://app.towerops.com" + convert_to_websocket_url("wss://towerops.net"), + "wss://towerops.net" ); } #[test] fn test_bare_domain_gets_wss() { assert_eq!( - convert_to_websocket_url("app.towerops.com"), - "wss://app.towerops.com" + convert_to_websocket_url("towerops.net"), + "wss://towerops.net" ); assert_eq!( convert_to_websocket_url("localhost:4000"), @@ -420,7 +420,118 @@ mod tests { assert_eq!(days_to_month_day(364, false), (12, 31)); } - // Note: main() function and init_logger() are not unit tested as they - // involve global state and tokio runtime initialization. - // They are tested via manual/integration testing. + #[test] + fn test_log_level_from_str_error() { + assert_eq!(LogLevel::from_str("error"), LogLevel::Error); + assert_eq!(LogLevel::from_str("ERROR"), LogLevel::Error); + assert_eq!(LogLevel::from_str("Error"), LogLevel::Error); + } + + #[test] + fn test_log_level_from_str_warn() { + assert_eq!(LogLevel::from_str("warn"), LogLevel::Warn); + assert_eq!(LogLevel::from_str("WARN"), LogLevel::Warn); + assert_eq!(LogLevel::from_str("Warn"), LogLevel::Warn); + } + + #[test] + fn test_log_level_from_str_info() { + assert_eq!(LogLevel::from_str("info"), LogLevel::Info); + assert_eq!(LogLevel::from_str("INFO"), LogLevel::Info); + assert_eq!(LogLevel::from_str("Info"), LogLevel::Info); + } + + #[test] + fn test_log_level_from_str_debug() { + assert_eq!(LogLevel::from_str("debug"), LogLevel::Debug); + assert_eq!(LogLevel::from_str("DEBUG"), LogLevel::Debug); + assert_eq!(LogLevel::from_str("Debug"), LogLevel::Debug); + } + + #[test] + fn test_log_level_from_str_unknown() { + // Unknown values default to Info + assert_eq!(LogLevel::from_str("unknown"), LogLevel::Info); + assert_eq!(LogLevel::from_str("trace"), LogLevel::Info); + assert_eq!(LogLevel::from_str(""), LogLevel::Info); + } + + #[test] + fn test_log_level_ordering() { + assert!(LogLevel::Error < LogLevel::Warn); + assert!(LogLevel::Warn < LogLevel::Info); + assert!(LogLevel::Info < LogLevel::Debug); + } + + #[test] + fn test_log_level_eq() { + assert_eq!(LogLevel::Error, LogLevel::Error); + assert_eq!(LogLevel::Warn, LogLevel::Warn); + assert_eq!(LogLevel::Info, LogLevel::Info); + assert_eq!(LogLevel::Debug, LogLevel::Debug); + } + + #[test] + fn test_log_level_copy() { + let level = LogLevel::Debug; + let copied = level; + assert_eq!(copied, LogLevel::Debug); + } + + #[test] + fn test_log_level_clone() { + let level = LogLevel::Warn; + let cloned = level.clone(); + assert_eq!(cloned, LogLevel::Warn); + } + + #[test] + fn test_days_to_month_day_february() { + // February 28th in non-leap year (day 58) + assert_eq!(days_to_month_day(58, false), (2, 28)); + + // February 29th in leap year (day 59) + assert_eq!(days_to_month_day(59, true), (2, 29)); + } + + #[test] + fn test_days_to_month_day_later_months() { + // April 15th in non-leap year (day 105) + // Jan=31, Feb=28, Mar=31, Apr 1-15 = 31+28+31+14 = 104 (0-indexed day 104) + assert_eq!(days_to_month_day(104, false), (4, 15)); + + // July 4th in non-leap year + // Jan=31, Feb=28, Mar=31, Apr=30, May=31, Jun=30, Jul 1-4 = 31+28+31+30+31+30+3 = 184 (day 184) + assert_eq!(days_to_month_day(184, false), (7, 4)); + } + + #[test] + fn test_days_to_month_day_end_of_year() { + // December 31st in leap year (day 365) + assert_eq!(days_to_month_day(365, true), (12, 31)); + + // December 25th in non-leap year + // 31+28+31+30+31+30+31+31+30+31+30+24 = 358 (day 358) + assert_eq!(days_to_month_day(358, false), (12, 25)); + } + + #[test] + fn test_days_to_month_day_overflow_fallback() { + // Day 400 is beyond year - should fallback to Dec 31 + assert_eq!(days_to_month_day(400, false), (12, 31)); + } + + #[test] + fn test_format_timestamp_structure() { + let timestamp = format_timestamp(); + // Should be in format "YYYY-MM-DD HH:MM:SS.mmm" + let parts: Vec<&str> = timestamp.split(' ').collect(); + assert_eq!(parts.len(), 2, "Expected date and time parts"); + + let date_parts: Vec<&str> = parts[0].split('-').collect(); + assert_eq!(date_parts.len(), 3, "Expected year-month-day"); + + let time_parts: Vec<&str> = parts[1].split(':').collect(); + assert_eq!(time_parts.len(), 3, "Expected hour:min:sec.ms"); + } } diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs deleted file mode 100644 index 8db631f..0000000 --- a/src/metrics/mod.rs +++ /dev/null @@ -1,149 +0,0 @@ -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use std::time::{SystemTime, UNIX_EPOCH}; - -/// Timestamp that serializes to RFC3339 format -#[derive(Debug, Clone, Copy)] -pub struct Timestamp { - secs: i64, -} - -impl Timestamp { - pub fn now() -> Self { - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("Time went backwards"); - Self { - secs: duration.as_secs() as i64, - } - } - - pub fn elapsed_secs(&self) -> i64 { - Self::now().secs - self.secs - } - - pub fn to_unix_timestamp(&self) -> i64 { - self.secs - } - - pub fn to_rfc3339(self) -> String { - // Convert Unix timestamp to RFC3339 format - // This is a simplified implementation that produces UTC timestamps - let secs = self.secs; - let days = secs / 86400; - let rem_secs = secs % 86400; - let hours = rem_secs / 3600; - let minutes = (rem_secs % 3600) / 60; - let seconds = rem_secs % 60; - - // Calculate year/month/day from days since epoch (1970-01-01) - let (year, month, day) = days_to_ymd(days); - - format!( - "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", - year, month, day, hours, minutes, seconds - ) - } -} - -impl Serialize for Timestamp { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&self.to_rfc3339()) - } -} - -impl<'de> Deserialize<'de> for Timestamp { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let _s = String::deserialize(deserializer)?; - // Simple RFC3339 parsing - accepts the format we produce - // For a production system, you might want more robust parsing - Ok(Timestamp { secs: 0 }) // Simplified - we mainly serialize, not deserialize - } -} - -/// Convert days since Unix epoch to year/month/day -fn days_to_ymd(mut days: i64) -> (i32, u8, u8) { - let mut year = 1970; - loop { - let days_in_year = if is_leap_year(year) { 366 } else { 365 }; - if days < days_in_year { - break; - } - days -= days_in_year; - year += 1; - } - - let days_in_months = if is_leap_year(year) { - [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - } else { - [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - }; - - let mut month = 1; - for &dim in &days_in_months { - if days < dim as i64 { - break; - } - days -= dim as i64; - month += 1; - } - - (year, month, days as u8 + 1) -} - -fn is_leap_year(year: i32) -> bool { - (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) -} - -/// Metric types that can be submitted to the API -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum Metric { - #[serde(rename = "sensor_reading")] - SensorReading(SensorReading), - #[serde(rename = "interface_stat")] - InterfaceStat(InterfaceStat), -} - -impl Metric { - pub fn metric_type(&self) -> &str { - match self { - Metric::SensorReading(_) => "sensor_reading", - Metric::InterfaceStat(_) => "interface_stat", - } - } - - pub fn timestamp(&self) -> &Timestamp { - match self { - Metric::SensorReading(sr) => &sr.timestamp, - Metric::InterfaceStat(is) => &is.timestamp, - } - } -} - -/// Sensor reading metric -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SensorReading { - pub sensor_id: String, - pub value: f64, - pub status: String, - pub timestamp: Timestamp, -} - -/// Interface statistics metric -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InterfaceStat { - pub interface_id: String, - pub if_in_octets: i64, - pub if_out_octets: i64, - pub if_in_errors: i64, - pub if_out_errors: i64, - pub if_in_discards: i64, - pub if_out_discards: i64, - pub timestamp: Timestamp, -} diff --git a/src/snmp/trap.rs b/src/snmp/trap.rs index f545a7e..4123208 100644 --- a/src/snmp/trap.rs +++ b/src/snmp/trap.rs @@ -729,4 +729,757 @@ mod tests { fn test_parse_ip_address_invalid_length() { assert!(parse_ip_address(&[192, 168, 1]).is_err()); } + + #[test] + fn test_generic_trap_from_u8_all_variants() { + assert!(matches!( + GenericTrap::from_u8(0), + Some(GenericTrap::ColdStart) + )); + assert!(matches!( + GenericTrap::from_u8(1), + Some(GenericTrap::WarmStart) + )); + assert!(matches!( + GenericTrap::from_u8(2), + Some(GenericTrap::LinkDown) + )); + assert!(matches!(GenericTrap::from_u8(3), Some(GenericTrap::LinkUp))); + assert!(matches!( + GenericTrap::from_u8(4), + Some(GenericTrap::AuthenticationFailure) + )); + assert!(matches!( + GenericTrap::from_u8(5), + Some(GenericTrap::EgpNeighborLoss) + )); + assert!(matches!( + GenericTrap::from_u8(6), + Some(GenericTrap::EnterpriseSpecific) + )); + assert!(GenericTrap::from_u8(7).is_none()); + assert!(GenericTrap::from_u8(255).is_none()); + } + + #[test] + fn test_generic_trap_display_all_variants() { + assert_eq!(format!("{}", GenericTrap::ColdStart), "coldStart"); + assert_eq!(format!("{}", GenericTrap::WarmStart), "warmStart"); + assert_eq!(format!("{}", GenericTrap::LinkDown), "linkDown"); + assert_eq!(format!("{}", GenericTrap::LinkUp), "linkUp"); + assert_eq!( + format!("{}", GenericTrap::AuthenticationFailure), + "authenticationFailure" + ); + assert_eq!( + format!("{}", GenericTrap::EgpNeighborLoss), + "egpNeighborLoss" + ); + assert_eq!( + format!("{}", GenericTrap::EnterpriseSpecific), + "enterpriseSpecific" + ); + } + + #[test] + fn test_parse_tlv_simple() { + // INTEGER 5: tag=0x02, length=0x01, value=0x05 + let data = [0x02, 0x01, 0x05]; + let (tag, value, remaining) = parse_tlv(&data).unwrap(); + assert_eq!(tag, 0x02); + assert_eq!(value, &[0x05]); + assert!(remaining.is_empty()); + } + + #[test] + fn test_parse_tlv_with_remaining() { + // INTEGER 5 followed by more data + let data = [0x02, 0x01, 0x05, 0x04, 0x02, 0x41, 0x42]; + let (tag, value, remaining) = parse_tlv(&data).unwrap(); + assert_eq!(tag, 0x02); + assert_eq!(value, &[0x05]); + assert_eq!(remaining, &[0x04, 0x02, 0x41, 0x42]); + } + + #[test] + fn test_parse_tlv_empty() { + let result = parse_tlv(&[]); + assert!(result.is_err()); + } + + #[test] + fn test_parse_tlv_too_short() { + // Says length is 5 but only has 2 bytes + let data = [0x02, 0x05, 0x01, 0x02]; + let result = parse_tlv(&data); + assert!(result.is_err()); + } + + #[test] + fn test_parse_length_empty() { + let result = parse_length(&[]); + assert!(result.is_err()); + } + + #[test] + fn test_parse_length_indefinite() { + // Indefinite length (0x80) not supported + let result = parse_length(&[0x80]); + assert!(result.is_err()); + } + + #[test] + fn test_parse_length_too_long() { + // Length field says 5 bytes but not enough data + let result = parse_length(&[0x85, 0x01]); + assert!(result.is_err()); + } + + #[test] + fn test_parse_integer_empty() { + assert_eq!(parse_integer(&[]).unwrap(), 0); + } + + #[test] + fn test_parse_integer_multibyte() { + // 256 = 0x0100 + assert_eq!(parse_integer(&[0x01, 0x00]).unwrap(), 256); + // -256 = 0xFF00 + assert_eq!(parse_integer(&[0xFF, 0x00]).unwrap(), -256); + } + + #[test] + fn test_parse_unsigned() { + assert_eq!(parse_unsigned(&[]).unwrap(), 0); + assert_eq!(parse_unsigned(&[0x01]).unwrap(), 1); + assert_eq!(parse_unsigned(&[0xFF]).unwrap(), 255); + assert_eq!(parse_unsigned(&[0x01, 0x00]).unwrap(), 256); + } + + #[test] + fn test_parse_oid_empty() { + assert_eq!(parse_oid(&[]).unwrap(), ""); + } + + #[test] + fn test_parse_value_to_string_integer() { + let result = parse_value_to_string(ber_tags::INTEGER, &[0x2A]); + assert_eq!(result, "42"); + } + + #[test] + fn test_parse_value_to_string_octet_string() { + let result = parse_value_to_string(ber_tags::OCTET_STRING, b"test"); + assert_eq!(result, "test"); + } + + #[test] + fn test_parse_value_to_string_object_identifier() { + let oid_bytes = [0x2B, 0x06, 0x01, 0x02, 0x01]; + let result = parse_value_to_string(ber_tags::OBJECT_IDENTIFIER, &oid_bytes); + assert_eq!(result, "1.3.6.1.2.1"); + } + + #[test] + fn test_parse_value_to_string_null() { + let result = parse_value_to_string(ber_tags::NULL, &[]); + assert_eq!(result, "null"); + } + + #[test] + fn test_parse_value_to_string_ip_address() { + let result = parse_value_to_string(ber_tags::IP_ADDRESS, &[10, 0, 0, 1]); + assert_eq!(result, "10.0.0.1"); + } + + #[test] + fn test_parse_value_to_string_counter32() { + let result = parse_value_to_string(ber_tags::COUNTER32, &[0x00, 0x01]); + assert_eq!(result, "1"); + } + + #[test] + fn test_parse_value_to_string_gauge32() { + let result = parse_value_to_string(ber_tags::GAUGE32, &[0x64]); + assert_eq!(result, "100"); + } + + #[test] + fn test_parse_value_to_string_timeticks() { + let result = parse_value_to_string(ber_tags::TIMETICKS, &[0x00, 0x01]); + assert_eq!(result, "1"); + } + + #[test] + fn test_parse_value_to_string_counter64() { + let result = parse_value_to_string(ber_tags::COUNTER64, &[0x01, 0x00]); + assert_eq!(result, "256"); + } + + #[test] + fn test_parse_value_to_string_unknown() { + let result = parse_value_to_string(0xFF, &[0x01]); + assert!(result.contains("tag=0xff")); + } + + #[test] + fn test_parse_varbinds_empty() { + let result = parse_varbinds(&[]).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_snmp_trap_display_v1_no_varbinds() { + let trap = SnmpTrap { + source_addr: "192.168.1.1:161".parse().unwrap(), + version: SnmpVersion::V1, + community: "public".to_string(), + trap_oid: "1.3.6.1.4.1.9.9.41".to_string(), + generic_trap: Some(GenericTrap::ColdStart), + specific_trap: None, + uptime: 0, + varbinds: vec![], + }; + + let display = format!("{}", trap); + assert!(display.contains("[v1]")); + assert!(display.contains("generic=coldStart")); + assert!(!display.contains("varbinds=")); + } + + #[test] + fn test_snmp_trap_display_v1_no_generic_trap() { + let trap = SnmpTrap { + source_addr: "192.168.1.1:161".parse().unwrap(), + version: SnmpVersion::V1, + community: "public".to_string(), + trap_oid: "1.3.6.1.4.1.9".to_string(), + generic_trap: None, + specific_trap: None, + uptime: 100, + varbinds: vec![], + }; + + let display = format!("{}", trap); + assert!(display.contains("[v1]")); + assert!(!display.contains("generic=")); + assert!(!display.contains("specific=")); + } + + #[test] + fn test_snmp_trap_display_multiple_varbinds() { + let trap = SnmpTrap { + source_addr: "10.0.0.1:162".parse().unwrap(), + version: SnmpVersion::V2c, + community: "private".to_string(), + trap_oid: "1.3.6.1.6.3.1.1.5.3".to_string(), + generic_trap: None, + specific_trap: None, + uptime: 5000, + varbinds: vec![ + ("1.3.6.1.2.1.2.2.1.1".to_string(), "1".to_string()), + ("1.3.6.1.2.1.2.2.1.2".to_string(), "eth0".to_string()), + ], + }; + + let display = format!("{}", trap); + assert!(display.contains("10.0.0.1:162")); + assert!(display.contains("[v2c]")); + assert!(display.contains("varbinds=[")); + assert!(display.contains(", ")); + } + + #[test] + fn test_trap_listener_new() { + let listener = TrapListener::new(1620); + assert_eq!(listener.port, 1620); + } + + #[test] + fn test_parse_error_display() { + let err = ParseError("test error".to_string()); + assert_eq!(format!("{}", err), "test error"); + } + + #[test] + fn test_snmp_version_eq() { + assert_eq!(SnmpVersion::V1, SnmpVersion::V1); + assert_eq!(SnmpVersion::V2c, SnmpVersion::V2c); + assert_ne!(SnmpVersion::V1, SnmpVersion::V2c); + } + + #[test] + fn test_snmp_trap_clone() { + let trap = SnmpTrap { + source_addr: "192.168.1.1:161".parse().unwrap(), + version: SnmpVersion::V1, + community: "public".to_string(), + trap_oid: "1.3.6.1.4.1.9".to_string(), + generic_trap: Some(GenericTrap::LinkUp), + specific_trap: Some(42), + uptime: 12345, + varbinds: vec![("oid".to_string(), "value".to_string())], + }; + + let cloned = trap.clone(); + assert_eq!(trap.source_addr, cloned.source_addr); + assert_eq!(trap.version, cloned.version); + assert_eq!(trap.community, cloned.community); + } + + #[test] + fn test_generic_trap_copy() { + let trap = GenericTrap::WarmStart; + let copied = trap; + assert!(matches!(copied, GenericTrap::WarmStart)); + } + + #[test] + fn test_snmp_version_copy() { + let version = SnmpVersion::V2c; + let copied = version; + assert_eq!(copied, SnmpVersion::V2c); + } + + // Test full SNMPv1 trap parsing with a minimal but valid trap packet + #[test] + fn test_parse_trap_v1() { + // Build a valid SNMPv1 trap packet + // Inner Trap-PDU contents: + // OID 1.3 (enterprise): 06 01 2B + // IpAddress 10.0.0.1: 40 04 0A 00 00 01 + // INTEGER 0 (generic): 02 01 00 + // INTEGER 0 (specific): 02 01 00 + // TimeTicks 0: 43 01 00 + // SEQUENCE {} (varbinds): 30 00 + // Total Trap-PDU content = 3 + 6 + 3 + 3 + 3 + 2 = 20 bytes + let trap_pdu: Vec = vec![ + 0x06, 0x01, 0x2B, // OID 1.3 + 0x40, 0x04, 0x0A, 0x00, 0x00, 0x01, // IpAddress 10.0.0.1 + 0x02, 0x01, 0x00, // INTEGER 0 (generic-trap) + 0x02, 0x01, 0x00, // INTEGER 0 (specific-trap) + 0x43, 0x01, 0x00, // TimeTicks 0 + 0x30, 0x00, // SEQUENCE {} (varbinds) + ]; + + // Full message: + // SEQUENCE { version, community, Trap-PDU } + // version: 02 01 00 (3 bytes) + // community "pub": 04 03 70 75 62 (5 bytes) + // Trap-PDU [4]: A4 + let mut trap_bytes: Vec = vec![ + 0x30, + 0x00, // SEQUENCE with placeholder length + 0x02, + 0x01, + 0x00, // INTEGER 0 (version SNMPv1) + 0x04, + 0x03, + 0x70, + 0x75, + 0x62, // OCTET STRING "pub" + 0xA4, + trap_pdu.len() as u8, // Trap-PDU tag with length + ]; + trap_bytes.extend_from_slice(&trap_pdu); + // Fix outer SEQUENCE length + trap_bytes[1] = (trap_bytes.len() - 2) as u8; + + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&trap_bytes, source_addr); + assert!(result.is_ok(), "Expected Ok, got {:?}", result); + + let trap = result.unwrap(); + assert_eq!(trap.version, SnmpVersion::V1); + assert_eq!(trap.community, "pub"); + assert!(trap.generic_trap.is_some()); + } + + // Test full SNMPv2c trap parsing + #[test] + fn test_parse_trap_v2c() { + // Build a valid SNMPv2c trap packet + // Inner SNMPv2-Trap-PDU contents: + // INTEGER 1 (request-id): 02 01 01 + // INTEGER 0 (error-status): 02 01 00 + // INTEGER 0 (error-index): 02 01 00 + // SEQUENCE {} (varbinds): 30 00 + // Total PDU content = 3 + 3 + 3 + 2 = 11 bytes + let trap_pdu: Vec = vec![ + 0x02, 0x01, 0x01, // INTEGER 1 (request-id) + 0x02, 0x01, 0x00, // INTEGER 0 (error-status) + 0x02, 0x01, 0x00, // INTEGER 0 (error-index) + 0x30, 0x00, // SEQUENCE {} (varbinds) + ]; + + // Full message: + // SEQUENCE { version, community, SNMPv2-Trap-PDU } + let mut trap_bytes: Vec = vec![ + 0x30, + 0x00, // SEQUENCE with placeholder length + 0x02, + 0x01, + 0x01, // INTEGER 1 (version SNMPv2c) + 0x04, + 0x03, + 0x70, + 0x75, + 0x62, // OCTET STRING "pub" + 0xA7, + trap_pdu.len() as u8, // SNMPv2-Trap-PDU tag with length + ]; + trap_bytes.extend_from_slice(&trap_pdu); + // Fix outer SEQUENCE length + trap_bytes[1] = (trap_bytes.len() - 2) as u8; + + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&trap_bytes, source_addr); + assert!(result.is_ok(), "Expected Ok, got {:?}", result); + + let trap = result.unwrap(); + assert_eq!(trap.version, SnmpVersion::V2c); + assert_eq!(trap.community, "pub"); + } + + #[test] + fn test_parse_trap_invalid_outer_sequence() { + // Not a SEQUENCE + let trap_bytes: Vec = vec![0x02, 0x01, 0x00]; + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&trap_bytes, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_trap_unsupported_version() { + // SEQUENCE with version 2 (not v1=0 or v2c=1) + let trap_bytes: Vec = vec![ + 0x30, 0x0A, // INTEGER 2 (unsupported) + 0x02, 0x01, 0x02, // OCTET STRING "pub" + 0x04, 0x03, 0x70, 0x75, 0x62, // Minimal PDU + 0xA7, 0x00, + ]; + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&trap_bytes, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_trap_invalid_version_tag() { + // Version is not an INTEGER + let trap_bytes: Vec = vec![ + 0x30, 0x08, // OCTET STRING instead of INTEGER for version + 0x04, 0x01, 0x00, // OCTET STRING "pub" + 0x04, 0x03, 0x70, 0x75, 0x62, + ]; + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&trap_bytes, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_trap_invalid_community_tag() { + // Community is not an OCTET STRING + let trap_bytes: Vec = vec![ + 0x30, 0x08, // INTEGER 0 (version) + 0x02, 0x01, 0x00, // INTEGER instead of OCTET STRING for community + 0x02, 0x01, 0x00, // PDU + 0xA4, 0x00, + ]; + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&trap_bytes, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_trap_v1_invalid_pdu_tag() { + // V1 with wrong PDU tag (should be 0xA4) + let trap_bytes: Vec = vec![ + 0x30, 0x0B, // INTEGER 0 (version v1) + 0x02, 0x01, 0x00, // OCTET STRING "pub" + 0x04, 0x03, 0x70, 0x75, 0x62, // Wrong PDU tag (0xA7 is v2c) + 0xA7, 0x00, + ]; + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&trap_bytes, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_trap_v2c_invalid_pdu_tag() { + // V2c with wrong PDU tag (should be 0xA7) + let trap_bytes: Vec = vec![ + 0x30, 0x0B, // INTEGER 1 (version v2c) + 0x02, 0x01, 0x01, // OCTET STRING "pub" + 0x04, 0x03, 0x70, 0x75, 0x62, // Wrong PDU tag (0xA4 is v1) + 0xA4, 0x00, + ]; + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&trap_bytes, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_varbinds_invalid_varbind_tag() { + // Varbind is not a SEQUENCE + let data: Vec = vec![ + // INTEGER instead of SEQUENCE + 0x02, 0x01, 0x00, + ]; + let result = parse_varbinds(&data); + assert!(result.is_err()); + } + + #[test] + fn test_parse_varbinds_invalid_oid_tag() { + // Valid SEQUENCE but OID is wrong type + let data: Vec = vec![ + // SEQUENCE + 0x30, 0x06, // INTEGER instead of OID + 0x02, 0x01, 0x00, // NULL value + 0x05, 0x00, + ]; + let result = parse_varbinds(&data); + assert!(result.is_err()); + } + + #[test] + fn test_parse_varbinds_valid() { + // Valid varbind: SEQUENCE { OID 1.3, INTEGER 42 } + let data: Vec = vec![ + // SEQUENCE + 0x30, 0x06, // OID 1.3 + 0x06, 0x01, 0x2B, // INTEGER 42 + 0x02, 0x01, 0x2A, + ]; + let result = parse_varbinds(&data).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].0, "1.3"); + assert_eq!(result[0].1, "42"); + } + + #[test] + fn test_parse_value_to_string_ip_address_invalid() { + // IP address with wrong length + let result = parse_value_to_string(ber_tags::IP_ADDRESS, &[192, 168, 1]); + assert_eq!(result, "?"); + } + + #[test] + fn test_parse_value_to_string_oid_empty() { + let result = parse_value_to_string(ber_tags::OBJECT_IDENTIFIER, &[]); + assert_eq!(result, ""); + } + + #[test] + fn test_parse_length_four_bytes() { + // Four-byte length: 0x84 followed by 4 bytes + let data = [0x84, 0x00, 0x00, 0x01, 0x00]; + let result = parse_length(&data); + assert!(result.is_ok()); + let (length, consumed) = result.unwrap(); + assert_eq!(length, 256); + assert_eq!(consumed, 5); + } + + #[test] + fn test_parse_integer_negative_multibyte() { + // -1 as two bytes: 0xFF 0xFF + assert_eq!(parse_integer(&[0xFF, 0xFF]).unwrap(), -1); + // -128 as single byte + assert_eq!(parse_integer(&[0x80]).unwrap(), -128); + // -129 as two bytes: 0xFF 0x7F + assert_eq!(parse_integer(&[0xFF, 0x7F]).unwrap(), -129); + } + + #[test] + fn test_parse_unsigned_large() { + // Large counter value + let data = [0x01, 0x00, 0x00, 0x00]; + let result = parse_unsigned(&data).unwrap(); + assert_eq!(result, 16777216); + } + + // Helper to build a minimal v1 trap with custom PDU content + fn build_v1_trap_packet(pdu_content: &[u8]) -> Vec { + let mut packet = vec![ + 0x30, + 0x00, // SEQUENCE placeholder + 0x02, + 0x01, + 0x00, // INTEGER 0 (version v1) + 0x04, + 0x03, + 0x70, + 0x75, + 0x62, // OCTET STRING "pub" + 0xA4, + pdu_content.len() as u8, // Trap-PDU + ]; + packet.extend_from_slice(pdu_content); + packet[1] = (packet.len() - 2) as u8; + packet + } + + // Helper to build a minimal v2c trap with custom PDU content + fn build_v2c_trap_packet(pdu_content: &[u8]) -> Vec { + let mut packet = vec![ + 0x30, + 0x00, // SEQUENCE placeholder + 0x02, + 0x01, + 0x01, // INTEGER 1 (version v2c) + 0x04, + 0x03, + 0x70, + 0x75, + 0x62, // OCTET STRING "pub" + 0xA7, + pdu_content.len() as u8, // SNMPv2-Trap-PDU + ]; + packet.extend_from_slice(pdu_content); + packet[1] = (packet.len() - 2) as u8; + packet + } + + #[test] + fn test_parse_v1_trap_invalid_enterprise_tag() { + // Enterprise should be OID, not INTEGER + let pdu = vec![ + 0x02, 0x01, 0x00, // INTEGER instead of OID + ]; + let packet = build_v1_trap_packet(&pdu); + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&packet, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_v1_trap_invalid_agent_addr_tag() { + // Agent addr should be IP_ADDRESS + let pdu = vec![ + 0x06, 0x01, 0x2B, // OID 1.3 (enterprise) + 0x02, 0x01, 0x00, // INTEGER instead of IpAddress + ]; + let packet = build_v1_trap_packet(&pdu); + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&packet, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_v1_trap_invalid_generic_trap_tag() { + // Generic trap should be INTEGER + let pdu = vec![ + 0x06, 0x01, 0x2B, // OID 1.3 (enterprise) + 0x40, 0x04, 0x0A, 0x00, 0x00, 0x01, // IpAddress 10.0.0.1 + 0x04, 0x01, 0x00, // OCTET STRING instead of INTEGER + ]; + let packet = build_v1_trap_packet(&pdu); + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&packet, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_v1_trap_invalid_specific_trap_tag() { + // Specific trap should be INTEGER + let pdu = vec![ + 0x06, 0x01, 0x2B, // OID 1.3 (enterprise) + 0x40, 0x04, 0x0A, 0x00, 0x00, 0x01, // IpAddress 10.0.0.1 + 0x02, 0x01, 0x00, // INTEGER 0 (generic) + 0x04, 0x01, 0x00, // OCTET STRING instead of INTEGER + ]; + let packet = build_v1_trap_packet(&pdu); + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&packet, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_v1_trap_invalid_timestamp_tag() { + // Timestamp should be TIMETICKS + let pdu = vec![ + 0x06, 0x01, 0x2B, // OID 1.3 (enterprise) + 0x40, 0x04, 0x0A, 0x00, 0x00, 0x01, // IpAddress 10.0.0.1 + 0x02, 0x01, 0x00, // INTEGER 0 (generic) + 0x02, 0x01, 0x00, // INTEGER 0 (specific) + 0x02, 0x01, 0x00, // INTEGER instead of TIMETICKS + ]; + let packet = build_v1_trap_packet(&pdu); + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&packet, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_v1_trap_invalid_varbinds_tag() { + // Varbinds should be SEQUENCE + let pdu = vec![ + 0x06, 0x01, 0x2B, // OID 1.3 (enterprise) + 0x40, 0x04, 0x0A, 0x00, 0x00, 0x01, // IpAddress 10.0.0.1 + 0x02, 0x01, 0x00, // INTEGER 0 (generic) + 0x02, 0x01, 0x00, // INTEGER 0 (specific) + 0x43, 0x01, 0x00, // TimeTicks 0 + 0x02, 0x01, 0x00, // INTEGER instead of SEQUENCE + ]; + let packet = build_v1_trap_packet(&pdu); + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&packet, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_v2c_trap_invalid_request_id_tag() { + // Request-id should be INTEGER + let pdu = vec![ + 0x04, 0x01, 0x00, // OCTET STRING instead of INTEGER + ]; + let packet = build_v2c_trap_packet(&pdu); + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&packet, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_v2c_trap_invalid_error_status_tag() { + // Error-status should be INTEGER + let pdu = vec![ + 0x02, 0x01, 0x01, // INTEGER 1 (request-id) + 0x04, 0x01, 0x00, // OCTET STRING instead of INTEGER + ]; + let packet = build_v2c_trap_packet(&pdu); + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&packet, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_v2c_trap_invalid_error_index_tag() { + // Error-index should be INTEGER + let pdu = vec![ + 0x02, 0x01, 0x01, // INTEGER 1 (request-id) + 0x02, 0x01, 0x00, // INTEGER 0 (error-status) + 0x04, 0x01, 0x00, // OCTET STRING instead of INTEGER + ]; + let packet = build_v2c_trap_packet(&pdu); + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&packet, source_addr); + assert!(result.is_err()); + } + + #[test] + fn test_parse_v2c_trap_invalid_varbinds_tag() { + // Varbinds should be SEQUENCE + let pdu = vec![ + 0x02, 0x01, 0x01, // INTEGER 1 (request-id) + 0x02, 0x01, 0x00, // INTEGER 0 (error-status) + 0x02, 0x01, 0x00, // INTEGER 0 (error-index) + 0x02, 0x01, 0x00, // INTEGER instead of SEQUENCE + ]; + let packet = build_v2c_trap_packet(&pdu); + let source_addr: SocketAddr = "192.168.1.1:162".parse().unwrap(); + let result = parse_trap(&packet, source_addr); + assert!(result.is_err()); + } } diff --git a/src/version.rs b/src/version.rs index ba367b7..715f26e 100644 --- a/src/version.rs +++ b/src/version.rs @@ -22,5 +22,18 @@ mod tests { // Just verify it's a non-empty string } - // Note: check_for_updates() just logs the version, no testing needed + #[test] + fn test_current_version_format() { + let version = current_version(); + // Version should be in semver-like format (e.g., "0.1.0" or custom BUILD_VERSION) + // At minimum, should have some content + assert!(version.len() >= 1); + } + + #[test] + fn test_check_for_updates() { + // This function just logs, but we can call it to verify it doesn't panic + check_for_updates(); + // If we get here, the function completed without panicking + } }