more tests

This commit is contained in:
Graham McIntire 2026-01-31 15:26:36 -06:00
parent 6e57918386
commit b8aecf0a55
No known key found for this signature in database
8 changed files with 890 additions and 345 deletions

View file

@ -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

View file

@ -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

View file

@ -1,3 +0,0 @@
mod storage;
pub use storage::{Storage, StorageError};

View file

@ -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<T> = std::result::Result<T, StorageError>;
/// SQLite storage for buffering metrics when API is unavailable
#[derive(Clone)]
pub struct Storage {
conn: Arc<Mutex<Connection>>,
}
impl Storage {
/// Create a new storage instance
pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
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<Vec<(i64, Metric)>> {
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::<Metric>(&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::<Vec<_>>().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<Option<Timestamp>> {
let conn = self.conn.lock().unwrap();
let result: std::result::Result<String, _> = 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<std::collections::HashMap<String, Timestamp>> {
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<String, Timestamp> = 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)
}
}

View file

@ -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");
}
}

View file

@ -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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.to_rfc3339())
}
}
impl<'de> Deserialize<'de> for Timestamp {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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,
}

View file

@ -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<u8> = 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 <len> <trap_pdu>
let mut trap_bytes: Vec<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> = 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<u8> {
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<u8> {
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());
}
}

View file

@ -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
}
}