diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e57341c2..0c34d470d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to this project will be documented in this file. - CLI - Remove log noise on resolve route + - Add filtering options and desired_status & metrics_publisher_pk field to device and link list commands - `doublezero resource verify` command added to verify onchain resources - Onchain programs - Removed device and user allowlist functionality, updating the global state, initialization flow, tests, and processors accordingly, and cleaning up unused account checks. diff --git a/smartcontract/cli/src/device/list.rs b/smartcontract/cli/src/device/list.rs index caa4dea8e..ab6888fb1 100644 --- a/smartcontract/cli/src/device/list.rs +++ b/smartcontract/cli/src/device/list.rs @@ -10,10 +10,10 @@ use doublezero_sdk::{ }, DeviceStatus, DeviceType, }; -use doublezero_serviceability::state::device::DeviceHealth; +use doublezero_serviceability::state::device::{DeviceDesiredStatus, DeviceHealth}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::{io::Write, net::Ipv4Addr}; +use std::{io::Write, net::Ipv4Addr, str::FromStr}; use tabled::{settings::Style, Table, Tabled}; #[derive(Args, Debug)] @@ -21,6 +21,27 @@ pub struct ListDeviceCliCommand { /// Filter by contributor (pubkey or code) #[arg(long, short = 'c')] pub contributor: Option, + /// Filter by exchange (pubkey or code) + #[arg(long)] + pub exchange: Option, + /// Filter by location (pubkey or code) + #[arg(long)] + pub location: Option, + /// Filter by device type (hybrid, transit, edge) + #[arg(long)] + pub device_type: Option, + /// Filter by status (pending, activated, deleting, rejected, drained, device-provisioning, link-provisioning) + #[arg(long)] + pub status: Option, + /// Filter by health (unknown, pending, ready-for-links, ready-for-users, impaired) + #[arg(long)] + pub health: Option, + /// Filter by desired status (pending, activated, drained) + #[arg(long)] + pub desired_status: Option, + /// Filter by device code (partial match) + #[arg(long)] + pub code: Option, /// Output as pretty JSON #[arg(long, default_value_t = false)] pub json: bool, @@ -61,8 +82,13 @@ pub struct DeviceDisplay { pub max_users: u16, pub status: DeviceStatus, pub health: DeviceHealth, + #[tabled(skip)] + pub desired_status: DeviceDesiredStatus, pub mgmt_vrf: String, #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] + #[tabled(skip)] + pub metrics_publisher_pk: Pubkey, + #[serde(serialize_with = "serializer::serialize_pubkey_as_string")] pub owner: Pubkey, } @@ -89,6 +115,63 @@ impl ListDeviceCliCommand { devices.retain(|_, device| device.contributor_pk == contributor_pk); } + // Filter by exchange if specified + if let Some(exchange_filter) = &self.exchange { + let exchange_pk = exchanges + .iter() + .find(|(pk, ex)| pk.to_string() == *exchange_filter || ex.code == *exchange_filter) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre::eyre!("Exchange '{}' not found", exchange_filter))?; + devices.retain(|_, device| device.exchange_pk == exchange_pk); + } + + // Filter by location if specified + if let Some(location_filter) = &self.location { + let location_pk = locations + .iter() + .find(|(pk, loc)| { + pk.to_string() == *location_filter || loc.code == *location_filter + }) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre::eyre!("Location '{}' not found", location_filter))?; + devices.retain(|_, device| device.location_pk == location_pk); + } + + // Filter by device type if specified + if let Some(device_type_filter) = &self.device_type { + let device_type = DeviceType::from_str(device_type_filter) + .map_err(|e| eyre::eyre!("Invalid device type '{}': {}", device_type_filter, e))?; + devices.retain(|_, device| device.device_type == device_type); + } + + // Filter by status if specified + if let Some(status_filter) = &self.status { + let status = DeviceStatus::from_str(status_filter) + .map_err(|e| eyre::eyre!("Invalid status '{}': {}", status_filter, e))?; + devices.retain(|_, device| device.status == status); + } + + // Filter by health if specified + if let Some(health_filter) = &self.health { + let health = DeviceHealth::from_str(health_filter) + .map_err(|e| eyre::eyre!("Invalid health '{}': {}", health_filter, e))?; + devices.retain(|_, device| device.device_health == health); + } + + // Filter by desired status if specified + if let Some(desired_status_filter) = &self.desired_status { + let desired_status = + DeviceDesiredStatus::from_str(desired_status_filter).map_err(|e| { + eyre::eyre!("Invalid desired status '{}': {}", desired_status_filter, e) + })?; + devices.retain(|_, device| device.desired_status == desired_status); + } + + // Filter by code if specified (partial match) + if let Some(code_filter) = &self.code { + devices.retain(|_, device| device.code.contains(code_filter)); + } + let mut device_displays: Vec = devices .into_iter() .map(|(pubkey, device)| { @@ -130,6 +213,8 @@ impl ListDeviceCliCommand { users: device.users_count, max_users: device.max_users, health: device.device_health, + desired_status: device.desired_status, + metrics_publisher_pk: device.metrics_publisher_pk, owner: device.owner, } }) @@ -270,6 +355,13 @@ mod tests { let mut output = Vec::new(); let res = ListDeviceCliCommand { contributor: None, + exchange: None, + location: None, + device_type: None, + status: None, + health: None, + desired_status: None, + code: None, json: false, json_compact: false, } @@ -281,12 +373,2028 @@ mod tests { let mut output = Vec::new(); let res = ListDeviceCliCommand { contributor: None, + exchange: None, + location: None, + device_type: None, + status: None, + health: None, + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB\",\"code\":\"device1_code\",\"bump_seed\":2,\"location_pk\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"contributor_code\":\"contributor1_code\",\"location_code\":\"location1_code\",\"location_name\":\"location1_name\",\"exchange_pk\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA\",\"exchange_code\":\"exchange1_code\",\"exchange_name\":\"exchange1_name\",\"device_type\":\"Hybrid\",\"public_ip\":\"1.2.3.4\",\"dz_prefixes\":\"1.2.3.4/32\",\"users\":0,\"max_users\":255,\"status\":\"Activated\",\"health\":\"ReadyForUsers\",\"desired_status\":\"Activated\",\"mgmt_vrf\":\"default\",\"metrics_publisher_pk\":\"11111111111111111111111111111111\",\"owner\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB\"}]\n"); + } + + #[test] + fn test_cli_device_list_filter_by_device_type() { + let mut client = create_test_client(); + + let location1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let location1 = Location { + account_type: AccountType::Location, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "location1_code".to_string(), + name: "location1_name".to_string(), + country: "location1_country".to_string(), + lat: 1.0, + lng: 2.0, + loc_id: 3, + status: LocationStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"), + }; + + client.expect_list_location().returning(move |_| { + let mut locations = HashMap::new(); + locations.insert(location1_pubkey, location1.clone()); + Ok(locations) + }); + + let exchange1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"); + let exchange1 = Exchange { + account_type: AccountType::Exchange, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "exchange1_code".to_string(), + name: "exchange1_name".to_string(), + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 1.0, + lng: 2.0, + bgp_community: 3, + unused: 0, + status: ExchangeStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"), + }; + + client.expect_list_exchange().returning(move |_| { + let mut exchanges = HashMap::new(); + exchanges.insert(exchange1_pubkey, exchange1.clone()); + Ok(exchanges) + }); + + let contributor_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "contributor1_code".to_string(), + status: ContributorStatus::Activated, + owner: contributor_pk, + ops_manager_pk: Pubkey::default(), + }; + + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor_pk, contributor.clone()); + Ok(contributors) + }); + + let device1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "device1_hybrid".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + let device2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"); + let device2 = Device { + account_type: AccountType::Device, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "device2_transit".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Transit, + public_ip: [5, 6, 7, 8].into(), + dz_prefixes: "5.6.7.8/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + devices.insert(device2_pubkey, device2.clone()); + Ok(devices) + }); + + // Test filter by device_type=hybrid (should return only device1) + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: None, + device_type: Some("hybrid".to_string()), + status: None, + health: None, + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("device1_hybrid")); + assert!(!output_str.contains("device2_transit")); + } + + #[test] + fn test_cli_device_list_filter_by_code() { + let mut client = create_test_client(); + + let location1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let location1 = Location { + account_type: AccountType::Location, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "location1_code".to_string(), + name: "location1_name".to_string(), + country: "location1_country".to_string(), + lat: 1.0, + lng: 2.0, + loc_id: 3, + status: LocationStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"), + }; + + client.expect_list_location().returning(move |_| { + let mut locations = HashMap::new(); + locations.insert(location1_pubkey, location1.clone()); + Ok(locations) + }); + + let exchange1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"); + let exchange1 = Exchange { + account_type: AccountType::Exchange, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "exchange1_code".to_string(), + name: "exchange1_name".to_string(), + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 1.0, + lng: 2.0, + bgp_community: 3, + unused: 0, + status: ExchangeStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"), + }; + + client.expect_list_exchange().returning(move |_| { + let mut exchanges = HashMap::new(); + exchanges.insert(exchange1_pubkey, exchange1.clone()); + Ok(exchanges) + }); + + let contributor_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "contributor1_code".to_string(), + status: ContributorStatus::Activated, + owner: contributor_pk, + ops_manager_pk: Pubkey::default(), + }; + + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor_pk, contributor.clone()); + Ok(contributors) + }); + + let device1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "ams-device-001".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + let device2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"); + let device2 = Device { + account_type: AccountType::Device, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "nyc-device-002".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Transit, + public_ip: [5, 6, 7, 8].into(), + dz_prefixes: "5.6.7.8/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + devices.insert(device2_pubkey, device2.clone()); + Ok(devices) + }); + + // Test filter by code=ams (should return only device1) + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: None, + device_type: None, + status: None, + health: None, + desired_status: None, + code: Some("ams".to_string()), + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("ams-device-001")); + assert!(!output_str.contains("nyc-device-002")); + } + + #[test] + fn test_cli_device_list_filter_by_status() { + let mut client = create_test_client(); + + let location1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let location1 = Location { + account_type: AccountType::Location, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "location1_code".to_string(), + name: "location1_name".to_string(), + country: "location1_country".to_string(), + lat: 1.0, + lng: 2.0, + loc_id: 3, + status: LocationStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"), + }; + + client.expect_list_location().returning(move |_| { + let mut locations = HashMap::new(); + locations.insert(location1_pubkey, location1.clone()); + Ok(locations) + }); + + let exchange1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"); + let exchange1 = Exchange { + account_type: AccountType::Exchange, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "exchange1_code".to_string(), + name: "exchange1_name".to_string(), + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 1.0, + lng: 2.0, + bgp_community: 3, + unused: 0, + status: ExchangeStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"), + }; + + client.expect_list_exchange().returning(move |_| { + let mut exchanges = HashMap::new(); + exchanges.insert(exchange1_pubkey, exchange1.clone()); + Ok(exchanges) + }); + + let contributor_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "contributor1_code".to_string(), + status: ContributorStatus::Activated, + owner: contributor_pk, + ops_manager_pk: Pubkey::default(), + }; + + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor_pk, contributor.clone()); + Ok(contributors) + }); + + let device1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "device1_activated".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + let device2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"); + let device2 = Device { + account_type: AccountType::Device, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "device2_pending".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [5, 6, 7, 8].into(), + dz_prefixes: "5.6.7.8/32".parse().unwrap(), + status: DeviceStatus::Pending, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::Pending, + desired_status: doublezero_serviceability::state::device::DeviceDesiredStatus::Pending, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + devices.insert(device2_pubkey, device2.clone()); + Ok(devices) + }); + + // Test filter by status=activated (should return only device1) + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: None, + device_type: None, + status: Some("activated".to_string()), + health: None, + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("device1_activated")); + assert!(!output_str.contains("device2_pending")); + } + + #[test] + fn test_cli_device_list_filter_combined() { + let mut client = create_test_client(); + + let location1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let location1 = Location { + account_type: AccountType::Location, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "ams".to_string(), + name: "Amsterdam".to_string(), + country: "NL".to_string(), + lat: 1.0, + lng: 2.0, + loc_id: 3, + status: LocationStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"), + }; + + let location2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); + let location2 = Location { + account_type: AccountType::Location, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "nyc".to_string(), + name: "New York".to_string(), + country: "US".to_string(), + lat: 1.0, + lng: 2.0, + loc_id: 4, + status: LocationStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"), + }; + + client.expect_list_location().returning(move |_| { + let mut locations = HashMap::new(); + locations.insert(location1_pubkey, location1.clone()); + locations.insert(location2_pubkey, location2.clone()); + Ok(locations) + }); + + let exchange1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"); + let exchange1 = Exchange { + account_type: AccountType::Exchange, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "exchange1_code".to_string(), + name: "exchange1_name".to_string(), + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 1.0, + lng: 2.0, + bgp_community: 3, + unused: 0, + status: ExchangeStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"), + }; + + client.expect_list_exchange().returning(move |_| { + let mut exchanges = HashMap::new(); + exchanges.insert(exchange1_pubkey, exchange1.clone()); + Ok(exchanges) + }); + + let contributor_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "contributor1_code".to_string(), + status: ContributorStatus::Activated, + owner: contributor_pk, + ops_manager_pk: Pubkey::default(), + }; + + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor_pk, contributor.clone()); + Ok(contributors) + }); + + let device1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "ams-device-001".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + let device2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"); + let device2 = Device { + account_type: AccountType::Device, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "nyc-device-002".to_string(), + contributor_pk, + location_pk: location2_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Transit, + public_ip: [5, 6, 7, 8].into(), + dz_prefixes: "5.6.7.8/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + devices.insert(device2_pubkey, device2.clone()); + Ok(devices) + }); + + // Test combined filters: location=ams AND device_type=hybrid AND status=activated + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: Some("ams".to_string()), + device_type: Some("hybrid".to_string()), + status: Some("activated".to_string()), + health: None, + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("ams-device-001")); + assert!(!output_str.contains("nyc-device-002")); + } + + #[test] + fn test_cli_device_list_filter_by_contributor() { + let mut client = create_test_client(); + + let location1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let location1 = Location { + account_type: AccountType::Location, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "ams".to_string(), + name: "Amsterdam".to_string(), + country: "NL".to_string(), + lat: 1.0, + lng: 2.0, + loc_id: 3, + status: LocationStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"), + }; + + client.expect_list_location().returning(move |_| { + let mut locations = HashMap::new(); + locations.insert(location1_pubkey, location1.clone()); + Ok(locations) + }); + + let exchange1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"); + let exchange1 = Exchange { + account_type: AccountType::Exchange, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "xams".to_string(), + name: "AMS-IX".to_string(), + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 1.0, + lng: 2.0, + bgp_community: 3, + unused: 0, + status: ExchangeStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"), + }; + + client.expect_list_exchange().returning(move |_| { + let mut exchanges = HashMap::new(); + exchanges.insert(exchange1_pubkey, exchange1.clone()); + Ok(exchanges) + }); + + let contributor1_pk = + Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor1 = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "acme".to_string(), + status: ContributorStatus::Activated, + owner: contributor1_pk, + ops_manager_pk: Pubkey::default(), + }; + + let contributor2_pk = + Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcy"); + let contributor2 = Contributor { + account_type: AccountType::Contributor, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "globex".to_string(), + status: ContributorStatus::Activated, + owner: contributor2_pk, + ops_manager_pk: Pubkey::default(), + }; + + let contributor1_clone = contributor1.clone(); + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor1_pk, contributor1.clone()); + contributors.insert(contributor2_pk, contributor2.clone()); + Ok(contributors) + }); + + client + .expect_get_contributor() + .returning(move |cmd| match cmd.pubkey_or_code.as_str() { + "acme" => Ok((contributor1_pk, contributor1_clone.clone())), + _ => Err(eyre::eyre!("Contributor not found")), + }); + + let device1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "device1".to_string(), + contributor_pk: contributor1_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + let device2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"); + let device2 = Device { + account_type: AccountType::Device, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "device2".to_string(), + contributor_pk: contributor2_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [5, 6, 7, 8].into(), + dz_prefixes: "5.6.7.8/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + devices.insert(device2_pubkey, device2.clone()); + Ok(devices) + }); + + // Test filter by contributor=acme (should return only device1) + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: Some("acme".to_string()), + exchange: None, + location: None, + device_type: None, + status: None, + health: None, + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("device1")); + assert!(!output_str.contains("device2")); + } + + #[test] + fn test_cli_device_list_filter_by_exchange() { + let mut client = create_test_client(); + + let location1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let location1 = Location { + account_type: AccountType::Location, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "ams".to_string(), + name: "Amsterdam".to_string(), + country: "NL".to_string(), + lat: 1.0, + lng: 2.0, + loc_id: 3, + status: LocationStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"), + }; + + client.expect_list_location().returning(move |_| { + let mut locations = HashMap::new(); + locations.insert(location1_pubkey, location1.clone()); + Ok(locations) + }); + + let exchange1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"); + let exchange1 = Exchange { + account_type: AccountType::Exchange, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "xams".to_string(), + name: "AMS-IX".to_string(), + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 1.0, + lng: 2.0, + bgp_community: 3, + unused: 0, + status: ExchangeStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"), + }; + + let exchange2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPU"); + let exchange2 = Exchange { + account_type: AccountType::Exchange, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "xnyc".to_string(), + name: "NYIIX".to_string(), + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 1.0, + lng: 2.0, + bgp_community: 4, + unused: 0, + status: ExchangeStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPU"), + }; + + client.expect_list_exchange().returning(move |_| { + let mut exchanges = HashMap::new(); + exchanges.insert(exchange1_pubkey, exchange1.clone()); + exchanges.insert(exchange2_pubkey, exchange2.clone()); + Ok(exchanges) + }); + + let contributor_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "acme".to_string(), + status: ContributorStatus::Activated, + owner: contributor_pk, + ops_manager_pk: Pubkey::default(), + }; + + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor_pk, contributor.clone()); + Ok(contributors) + }); + + let device1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "device1".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + let device2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"); + let device2 = Device { + account_type: AccountType::Device, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "device2".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange2_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [5, 6, 7, 8].into(), + dz_prefixes: "5.6.7.8/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + devices.insert(device2_pubkey, device2.clone()); + Ok(devices) + }); + + // Test filter by exchange=xams (should return only device1) + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: Some("xams".to_string()), + location: None, + device_type: None, + status: None, + health: None, + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("device1")); + assert!(!output_str.contains("device2")); + } + + #[test] + fn test_cli_device_list_filter_by_location() { + let mut client = create_test_client(); + + let location1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let location1 = Location { + account_type: AccountType::Location, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "ams".to_string(), + name: "Amsterdam".to_string(), + country: "NL".to_string(), + lat: 1.0, + lng: 2.0, + loc_id: 3, + status: LocationStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"), + }; + + let location2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPT"); + let location2 = Location { + account_type: AccountType::Location, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "nyc".to_string(), + name: "New York".to_string(), + country: "US".to_string(), + lat: 1.0, + lng: 2.0, + loc_id: 4, + status: LocationStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPT"), + }; + + client.expect_list_location().returning(move |_| { + let mut locations = HashMap::new(); + locations.insert(location1_pubkey, location1.clone()); + locations.insert(location2_pubkey, location2.clone()); + Ok(locations) + }); + + let exchange1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"); + let exchange1 = Exchange { + account_type: AccountType::Exchange, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "xams".to_string(), + name: "AMS-IX".to_string(), + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 1.0, + lng: 2.0, + bgp_community: 3, + unused: 0, + status: ExchangeStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"), + }; + + client.expect_list_exchange().returning(move |_| { + let mut exchanges = HashMap::new(); + exchanges.insert(exchange1_pubkey, exchange1.clone()); + Ok(exchanges) + }); + + let contributor_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "acme".to_string(), + status: ContributorStatus::Activated, + owner: contributor_pk, + ops_manager_pk: Pubkey::default(), + }; + + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor_pk, contributor.clone()); + Ok(contributors) + }); + + let device1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "device1".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + let device2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"); + let device2 = Device { + account_type: AccountType::Device, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "device2".to_string(), + contributor_pk, + location_pk: location2_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [5, 6, 7, 8].into(), + dz_prefixes: "5.6.7.8/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + devices.insert(device2_pubkey, device2.clone()); + Ok(devices) + }); + + // Test filter by location=ams (should return only device1) + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: Some("ams".to_string()), + device_type: None, + status: None, + health: None, + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("device1")); + assert!(!output_str.contains("device2")); + } + + #[test] + fn test_cli_device_list_filter_by_health() { + let mut client = create_test_client(); + + let location1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let location1 = Location { + account_type: AccountType::Location, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "ams".to_string(), + name: "Amsterdam".to_string(), + country: "NL".to_string(), + lat: 1.0, + lng: 2.0, + loc_id: 3, + status: LocationStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"), + }; + + client.expect_list_location().returning(move |_| { + let mut locations = HashMap::new(); + locations.insert(location1_pubkey, location1.clone()); + Ok(locations) + }); + + let exchange1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"); + let exchange1 = Exchange { + account_type: AccountType::Exchange, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "xams".to_string(), + name: "AMS-IX".to_string(), + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 1.0, + lng: 2.0, + bgp_community: 3, + unused: 0, + status: ExchangeStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"), + }; + + client.expect_list_exchange().returning(move |_| { + let mut exchanges = HashMap::new(); + exchanges.insert(exchange1_pubkey, exchange1.clone()); + Ok(exchanges) + }); + + let contributor_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "acme".to_string(), + status: ContributorStatus::Activated, + owner: contributor_pk, + ops_manager_pk: Pubkey::default(), + }; + + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor_pk, contributor.clone()); + Ok(contributors) + }); + + let device1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "device1".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + let device2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"); + let device2 = Device { + account_type: AccountType::Device, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "device2".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [5, 6, 7, 8].into(), + dz_prefixes: "5.6.7.8/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::Impaired, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + devices.insert(device2_pubkey, device2.clone()); + Ok(devices) + }); + + // Test filter by health=ready-for-users (should return only device1) + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: None, + device_type: None, + status: None, + health: Some("ready-for-users".to_string()), + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("device1")); + assert!(!output_str.contains("device2")); + } + + #[test] + fn test_cli_device_list_filter_by_desired_status() { + let mut client = create_test_client(); + + let location1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let location1 = Location { + account_type: AccountType::Location, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "ams".to_string(), + name: "Amsterdam".to_string(), + country: "NL".to_string(), + lat: 1.0, + lng: 2.0, + loc_id: 3, + status: LocationStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"), + }; + + client.expect_list_location().returning(move |_| { + let mut locations = HashMap::new(); + locations.insert(location1_pubkey, location1.clone()); + Ok(locations) + }); + + let exchange1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"); + let exchange1 = Exchange { + account_type: AccountType::Exchange, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "xams".to_string(), + name: "AMS-IX".to_string(), + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 1.0, + lng: 2.0, + bgp_community: 3, + unused: 0, + status: ExchangeStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"), + }; + + client.expect_list_exchange().returning(move |_| { + let mut exchanges = HashMap::new(); + exchanges.insert(exchange1_pubkey, exchange1.clone()); + Ok(exchanges) + }); + + let contributor_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "acme".to_string(), + status: ContributorStatus::Activated, + owner: contributor_pk, + ops_manager_pk: Pubkey::default(), + }; + + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor_pk, contributor.clone()); + Ok(contributors) + }); + + let device1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "device1".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + let device2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"); + let device2 = Device { + account_type: AccountType::Device, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "device2".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [5, 6, 7, 8].into(), + dz_prefixes: "5.6.7.8/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: doublezero_serviceability::state::device::DeviceDesiredStatus::Drained, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + devices.insert(device2_pubkey, device2.clone()); + Ok(devices) + }); + + // Test filter by desired_status=activated (should return only device1) + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: None, + device_type: None, + status: None, + health: None, + desired_status: Some("activated".to_string()), + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("device1")); + assert!(!output_str.contains("device2")); + } + + #[test] + fn test_cli_device_list_json_pretty() { + let mut client = create_test_client(); + + let location1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let location1 = Location { + account_type: AccountType::Location, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "ams".to_string(), + name: "Amsterdam".to_string(), + country: "NL".to_string(), + lat: 1.0, + lng: 2.0, + loc_id: 3, + status: LocationStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"), + }; + + client.expect_list_location().returning(move |_| { + let mut locations = HashMap::new(); + locations.insert(location1_pubkey, location1.clone()); + Ok(locations) + }); + + let exchange1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"); + let exchange1 = Exchange { + account_type: AccountType::Exchange, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "xams".to_string(), + name: "AMS-IX".to_string(), + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 1.0, + lng: 2.0, + bgp_community: 3, + unused: 0, + status: ExchangeStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"), + }; + + client.expect_list_exchange().returning(move |_| { + let mut exchanges = HashMap::new(); + exchanges.insert(exchange1_pubkey, exchange1.clone()); + Ok(exchanges) + }); + + let contributor_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "acme".to_string(), + status: ContributorStatus::Activated, + owner: contributor_pk, + ops_manager_pk: Pubkey::default(), + }; + + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor_pk, contributor.clone()); + Ok(contributors) + }); + + let device1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "device1".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + Ok(devices) + }); + + // Test JSON pretty output + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: None, + device_type: None, + status: None, + health: None, + desired_status: None, + code: None, + json: true, + json_compact: false, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + // Pretty JSON should have indentation (newlines and spaces) + assert!(output_str.contains(" \"code\":")); + assert!(output_str.contains("device1")); + } + + #[test] + fn test_cli_device_list_empty() { + let mut client = create_test_client(); + + client + .expect_list_location() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_exchange() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_contributor() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_device() + .returning(|_| Ok(HashMap::new())); + + // Test with empty device list + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: None, + device_type: None, + status: None, + health: None, + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert_eq!(output_str, "[]\n"); + } + + #[test] + fn test_cli_device_list_error_contributor_not_found() { + let mut client = create_test_client(); + + client + .expect_list_location() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_exchange() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_contributor() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_device() + .returning(|_| Ok(HashMap::new())); + client + .expect_get_contributor() + .returning(|_| Err(eyre::eyre!("Not found"))); + + // Test with non-existent contributor + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: Some("nonexistent".to_string()), + exchange: None, + location: None, + device_type: None, + status: None, + health: None, + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_err()); + assert_eq!( + res.unwrap_err().to_string(), + "Contributor 'nonexistent' not found" + ); + } + + #[test] + fn test_cli_device_list_error_exchange_not_found() { + let mut client = create_test_client(); + + client + .expect_list_location() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_exchange() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_contributor() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_device() + .returning(|_| Ok(HashMap::new())); + + // Test with non-existent exchange + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: Some("nonexistent".to_string()), + location: None, + device_type: None, + status: None, + health: None, + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_err()); + assert_eq!( + res.unwrap_err().to_string(), + "Exchange 'nonexistent' not found" + ); + } + + #[test] + fn test_cli_device_list_error_location_not_found() { + let mut client = create_test_client(); + + client + .expect_list_location() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_exchange() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_contributor() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_device() + .returning(|_| Ok(HashMap::new())); + + // Test with non-existent location + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: Some("nonexistent".to_string()), + device_type: None, + status: None, + health: None, + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_err()); + assert_eq!( + res.unwrap_err().to_string(), + "Location 'nonexistent' not found" + ); + } + + #[test] + fn test_cli_device_list_error_invalid_device_type() { + let mut client = create_test_client(); + + client + .expect_list_location() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_exchange() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_contributor() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_device() + .returning(|_| Ok(HashMap::new())); + + // Test with invalid device type + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: None, + device_type: Some("invalid".to_string()), + status: None, + health: None, + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_err()); + assert!(res.unwrap_err().to_string().contains("Invalid device type")); + } + + #[test] + fn test_cli_device_list_error_invalid_status() { + let mut client = create_test_client(); + + client + .expect_list_location() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_exchange() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_contributor() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_device() + .returning(|_| Ok(HashMap::new())); + + // Test with invalid status + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: None, + device_type: None, + status: Some("invalid".to_string()), + health: None, + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_err()); + assert!(res.unwrap_err().to_string().contains("Invalid status")); + } + + #[test] + fn test_cli_device_list_error_invalid_health() { + let mut client = create_test_client(); + + client + .expect_list_location() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_exchange() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_contributor() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_device() + .returning(|_| Ok(HashMap::new())); + + // Test with invalid health + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: None, + device_type: None, + status: None, + health: Some("invalid".to_string()), + desired_status: None, + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_err()); + assert!(res.unwrap_err().to_string().contains("Invalid health")); + } + + #[test] + fn test_cli_device_list_error_invalid_desired_status() { + let mut client = create_test_client(); + + client + .expect_list_location() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_exchange() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_contributor() + .returning(|_| Ok(HashMap::new())); + client + .expect_list_device() + .returning(|_| Ok(HashMap::new())); + + // Test with invalid desired status + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: None, + device_type: None, + status: None, + health: None, + desired_status: Some("invalid".to_string()), + code: None, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_err()); + assert!(res + .unwrap_err() + .to_string() + .contains("Invalid desired status")); + } + + #[test] + fn test_cli_device_list_sorting() { + let mut client = create_test_client(); + + let location1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let location1 = Location { + account_type: AccountType::Location, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "ams".to_string(), + name: "Amsterdam".to_string(), + country: "NL".to_string(), + lat: 1.0, + lng: 2.0, + loc_id: 3, + status: LocationStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"), + }; + + client.expect_list_location().returning(move |_| { + let mut locations = HashMap::new(); + locations.insert(location1_pubkey, location1.clone()); + Ok(locations) + }); + + let exchange1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"); + let exchange1 = Exchange { + account_type: AccountType::Exchange, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "xams".to_string(), + name: "AMS-IX".to_string(), + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 1.0, + lng: 2.0, + bgp_community: 3, + unused: 0, + status: ExchangeStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA"), + }; + + let exchange2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPU"); + let exchange2 = Exchange { + account_type: AccountType::Exchange, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "xnyc".to_string(), + name: "NYIIX".to_string(), + device1_pk: Pubkey::default(), + device2_pk: Pubkey::default(), + lat: 1.0, + lng: 2.0, + bgp_community: 4, + unused: 0, + status: ExchangeStatus::Activated, + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPU"), + }; + + client.expect_list_exchange().returning(move |_| { + let mut exchanges = HashMap::new(); + exchanges.insert(exchange1_pubkey, exchange1.clone()); + exchanges.insert(exchange2_pubkey, exchange2.clone()); + Ok(exchanges) + }); + + let contributor_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "acme".to_string(), + status: ContributorStatus::Activated, + owner: contributor_pk, + ops_manager_pk: Pubkey::default(), + }; + + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor_pk, contributor.clone()); + Ok(contributors) + }); + + let device1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "zdevice".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange2_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + let device2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"); + let device2 = Device { + account_type: AccountType::Device, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "adevice".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [5, 6, 7, 8].into(), + dz_prefixes: "5.6.7.8/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPD"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + let device3_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPF"); + let device3 = Device { + account_type: AccountType::Device, + index: 3, + bump_seed: 4, + reference_count: 0, + code: "bdevice".to_string(), + contributor_pk, + location_pk: location1_pubkey, + exchange_pk: exchange1_pubkey, + device_type: DeviceType::Hybrid, + public_ip: [9, 10, 11, 12].into(), + dz_prefixes: "9.10.11.12/32".parse().unwrap(), + status: DeviceStatus::Activated, + metrics_publisher_pk: Pubkey::default(), + owner: Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPF"), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + devices.insert(device2_pubkey, device2.clone()); + devices.insert(device3_pubkey, device3.clone()); + Ok(devices) + }); + + // Test that devices are sorted by exchange_name, then by code + let mut output = Vec::new(); + let res = ListDeviceCliCommand { + contributor: None, + exchange: None, + location: None, + device_type: None, + status: None, + health: None, + desired_status: None, + code: None, json: false, json_compact: true, } .execute(&client, &mut output); assert!(res.is_ok()); let output_str = String::from_utf8(output).unwrap(); - assert_eq!(output_str, "[{\"account\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB\",\"code\":\"device1_code\",\"bump_seed\":2,\"location_pk\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR\",\"contributor_code\":\"contributor1_code\",\"location_code\":\"location1_code\",\"location_name\":\"location1_name\",\"exchange_pk\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPA\",\"exchange_code\":\"exchange1_code\",\"exchange_name\":\"exchange1_name\",\"device_type\":\"Hybrid\",\"public_ip\":\"1.2.3.4\",\"dz_prefixes\":\"1.2.3.4/32\",\"users\":0,\"max_users\":255,\"status\":\"Activated\",\"health\":\"ReadyForUsers\",\"mgmt_vrf\":\"default\",\"owner\":\"1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPB\"}]\n"); + // Check that AMS-IX devices come before NYIIX devices + let ams_adevice_pos = output_str.find("adevice").unwrap(); + let ams_bdevice_pos = output_str.find("bdevice").unwrap(); + let nyiix_zdevice_pos = output_str.find("zdevice").unwrap(); + // adevice < bdevice < zdevice in position + assert!(ams_adevice_pos < ams_bdevice_pos); + assert!(ams_bdevice_pos < nyiix_zdevice_pos); } } diff --git a/smartcontract/cli/src/link/list.rs b/smartcontract/cli/src/link/list.rs index 5db233da8..14e8f3222 100644 --- a/smartcontract/cli/src/link/list.rs +++ b/smartcontract/cli/src/link/list.rs @@ -15,7 +15,7 @@ use doublezero_sdk::{ use doublezero_serviceability::state::link::{LinkDesiredStatus, LinkHealth}; use serde::Serialize; use solana_sdk::pubkey::Pubkey; -use std::io::Write; +use std::{io::Write, str::FromStr}; use tabled::{settings::Style, Table, Tabled}; #[derive(Args, Debug)] @@ -23,6 +23,27 @@ pub struct ListLinkCliCommand { /// Filter by contributor (pubkey or code) #[arg(long, short = 'c')] pub contributor: Option, + /// Filter by side A device (pubkey or code) + #[arg(long)] + pub side_a: Option, + /// Filter by side Z device (pubkey or code) + #[arg(long)] + pub side_z: Option, + /// Filter by link type (WAN, DZX) + #[arg(long)] + pub link_type: Option, + /// Filter by status (pending, activated, deleting, rejected, drained) + #[arg(long)] + pub status: Option, + /// Filter by health (unknown, pending, ready-for-service, impaired) + #[arg(long)] + pub health: Option, + /// Filter by desired status (pending, activated, drained) + #[arg(long)] + pub desired_status: Option, + /// Filter by link code (partial match) + #[arg(long)] + pub code: Option, /// List only WAN links. #[arg(long, default_value_t = false)] pub wan: bool, @@ -105,6 +126,61 @@ impl ListLinkCliCommand { links.retain(|(_, link)| link.link_type == LinkLinkType::DZX); } + // Filter by side_a device if specified + if let Some(side_a_filter) = &self.side_a { + let side_a_pk = devices + .iter() + .find(|(pk, dev)| pk.to_string() == *side_a_filter || dev.code == *side_a_filter) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre::eyre!("Side A device '{}' not found", side_a_filter))?; + links.retain(|(_, link)| link.side_a_pk == side_a_pk); + } + + // Filter by side_z device if specified + if let Some(side_z_filter) = &self.side_z { + let side_z_pk = devices + .iter() + .find(|(pk, dev)| pk.to_string() == *side_z_filter || dev.code == *side_z_filter) + .map(|(pk, _)| *pk) + .ok_or_else(|| eyre::eyre!("Side Z device '{}' not found", side_z_filter))?; + links.retain(|(_, link)| link.side_z_pk == side_z_pk); + } + + // Filter by link type if specified + if let Some(link_type_filter) = &self.link_type { + let link_type = LinkLinkType::from_str(link_type_filter) + .map_err(|e| eyre::eyre!("Invalid link type '{}': {}", link_type_filter, e))?; + links.retain(|(_, link)| link.link_type == link_type); + } + + // Filter by status if specified + if let Some(status_filter) = &self.status { + let status = LinkStatus::from_str(status_filter) + .map_err(|e| eyre::eyre!("Invalid status '{}': {}", status_filter, e))?; + links.retain(|(_, link)| link.status == status); + } + + // Filter by health if specified + if let Some(health_filter) = &self.health { + let health = LinkHealth::from_str(health_filter) + .map_err(|e| eyre::eyre!("Invalid health '{}': {}", health_filter, e))?; + links.retain(|(_, link)| link.link_health == health); + } + + // Filter by desired status if specified + if let Some(desired_status_filter) = &self.desired_status { + let desired_status = + LinkDesiredStatus::from_str(desired_status_filter).map_err(|e| { + eyre::eyre!("Invalid desired status '{}': {}", desired_status_filter, e) + })?; + links.retain(|(_, link)| link.desired_status == desired_status); + } + + // Filter by code if specified (partial match) + if let Some(code_filter) = &self.code { + links.retain(|(_, link)| link.code.contains(code_filter)); + } + let mut tunnel_displays: Vec = links .into_iter() .map(|(pubkey, link)| { @@ -300,6 +376,13 @@ mod tests { let mut output = Vec::new(); let res = ListLinkCliCommand { contributor: None, + side_a: None, + side_z: None, + link_type: None, + status: None, + health: None, + desired_status: None, + code: None, wan: false, dzx: false, json: false, @@ -314,6 +397,13 @@ mod tests { let mut output = Vec::new(); let res = ListLinkCliCommand { contributor: None, + side_a: None, + side_z: None, + link_type: None, + status: None, + health: None, + desired_status: None, + code: None, wan: false, dzx: false, json: false, @@ -491,6 +581,13 @@ mod tests { let mut output = Vec::new(); let res = ListLinkCliCommand { contributor: Some("contributor1_code".to_string()), + side_a: None, + side_z: None, + link_type: None, + status: None, + health: None, + desired_status: None, + code: None, wan: false, dzx: false, json: false, @@ -503,4 +600,455 @@ mod tests { assert!(output_str.contains("tunnel_code")); assert!(!output_str.contains("tunnel_code_two")); } + + #[test] + fn test_cli_link_list_filter_by_link_type() { + let mut client = create_test_client(); + + let contributor_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "contributor1_code".to_string(), + status: ContributorStatus::Activated, + owner: contributor_pk, + ops_manager_pk: Pubkey::default(), + }; + + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor_pk, contributor.clone()); + Ok(contributors) + }); + + let device1_pubkey = Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "device1_code".to_string(), + contributor_pk, + location_pk: Pubkey::new_unique(), + exchange_pk: Pubkey::new_unique(), + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + owner: Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + let device2_pubkey = Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzoa"); + let device2 = Device { + account_type: AccountType::Device, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "device2_code".to_string(), + contributor_pk, + location_pk: Pubkey::new_unique(), + exchange_pk: Pubkey::new_unique(), + device_type: DeviceType::Hybrid, + public_ip: [5, 6, 7, 8].into(), + dz_prefixes: "5.6.7.8/32".parse().unwrap(), + status: DeviceStatus::Activated, + owner: Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzoa"), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + devices.insert(device2_pubkey, device2.clone()); + Ok(devices) + }); + + let link1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let link1 = Link { + account_type: AccountType::Link, + index: 1, + bump_seed: 2, + code: "wan_link".to_string(), + contributor_pk, + side_a_pk: device1_pubkey, + side_z_pk: device2_pubkey, + link_type: LinkLinkType::WAN, + bandwidth: 10_000_000_000, + mtu: 4500, + delay_ns: 20_000, + jitter_ns: 1121, + delay_override_ns: 0, + tunnel_id: 1234, + tunnel_net: "1.2.3.4/32".parse().unwrap(), + status: LinkStatus::Activated, + owner: Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"), + side_a_iface_name: "eth0".to_string(), + side_z_iface_name: "eth1".to_string(), + link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, + desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + }; + + let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); + let link2 = Link { + account_type: AccountType::Link, + index: 2, + bump_seed: 3, + code: "dzx_link".to_string(), + contributor_pk, + side_a_pk: device1_pubkey, + side_z_pk: device2_pubkey, + link_type: LinkLinkType::DZX, + bandwidth: 5_000_000_000, + mtu: 1500, + delay_ns: 10_000, + jitter_ns: 500, + delay_override_ns: 0, + tunnel_id: 5678, + tunnel_net: "5.6.7.8/32".parse().unwrap(), + status: LinkStatus::Activated, + owner: Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"), + side_a_iface_name: "eth2".to_string(), + side_z_iface_name: "eth3".to_string(), + link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, + desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + }; + + client.expect_list_link().returning(move |_| { + let mut links = HashMap::new(); + links.insert(link1_pubkey, link1.clone()); + links.insert(link2_pubkey, link2.clone()); + Ok(links) + }); + + // Test filter by link_type=WAN (should return only link1) + let mut output = Vec::new(); + let res = ListLinkCliCommand { + contributor: None, + side_a: None, + side_z: None, + link_type: Some("WAN".to_string()), + status: None, + health: None, + desired_status: None, + code: None, + wan: false, + dzx: false, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("wan_link")); + assert!(!output_str.contains("dzx_link")); + } + + #[test] + fn test_cli_link_list_filter_by_side_a() { + let mut client = create_test_client(); + + let contributor_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "contributor1_code".to_string(), + status: ContributorStatus::Activated, + owner: contributor_pk, + ops_manager_pk: Pubkey::default(), + }; + + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor_pk, contributor.clone()); + Ok(contributors) + }); + + let device1_pubkey = Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "device_ams".to_string(), + contributor_pk, + location_pk: Pubkey::new_unique(), + exchange_pk: Pubkey::new_unique(), + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + owner: Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + let device2_pubkey = Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzoa"); + let device2 = Device { + account_type: AccountType::Device, + index: 2, + bump_seed: 3, + reference_count: 0, + code: "device_nyc".to_string(), + contributor_pk, + location_pk: Pubkey::new_unique(), + exchange_pk: Pubkey::new_unique(), + device_type: DeviceType::Hybrid, + public_ip: [5, 6, 7, 8].into(), + dz_prefixes: "5.6.7.8/32".parse().unwrap(), + status: DeviceStatus::Activated, + owner: Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzoa"), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + devices.insert(device2_pubkey, device2.clone()); + Ok(devices) + }); + + let link1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let link1 = Link { + account_type: AccountType::Link, + index: 1, + bump_seed: 2, + code: "link_ams_to_nyc".to_string(), + contributor_pk, + side_a_pk: device1_pubkey, + side_z_pk: device2_pubkey, + link_type: LinkLinkType::WAN, + bandwidth: 10_000_000_000, + mtu: 4500, + delay_ns: 20_000, + jitter_ns: 1121, + delay_override_ns: 0, + tunnel_id: 1234, + tunnel_net: "1.2.3.4/32".parse().unwrap(), + status: LinkStatus::Activated, + owner: Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"), + side_a_iface_name: "eth0".to_string(), + side_z_iface_name: "eth1".to_string(), + link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, + desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + }; + + let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); + let link2 = Link { + account_type: AccountType::Link, + index: 2, + bump_seed: 3, + code: "link_nyc_to_ams".to_string(), + contributor_pk, + side_a_pk: device2_pubkey, + side_z_pk: device1_pubkey, + link_type: LinkLinkType::WAN, + bandwidth: 5_000_000_000, + mtu: 1500, + delay_ns: 10_000, + jitter_ns: 500, + delay_override_ns: 0, + tunnel_id: 5678, + tunnel_net: "5.6.7.8/32".parse().unwrap(), + status: LinkStatus::Activated, + owner: Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"), + side_a_iface_name: "eth2".to_string(), + side_z_iface_name: "eth3".to_string(), + link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, + desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + }; + + client.expect_list_link().returning(move |_| { + let mut links = HashMap::new(); + links.insert(link1_pubkey, link1.clone()); + links.insert(link2_pubkey, link2.clone()); + Ok(links) + }); + + // Test filter by side_a=device_ams (should return only link1) + let mut output = Vec::new(); + let res = ListLinkCliCommand { + contributor: None, + side_a: Some("device_ams".to_string()), + side_z: None, + link_type: None, + status: None, + health: None, + desired_status: None, + code: None, + wan: false, + dzx: false, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("link_ams_to_nyc")); + assert!(!output_str.contains("link_nyc_to_ams")); + } + + #[test] + fn test_cli_link_list_filter_by_code() { + let mut client = create_test_client(); + + let contributor_pk = Pubkey::from_str_const("HQ3UUt18uJqKaQFJhgV9zaTdQxUZjNrsKFgoEDquBkcx"); + let contributor = Contributor { + account_type: AccountType::Contributor, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "contributor1_code".to_string(), + status: ContributorStatus::Activated, + owner: contributor_pk, + ops_manager_pk: Pubkey::default(), + }; + + client.expect_list_contributor().returning(move |_| { + let mut contributors = HashMap::new(); + contributors.insert(contributor_pk, contributor.clone()); + Ok(contributors) + }); + + let device1_pubkey = Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"); + let device1 = Device { + account_type: AccountType::Device, + index: 1, + bump_seed: 2, + reference_count: 0, + code: "device1_code".to_string(), + contributor_pk, + location_pk: Pubkey::new_unique(), + exchange_pk: Pubkey::new_unique(), + device_type: DeviceType::Hybrid, + public_ip: [1, 2, 3, 4].into(), + dz_prefixes: "1.2.3.4/32".parse().unwrap(), + status: DeviceStatus::Activated, + owner: Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"), + metrics_publisher_pk: Pubkey::default(), + mgmt_vrf: "default".to_string(), + interfaces: vec![], + max_users: 255, + users_count: 0, + device_health: doublezero_serviceability::state::device::DeviceHealth::ReadyForUsers, + desired_status: + doublezero_serviceability::state::device::DeviceDesiredStatus::Activated, + }; + + client.expect_list_device().returning(move |_| { + let mut devices = HashMap::new(); + devices.insert(device1_pubkey, device1.clone()); + Ok(devices) + }); + + let link1_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPR"); + let link1 = Link { + account_type: AccountType::Link, + index: 1, + bump_seed: 2, + code: "production-link-001".to_string(), + contributor_pk, + side_a_pk: device1_pubkey, + side_z_pk: device1_pubkey, + link_type: LinkLinkType::WAN, + bandwidth: 10_000_000_000, + mtu: 4500, + delay_ns: 20_000, + jitter_ns: 1121, + delay_override_ns: 0, + tunnel_id: 1234, + tunnel_net: "1.2.3.4/32".parse().unwrap(), + status: LinkStatus::Activated, + owner: Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"), + side_a_iface_name: "eth0".to_string(), + side_z_iface_name: "eth1".to_string(), + link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, + desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + }; + + let link2_pubkey = Pubkey::from_str_const("1111111FVAiSujNZVgYSc27t6zUTWoKfAGxbRzzPS"); + let link2 = Link { + account_type: AccountType::Link, + index: 2, + bump_seed: 3, + code: "staging-link-002".to_string(), + contributor_pk, + side_a_pk: device1_pubkey, + side_z_pk: device1_pubkey, + link_type: LinkLinkType::WAN, + bandwidth: 5_000_000_000, + mtu: 1500, + delay_ns: 10_000, + jitter_ns: 500, + delay_override_ns: 0, + tunnel_id: 5678, + tunnel_net: "5.6.7.8/32".parse().unwrap(), + status: LinkStatus::Activated, + owner: Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"), + side_a_iface_name: "eth2".to_string(), + side_z_iface_name: "eth3".to_string(), + link_health: doublezero_serviceability::state::link::LinkHealth::ReadyForService, + desired_status: doublezero_serviceability::state::link::LinkDesiredStatus::Activated, + }; + + client.expect_list_link().returning(move |_| { + let mut links = HashMap::new(); + links.insert(link1_pubkey, link1.clone()); + links.insert(link2_pubkey, link2.clone()); + Ok(links) + }); + + // Test filter by code=production (should return only link1) + let mut output = Vec::new(); + let res = ListLinkCliCommand { + contributor: None, + side_a: None, + side_z: None, + link_type: None, + status: None, + health: None, + desired_status: None, + code: Some("production".to_string()), + wan: false, + dzx: false, + json: false, + json_compact: true, + } + .execute(&client, &mut output); + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("production-link-001")); + assert!(!output_str.contains("staging-link-002")); + } } diff --git a/smartcontract/cli/src/user/list.rs b/smartcontract/cli/src/user/list.rs index ea039e660..e0b81d343 100644 --- a/smartcontract/cli/src/user/list.rs +++ b/smartcontract/cli/src/user/list.rs @@ -38,6 +38,24 @@ pub struct ListUserCliCommand { /// Filter by owner public key #[arg(long, value_delimiter = ',', value_name = "OWNER_PUBLIC_KEY,...")] pub owner: Option>, + /// Filter by user type + #[arg(long, value_delimiter = ',', value_name = "USER_TYPE,...")] + pub user_type: Option>, + /// Filter by CYOA type + #[arg(long, value_delimiter = ',', value_name = "CYOA_TYPE,...")] + pub cyoa_type: Option>, + /// Filter by DoubleZero IP address + #[arg(long, value_delimiter = ',', value_name = "DZ_IP,...")] + pub dz_ip: Option>, + /// Filter by tunnel ID + #[arg(long, value_delimiter = ',', value_name = "TUNNEL_ID,...")] + pub tunnel_id: Option>, + /// Filter by status + #[arg(long, value_delimiter = ',', value_name = "STATUS,...")] + pub status: Option>, + /// Filter by multicast group (as publisher or subscriber) + #[arg(long, value_delimiter = ',', value_name = "MULTICAST_GROUP,...")] + pub multicast_group: Option>, /// Output as pretty JSON. #[arg(long, default_value_t = false)] pub json: bool, @@ -182,6 +200,59 @@ impl ListUserCliCommand { }); } + if let Some(ref user_type_vec) = self.user_type { + users.retain(|(_, user, _)| { + user_type_vec + .iter() + .any(|ut| ut.to_lowercase() == user.user_type.to_string().to_lowercase()) + }); + } + + if let Some(ref cyoa_type_vec) = self.cyoa_type { + users.retain(|(_, user, _)| { + cyoa_type_vec + .iter() + .any(|ct| ct.to_lowercase() == user.cyoa_type.to_string().to_lowercase()) + }); + } + + if let Some(ref dz_ips) = self.dz_ip { + users.retain(|(_, user, _)| dz_ips.contains(&user.dz_ip)); + } + + if let Some(ref tunnel_ids) = self.tunnel_id { + users.retain(|(_, user, _)| tunnel_ids.contains(&user.tunnel_id)); + } + + if let Some(ref status_vec) = self.status { + users.retain(|(_, user, _)| { + status_vec + .iter() + .any(|s| s.to_lowercase() == user.status.to_string().to_lowercase()) + }); + } + + if let Some(ref mgroup_vec) = self.multicast_group { + users.retain(|(_, user, _)| { + let user_groups = user.get_multicast_groups(); + mgroup_vec.iter().any(|mg_filter| { + user_groups.iter().any(|user_mg_pk| { + // Check if matches by pubkey string + if mg_filter == &user_mg_pk.to_string() { + return true; + } + // Check if matches by multicast group code + if let Some(mgroup) = mgroups.get(user_mg_pk) { + if mg_filter == &mgroup.code { + return true; + } + } + false + }) + }) + }); + } + let mut users_displays: Vec = users .into_iter() .map(|(pubkey, user, accesspass)| { @@ -290,7 +361,8 @@ mod tests { doublezerocommand::CliCommand, tests::utils::create_test_client, user::list::{ - ListUserCliCommand, UserCYOA::GREOverDIA, UserStatus::Activated, UserType::IBRL, + ListUserCliCommand, UserCYOA, UserCYOA::GREOverDIA, UserStatus, UserStatus::Activated, + UserType::IBRL, }, }; use doublezero_sdk::{ @@ -564,6 +636,12 @@ mod tests { location: None, owner: None, client_ip: None, + user_type: None, + cyoa_type: None, + dz_ip: None, + tunnel_id: None, + status: None, + multicast_group: None, json: false, json_compact: false, } @@ -581,6 +659,12 @@ mod tests { location: None, owner: None, client_ip: None, + user_type: None, + cyoa_type: None, + dz_ip: None, + tunnel_id: None, + status: None, + multicast_group: None, json: false, json_compact: true, } @@ -590,4 +674,580 @@ mod tests { let output_str = String::from_utf8(output).unwrap(); assert_eq!(output_str, "[{\"account\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\",\"user_type\":\"Multicast\",\"device_pk\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9\",\"multicast\":\"S:m_code\",\"publishers\":\"\",\"subscribers\":\"11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo8\",\"device_name\":\"device1_code\",\"location_code\":\"location1_code\",\"location_name\":\"location1_name\",\"cyoa_type\":\"GREOverDIA\",\"client_ip\":\"1.2.3.4\",\"dz_ip\":\"2.3.4.5\",\"accesspass\":\"Prepaid: (expires epoch 10)\",\"tunnel_id\":500,\"tunnel_net\":\"1.2.3.5/32\",\"status\":\"Activated\",\"owner\":\"11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo\"}]\n"); } + + #[test] + fn test_cli_user_list_filter_by_user_type() { + let mut client = create_test_client(); + + let user1_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo"); + let user2_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUx"); + + let device1_pubkey = Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"); + + let user1 = User { + account_type: AccountType::User, + index: 1, + bump_seed: 2, + owner: user1_pubkey, + user_type: UserType::IBRL, + tenant_pk: Pubkey::default(), + device_pk: device1_pubkey, + cyoa_type: GREOverDIA, + client_ip: [1, 2, 3, 4].into(), + dz_ip: [2, 3, 4, 5].into(), + tunnel_id: 500, + tunnel_net: "1.2.3.5/32".parse().unwrap(), + status: Activated, + publishers: vec![], + subscribers: vec![], + validator_pubkey: Pubkey::default(), + }; + + let user2 = User { + account_type: AccountType::User, + index: 2, + bump_seed: 3, + owner: user2_pubkey, + user_type: UserType::Multicast, + tenant_pk: Pubkey::default(), + device_pk: device1_pubkey, + cyoa_type: GREOverDIA, + client_ip: [1, 2, 3, 5].into(), + dz_ip: [2, 3, 4, 6].into(), + tunnel_id: 501, + tunnel_net: "1.2.3.6/32".parse().unwrap(), + status: Activated, + publishers: vec![], + subscribers: vec![], + validator_pubkey: Pubkey::default(), + }; + + client.expect_list_user().returning(move |_| { + let mut users = std::collections::HashMap::new(); + users.insert(user1_pubkey, user1.clone()); + users.insert(user2_pubkey, user2.clone()); + Ok(users) + }); + + client + .expect_list_device() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_location() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_multicastgroup() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_accesspass() + .returning(|_| Ok(std::collections::HashMap::new())); + + let mut output = Vec::new(); + let res = ListUserCliCommand { + prepaid: false, + solana_validator: false, + solana_identity: None, + device: None, + location: None, + owner: None, + client_ip: None, + user_type: Some(vec!["Multicast".to_string()]), + cyoa_type: None, + dz_ip: None, + tunnel_id: None, + status: None, + multicast_group: None, + json: false, + json_compact: false, + } + .execute(&client, &mut output); + + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("Multicast")); + assert!(!output_str.contains("IBRL") || output_str.contains("Multicast")); + } + + #[test] + fn test_cli_user_list_filter_by_cyoa_type() { + let mut client = create_test_client(); + + let user1_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo"); + let user2_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUx"); + + let device1_pubkey = Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"); + + let user1 = User { + account_type: AccountType::User, + index: 1, + bump_seed: 2, + owner: user1_pubkey, + user_type: IBRL, + tenant_pk: Pubkey::default(), + device_pk: device1_pubkey, + cyoa_type: GREOverDIA, + client_ip: [1, 2, 3, 4].into(), + dz_ip: [2, 3, 4, 5].into(), + tunnel_id: 500, + tunnel_net: "1.2.3.5/32".parse().unwrap(), + status: Activated, + publishers: vec![], + subscribers: vec![], + validator_pubkey: Pubkey::default(), + }; + + let user2 = User { + account_type: AccountType::User, + index: 2, + bump_seed: 3, + owner: user2_pubkey, + user_type: UserType::Multicast, + tenant_pk: Pubkey::default(), + device_pk: device1_pubkey, + cyoa_type: UserCYOA::GREOverFabric, + client_ip: [1, 2, 3, 5].into(), + dz_ip: [2, 3, 4, 6].into(), + tunnel_id: 501, + tunnel_net: "1.2.3.6/32".parse().unwrap(), + status: Activated, + publishers: vec![], + subscribers: vec![], + validator_pubkey: Pubkey::default(), + }; + + client.expect_list_user().returning(move |_| { + let mut users = std::collections::HashMap::new(); + users.insert(user1_pubkey, user1.clone()); + users.insert(user2_pubkey, user2.clone()); + Ok(users) + }); + + client + .expect_list_device() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_location() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_multicastgroup() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_accesspass() + .returning(|_| Ok(std::collections::HashMap::new())); + + let mut output = Vec::new(); + let res = ListUserCliCommand { + prepaid: false, + solana_validator: false, + solana_identity: None, + device: None, + location: None, + owner: None, + client_ip: None, + user_type: None, + cyoa_type: Some(vec!["GREOverDIA".to_string()]), + dz_ip: None, + tunnel_id: None, + status: None, + multicast_group: None, + json: false, + json_compact: false, + } + .execute(&client, &mut output); + + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("GREOverDIA")); + assert!(!output_str.contains("GREOverFabric")); + } + + #[test] + fn test_cli_user_list_filter_by_dz_ip() { + let mut client = create_test_client(); + + let user1_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo"); + let user2_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUx"); + + let device1_pubkey = Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"); + + let user1 = User { + account_type: AccountType::User, + index: 1, + bump_seed: 2, + owner: user1_pubkey, + user_type: IBRL, + tenant_pk: Pubkey::default(), + device_pk: device1_pubkey, + cyoa_type: GREOverDIA, + client_ip: [1, 2, 3, 4].into(), + dz_ip: [2, 3, 4, 5].into(), + tunnel_id: 500, + tunnel_net: "1.2.3.5/32".parse().unwrap(), + status: Activated, + publishers: vec![], + subscribers: vec![], + validator_pubkey: Pubkey::default(), + }; + + let user2 = User { + account_type: AccountType::User, + index: 2, + bump_seed: 3, + owner: user2_pubkey, + user_type: UserType::Multicast, + tenant_pk: Pubkey::default(), + device_pk: device1_pubkey, + cyoa_type: GREOverDIA, + client_ip: [1, 2, 3, 5].into(), + dz_ip: [2, 3, 4, 6].into(), + tunnel_id: 501, + tunnel_net: "1.2.3.6/32".parse().unwrap(), + status: Activated, + publishers: vec![], + subscribers: vec![], + validator_pubkey: Pubkey::default(), + }; + + client.expect_list_user().returning(move |_| { + let mut users = std::collections::HashMap::new(); + users.insert(user1_pubkey, user1.clone()); + users.insert(user2_pubkey, user2.clone()); + Ok(users) + }); + + client + .expect_list_device() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_location() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_multicastgroup() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_accesspass() + .returning(|_| Ok(std::collections::HashMap::new())); + + let mut output = Vec::new(); + let res = ListUserCliCommand { + prepaid: false, + solana_validator: false, + solana_identity: None, + device: None, + location: None, + owner: None, + client_ip: None, + user_type: None, + cyoa_type: None, + dz_ip: Some(vec![[2, 3, 4, 5].into()]), + tunnel_id: None, + status: None, + multicast_group: None, + json: false, + json_compact: false, + } + .execute(&client, &mut output); + + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("2.3.4.5")); + assert!(!output_str.contains("2.3.4.6")); + } + + #[test] + fn test_cli_user_list_filter_by_tunnel_id() { + let mut client = create_test_client(); + + let user1_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo"); + let user2_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUx"); + + let device1_pubkey = Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"); + + let user1 = User { + account_type: AccountType::User, + index: 1, + bump_seed: 2, + owner: user1_pubkey, + user_type: IBRL, + tenant_pk: Pubkey::default(), + device_pk: device1_pubkey, + cyoa_type: GREOverDIA, + client_ip: [1, 2, 3, 4].into(), + dz_ip: [2, 3, 4, 5].into(), + tunnel_id: 500, + tunnel_net: "1.2.3.5/32".parse().unwrap(), + status: Activated, + publishers: vec![], + subscribers: vec![], + validator_pubkey: Pubkey::default(), + }; + + let user2 = User { + account_type: AccountType::User, + index: 2, + bump_seed: 3, + owner: user2_pubkey, + user_type: UserType::Multicast, + tenant_pk: Pubkey::default(), + device_pk: device1_pubkey, + cyoa_type: GREOverDIA, + client_ip: [1, 2, 3, 5].into(), + dz_ip: [2, 3, 4, 6].into(), + tunnel_id: 501, + tunnel_net: "1.2.3.6/32".parse().unwrap(), + status: Activated, + publishers: vec![], + subscribers: vec![], + validator_pubkey: Pubkey::default(), + }; + + client.expect_list_user().returning(move |_| { + let mut users = std::collections::HashMap::new(); + users.insert(user1_pubkey, user1.clone()); + users.insert(user2_pubkey, user2.clone()); + Ok(users) + }); + + client + .expect_list_device() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_location() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_multicastgroup() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_accesspass() + .returning(|_| Ok(std::collections::HashMap::new())); + + let mut output = Vec::new(); + let res = ListUserCliCommand { + prepaid: false, + solana_validator: false, + solana_identity: None, + device: None, + location: None, + owner: None, + client_ip: None, + user_type: None, + cyoa_type: None, + dz_ip: None, + tunnel_id: Some(vec![500]), + status: None, + multicast_group: None, + json: false, + json_compact: false, + } + .execute(&client, &mut output); + + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("500")); + assert!(!output_str.contains("501") || output_str.contains("500")); + } + + #[test] + fn test_cli_user_list_filter_by_status() { + let mut client = create_test_client(); + + let user1_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo"); + let user2_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUx"); + + let device1_pubkey = Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"); + + let user1 = User { + account_type: AccountType::User, + index: 1, + bump_seed: 2, + owner: user1_pubkey, + user_type: IBRL, + tenant_pk: Pubkey::default(), + device_pk: device1_pubkey, + cyoa_type: GREOverDIA, + client_ip: [1, 2, 3, 4].into(), + dz_ip: [2, 3, 4, 5].into(), + tunnel_id: 500, + tunnel_net: "1.2.3.5/32".parse().unwrap(), + status: Activated, + publishers: vec![], + subscribers: vec![], + validator_pubkey: Pubkey::default(), + }; + + let user2 = User { + account_type: AccountType::User, + index: 2, + bump_seed: 3, + owner: user2_pubkey, + user_type: UserType::Multicast, + tenant_pk: Pubkey::default(), + device_pk: device1_pubkey, + cyoa_type: GREOverDIA, + client_ip: [1, 2, 3, 5].into(), + dz_ip: [2, 3, 4, 6].into(), + tunnel_id: 501, + tunnel_net: "1.2.3.6/32".parse().unwrap(), + status: UserStatus::Pending, + publishers: vec![], + subscribers: vec![], + validator_pubkey: Pubkey::default(), + }; + + client.expect_list_user().returning(move |_| { + let mut users = std::collections::HashMap::new(); + users.insert(user1_pubkey, user1.clone()); + users.insert(user2_pubkey, user2.clone()); + Ok(users) + }); + + client + .expect_list_device() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_location() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_multicastgroup() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_accesspass() + .returning(|_| Ok(std::collections::HashMap::new())); + + let mut output = Vec::new(); + let res = ListUserCliCommand { + prepaid: false, + solana_validator: false, + solana_identity: None, + device: None, + location: None, + owner: None, + client_ip: None, + user_type: None, + cyoa_type: None, + dz_ip: None, + tunnel_id: None, + status: Some(vec!["activated".to_string()]), + multicast_group: None, + json: false, + json_compact: false, + } + .execute(&client, &mut output); + + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("activated")); + assert!(!output_str.contains("pending")); + } + + #[test] + fn test_cli_user_list_filter_by_multicast_group() { + let mut client = create_test_client(); + + let user1_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUo"); + let user2_pubkey = Pubkey::from_str_const("11111115RidqCHAoz6dzmXxGcfWLNzevYqNpaRAUx"); + + let device1_pubkey = Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"); + let mgroup1_pubkey = Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo8"); + + let mgroup1 = MulticastGroup { + account_type: AccountType::MulticastGroup, + index: 1, + bump_seed: 2, + tenant_pk: Pubkey::default(), + code: "m_code".to_string(), + multicast_ip: [1, 2, 3, 4].into(), + max_bandwidth: 1000, + status: MulticastGroupStatus::Activated, + owner: Pubkey::from_str_const("11111115q4EpJaTXAZWpCg3J2zppWGSZ46KXozzo9"), + publisher_count: 0, + subscriber_count: 0, + }; + + let user1 = User { + account_type: AccountType::User, + index: 1, + bump_seed: 2, + owner: user1_pubkey, + user_type: IBRL, + tenant_pk: Pubkey::default(), + device_pk: device1_pubkey, + cyoa_type: GREOverDIA, + client_ip: [1, 2, 3, 4].into(), + dz_ip: [2, 3, 4, 5].into(), + tunnel_id: 500, + tunnel_net: "1.2.3.5/32".parse().unwrap(), + status: Activated, + publishers: vec![], + subscribers: vec![], + validator_pubkey: Pubkey::default(), + }; + + let user2 = User { + account_type: AccountType::User, + index: 2, + bump_seed: 3, + owner: user2_pubkey, + user_type: UserType::Multicast, + tenant_pk: Pubkey::default(), + device_pk: device1_pubkey, + cyoa_type: GREOverDIA, + client_ip: [1, 2, 3, 5].into(), + dz_ip: [2, 3, 4, 6].into(), + tunnel_id: 501, + tunnel_net: "1.2.3.6/32".parse().unwrap(), + status: Activated, + publishers: vec![], + subscribers: vec![mgroup1_pubkey], + validator_pubkey: Pubkey::default(), + }; + + client.expect_list_user().returning(move |_| { + let mut users = std::collections::HashMap::new(); + users.insert(user1_pubkey, user1.clone()); + users.insert(user2_pubkey, user2.clone()); + Ok(users) + }); + + client + .expect_list_device() + .returning(|_| Ok(std::collections::HashMap::new())); + client + .expect_list_location() + .returning(|_| Ok(std::collections::HashMap::new())); + client.expect_list_multicastgroup().returning(move |_| { + let mut mgroups = std::collections::HashMap::new(); + mgroups.insert(mgroup1_pubkey, mgroup1.clone()); + Ok(mgroups) + }); + client + .expect_list_accesspass() + .returning(|_| Ok(std::collections::HashMap::new())); + + let mut output = Vec::new(); + let res = ListUserCliCommand { + prepaid: false, + solana_validator: false, + solana_identity: None, + device: None, + location: None, + owner: None, + client_ip: None, + user_type: None, + cyoa_type: None, + dz_ip: None, + tunnel_id: None, + status: None, + multicast_group: Some(vec!["m_code".to_string()]), + json: false, + json_compact: false, + } + .execute(&client, &mut output); + + assert!(res.is_ok()); + let output_str = String::from_utf8(output).unwrap(); + assert!(output_str.contains("m_code")); + assert!(output_str.contains("Multicast")); + assert!(!output_str.contains("IBRL")); + } }