diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bb3943..69cc7d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Added + +- **ports**: new `unifi ports` command tree — `list`, `show`, `find`, and `cycle` — for working with individual switch ports instead of whole devices. +- **ports cycle**: power-cycles a single PoE port via the `devmgr` `power-cycle` command, with pre-flight checks that reject non-PoE ports, administratively-disabled PoE, and a port not currently delivering PoE (`poe_enable: false`, observed live to make the controller reject the command) without ever sending the power-cycle command (the port table is read first). Prompts for confirmation on a TTY; requires `--yes` when piped, otherwise exits 2 with `kind: confirmation_required`. +- **ports find**: resolves a MAC, an IP, or a client name (case-insensitive substring match) to the switch port(s) a device is attached to, via `port_table.last_connection`. A device that has moved between ports appears once per port, with `connected` marking its current one. A name is ambiguous only when it matches more than one device that is actually on a switch port; that returns `kind: conflict` (exit 6) listing the candidates rather than guessing. Other client records sharing the name that aren't themselves on a port (a device's WiFi interface reporting under the same name as its wired one, say) don't count toward ambiguity. +- **ports show**: exposes PoE telemetry the CLI previously discarded — `poe_mode`, `poe_class`, `poe_voltage`, `poe_current`, `poe_good` — plus `attached_mac`. +- The `conflict` error kind (exit code 6), already advertised in `unifi schema`'s error table since 0.2.2 but never emitted by any code path until now. + +### Changed + +- **devices ports**: `unifi devices ports ` is now an alias for `unifi ports list `. Its JSON output gains `device_mac` and `device_name`; every previously emitted key is unchanged, and it keeps its historical bare-JSON-array shape for backward compatibility. ## [0.3.0](https://github.com/rvben/unifi-cli/compare/v0.2.3...v0.3.0) - 2026-07-09 diff --git a/README.md b/README.md index 7426afc..177a5a3 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,71 @@ unifi devices locate aa:bb:cc:dd:ee:ff # Blink locate LED unifi devices locate aa:bb:cc:dd:ee:ff --off # Stop blinking ``` +### Ports + +Find which switch port a device is plugged into, then power-cycle just that +port instead of rebooting the whole switch: + +```bash +# Which port is my Pi on? Matches by name (case-insensitive substring), +# MAC, or IP. +unifi ports find garage-pi +unifi ports find aa:bb:cc:dd:ee:10 + +# Inspect it — PoE mode, class, voltage, current, and what's attached +unifi ports show aa:bb:cc:dd:ee:ff 5 + +# Bounce PoE on that port only — the rest of the switch is untouched +unifi ports cycle aa:bb:cc:dd:ee:ff 5 +``` + +`ports find`'s output feeds directly into `show` and `cycle`: `device_mac` +and `port_idx` are the *switch's* MAC and port index, not the attached +device's. A name is ambiguous only when it matches more than one device +that's actually on a switch port — that returns `kind: conflict` (exit 6) +listing the candidates rather than guessing. Other client records sharing +the name (a device's WiFi interface reporting under the same name as its +wired one, say) don't cause a conflict if they're not themselves on a port. +A device that has moved between switch ports appears once per port it has +ever used, with a `connected` field distinguishing its current port from +stale history. + +`ports show` exposes PoE telemetry the CLI previously discarded: +`poe_mode`, `poe_class`, `poe_voltage`, `poe_current`, `poe_good`, and the +MAC of the attached device (`attached_mac`). + +`ports cycle` is destructive. On a terminal it shows what is about to lose +power and asks for confirmation; when piped it requires `--yes` and +otherwise exits 2 with `kind: confirmation_required`. It reads the port +table first, then refuses **without ever sending the power-cycle command** +when: + +- the port is not PoE-capable (an SFP+ port, say) → `kind: conflict`, exit 6 +- the port's PoE is administratively off → `kind: conflict`, exit 6 +- the port isn't currently delivering PoE (`poe_enable: false`) → `kind: conflict`, exit 6 +- the device has no such port index → `kind: not_found`, exit 4 + +The off interval — how long the port stays unpowered — is chosen by the +switch firmware, not by this CLI. The power-cycle command takes only the +target port, with no duration parameter, on either the legacy endpoint or +the Integration API, so the interval isn't configurable and varies by +device model and firmware version. IEEE 802.3 PoE detection timing imposes +a floor regardless: expect the port to sit dark for roughly 1-2 seconds at +minimum before power returns. + +List ports for one device, or across every device: + +```bash +unifi ports list aa:bb:cc:dd:ee:ff +unifi ports list --limit 20 --fields port_idx,poe_power +``` + +`ports list` returns the paginated `{items, total, limit, offset}` envelope +used by the other list commands. `unifi devices ports ` remains an +alias for `unifi ports list `; it keeps its original bare-JSON-array +shape for backward compatibility, and both emit the same per-row fields, +including `device_mac` and `device_name`. + ### Events ```bash @@ -239,6 +304,7 @@ unifi schema # Dumps all commands, arguments, output fields as JSON | 3 | Authentication error (401/403) | | 4 | Not found (404) | | 5 | API error (server error) | +| 6 | Conflict (ambiguous match or failed precondition) | ## Development diff --git a/src/api/client.rs b/src/api/client.rs index 6ab5605..c8709b8 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -339,6 +339,18 @@ impl UnifiClient { Ok(()) } + /// Power-cycle a single PoE port. `mac` is the **switch's** MAC, not the + /// attached device's. + pub async fn power_cycle_port(&self, mac: &str, port_idx: u32) -> Result<(), ApiError> { + let formatted = format_mac(&normalize_mac(mac)); + self.post_legacy_cmd( + "devmgr", + serde_json::json!({"cmd": "power-cycle", "mac": formatted, "port_idx": port_idx}), + ) + .await?; + Ok(()) + } + pub async fn upgrade_device(&self, mac: &str) -> Result<(), ApiError> { let formatted = format_mac(&normalize_mac(mac)); self.post_legacy_cmd( @@ -407,6 +419,13 @@ impl UnifiClient { .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}"))) } + /// Every device that reports a port table, in one request. `/stat/device` + /// already returns all devices with their port tables, so the unfiltered + /// listing costs no more than the filtered one. + pub async fn list_all_device_ports(&self) -> Result, ApiError> { + self.get_legacy("/stat/device").await + } + // All clients with bandwidth data (legacy endpoint for richer stats) pub async fn list_clients_legacy(&self) -> Result, ApiError> { self.get_legacy("/stat/sta").await diff --git a/src/api/mod.rs b/src/api/mod.rs index 580cd16..657cfc8 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -6,7 +6,7 @@ pub use client::ProtectSession; pub use client::UnifiClient; pub use client::error_for_status; pub use types::{ - ApiError, Client, Device, DeviceWithPorts, Event, HealthSubsystem, HostSystem, LegacyClient, - LegacyDevice, LegacyResponse, Network, PortEntry, SysInfo, format_bytes, format_mac, - format_uptime, normalize_mac, + ApiError, Client, Device, DeviceWithPorts, Event, HealthSubsystem, HostSystem, LastConnection, + LegacyClient, LegacyDevice, LegacyResponse, Network, PortEntry, SysInfo, format_bytes, + format_mac, format_uptime, normalize_mac, }; diff --git a/src/api/tests.rs b/src/api/tests.rs index 27b4ced..2d8f36a 100644 --- a/src/api/tests.rs +++ b/src/api/tests.rs @@ -145,6 +145,17 @@ fn api_error_display_auth() { assert!(display.contains("Hint:")); } +#[test] +fn api_error_display_conflict_has_no_prefix() { + // Unlike Auth/NotFound (sentence fragments that get a prefix added), + // Conflict messages are written whole, to be read by an operator as-is. + let err = ApiError::Conflict("Port 5 on aa:bb:cc:dd:ee:ff does not support PoE.".into()); + assert_eq!( + err.to_string(), + "Port 5 on aa:bb:cc:dd:ee:ff does not support PoE." + ); +} + #[test] fn api_error_display_other() { let err = ApiError::Other("something went wrong".into()); @@ -812,3 +823,70 @@ fn legacy_client_clean_name_no_mac() { let client: LegacyClient = serde_json::from_str(json).unwrap(); assert_eq!(client.clean_name(), "device ee:ff"); } + +// --- PortEntry PoE telemetry --- + +#[test] +fn port_entry_parses_poe_telemetry_with_string_numbers() { + // The controller returns poe_voltage/poe_current/poe_power as JSON + // strings on some firmware, the same quirk PR #3 fixed for poe_power. + let json = serde_json::json!({ + "port_idx": 3, + "name": "Port 3", + "up": true, + "port_poe": true, + "poe_enable": true, + "poe_mode": "auto", + "poe_class": "Class 3", + "poe_power": "5.00", + "poe_voltage": "53.75", + "poe_current": "93.00", + "poe_good": true, + "autoneg": true, + "enable": true, + "is_uplink": false, + "stp_state": "forwarding", + "tx_errors": 0, + "rx_errors": 0, + "last_connection": { + "mac": "aa:bb:cc:dd:ee:20", + "connected": true, + "last_seen": 1783622695 + } + }); + let p: PortEntry = serde_json::from_value(json).expect("PortEntry must parse"); + assert_eq!(p.poe_mode.as_deref(), Some("auto")); + assert_eq!(p.poe_class.as_deref(), Some("Class 3")); + assert_eq!(p.poe_voltage, Some(53.75)); + assert_eq!(p.poe_current, Some(93.00)); + assert_eq!(p.poe_good, Some(true)); + assert_eq!(p.stp_state.as_deref(), Some("forwarding")); + assert_eq!(p.autoneg, Some(true)); + assert_eq!(p.enable, Some(true)); + assert_eq!(p.is_uplink, Some(false)); + let lc = p.last_connection.expect("last_connection present"); + assert_eq!(lc.mac.as_deref(), Some("aa:bb:cc:dd:ee:20")); + assert_eq!(lc.connected, Some(true)); +} + +#[test] +fn port_entry_tolerates_absent_last_connection() { + // A port nothing has ever linked to omits last_connection entirely. + // This is exactly how the empty test-target port was identified. + let json = serde_json::json!({ + "port_idx": 4, + "name": "Port 4", + "up": false, + "port_poe": true, + "poe_enable": false, + "poe_mode": "auto" + }); + let p: PortEntry = serde_json::from_value(json).expect("PortEntry must parse"); + assert!(p.last_connection.is_none()); + assert_eq!(p.poe_voltage, None); + assert!(!p.up); + // Absent tri-state keys must read as unknown, never as a confident "no". + assert_eq!(p.enable, None, "absent enable must not read as disabled"); + assert_eq!(p.autoneg, None); + assert_eq!(p.is_uplink, None); +} diff --git a/src/api/types.rs b/src/api/types.rs index 5a66812..de63296 100644 --- a/src/api/types.rs +++ b/src/api/types.rs @@ -302,10 +302,43 @@ pub struct PortEntry { pub poe_power: Option, #[serde(default)] pub port_poe: bool, + /// "auto", "off", "passthrough", "passive24v". Absent on some firmware. + pub poe_mode: Option, + pub poe_class: Option, + #[serde(default, deserialize_with = "deserialize_string_or_number_f64")] + pub poe_voltage: Option, + #[serde(default, deserialize_with = "deserialize_string_or_number_f64")] + pub poe_current: Option, + pub poe_good: Option, + /// Auto-negotiation state. `Option`, not a defaulted bool, like `enable` + /// and `is_uplink` below: a firmware that omits this key must not be + /// reported as "auto-negotiation off". Matches `poe_good` above; contrast + /// `up`/`poe_enable`, where an absent key genuinely does mean false. + pub autoneg: Option, + /// Administrative enable state. Same tri-state rationale as `autoneg`: an + /// absent key must not be reported as "port administratively disabled". + pub enable: Option, + /// Whether this port is the switch's uplink. Same tri-state rationale as + /// `autoneg`: an absent key must not be reported as "not an uplink". + pub is_uplink: Option, + pub stp_state: Option, + pub tx_errors: Option, + pub rx_errors: Option, + /// Absent entirely on a port nothing has linked to within retention. + pub last_connection: Option, pub tx_bytes: Option, pub rx_bytes: Option, } +/// The device most recently seen on a port. `connected` distinguishes a live +/// attachment from a stale record of a device that has since moved. +#[derive(Debug, Deserialize)] +pub struct LastConnection { + pub mac: Option, + pub connected: Option, + pub last_seen: Option, +} + fn deserialize_string_or_number_f64<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, @@ -485,9 +518,16 @@ pub type RtspsStreams = std::collections::HashMap>; #[derive(Debug)] pub enum ApiError { Http(reqwest::Error), - Api { status: u16, message: String }, + Api { + status: u16, + message: String, + }, NotFound(String), Auth(String), + /// A request that cannot succeed against the resource's current state, + /// rejected locally before any HTTP call. Published by `unifi schema` + /// as kind `conflict`, exit code 6. + Conflict(String), Other(String), } @@ -570,6 +610,7 @@ impl fmt::Display for ApiError { "\n Hint: Check your API key. Generate one in UniFi Settings > API" ) } + ApiError::Conflict(msg) => write!(f, "{msg}"), ApiError::Other(msg) => write!(f, "{msg}"), } } diff --git a/src/commands/devices.rs b/src/commands/devices.rs index 16aad85..68d4d66 100644 --- a/src/commands/devices.rs +++ b/src/commands/devices.rs @@ -1,6 +1,7 @@ use owo_colors::OwoColorize; -use crate::api::{Device, UnifiClient, format_bytes, format_mac, format_uptime}; +use crate::api::{Device, UnifiClient, format_mac, format_uptime}; +use crate::commands::ports::{self, PortRow}; use crate::output::{OutputConfig, use_color}; pub struct Pagination { @@ -253,6 +254,10 @@ pub async fn restart( Ok(()) } +/// Alias for `ports list `. Deliberately keeps the historical bare JSON +/// array shape: `ports list` emits the paginated `{items,total,...}` envelope, +/// but changing this one from array to object would break every consumer +/// indexing the top level. pub async fn ports( client: &UnifiClient, mac: &str, @@ -268,101 +273,21 @@ pub async fn ports( return Ok(()); } + let devices = vec![device]; + // Historical label for a device with neither `name` nor `model`; `ports + // list` / `ports find` keep "-" via `collect_rows`. + let rows = ports::collect_rows_with_fallback(&devices, "Device"); + if out.is_json() { - out.print_data( - &serde_json::to_string_pretty( - &device - .port_table - .iter() - .map(|p| { - serde_json::json!({ - "port_idx": p.port_idx, - "name": p.name, - "media": p.media, - "up": p.up, - "speed": p.speed, - "full_duplex": p.full_duplex, - "poe_enable": p.poe_enable, - "poe_power": p.poe_power, - "port_poe": p.port_poe, - "tx_bytes": p.tx_bytes, - "rx_bytes": p.rx_bytes, - }) - }) - .collect::>(), - ) - .expect("failed to serialize JSON"), - ); + let items: Vec = rows.iter().map(ports::row_json).collect(); + out.print_data(&serde_json::to_string_pretty(&items)?); } else { - let device_label = device - .name - .as_deref() - .unwrap_or(device.model.as_deref().unwrap_or("Device")); - out.print_message(&format!("Ports for {device_label}:\n")); - - let color = use_color(); - let header = format!( - "{:<6} {:<16} {:<6} {:<10} {:<8} {:>10} {:>10}", - "Port", "Name", "Link", "Speed", "PoE", "TX", "RX" - ); - if color { - println!("{}", header.bold()); - println!("{}", "-".repeat(70).dimmed()); - } else { - println!("{header}"); - println!("{}", "-".repeat(70)); - } - - for p in &device.port_table { - let port = p - .port_idx - .map(|i| i.to_string()) - .unwrap_or_else(|| "-".into()); - let name = p.name.as_deref().unwrap_or("-"); - let link = if p.up { "up" } else { "down" }; - let speed = if p.up { - match p.speed { - Some(s) => { - let duplex = if p.full_duplex { "FD" } else { "HD" }; - format!("{s}{duplex}") - } - None => "up".into(), - } - } else { - "down".into() - }; - let poe = if p.poe_enable { - match p.poe_power { - Some(w) if w > 0.0 => format!("{w:.1}W"), - _ => "on".into(), - } - } else if p.port_poe { - "off".into() - } else { - "-".into() - }; - let tx = p.tx_bytes.map(format_bytes).unwrap_or_else(|| "-".into()); - let rx = p.rx_bytes.map(format_bytes).unwrap_or_else(|| "-".into()); - - if color { - let link_display = if p.up { - format!("{}", "up".green()) - } else { - format!("{}", "down".dimmed()) - }; - println!( - " {:<5} {:<16} {:<6} {:<10} {:<8} {:>10} {:>10}", - port, name, link_display, speed, poe, tx, rx - ); - } else { - println!( - " {:<5} {:<16} {:<6} {:<10} {:<8} {:>10} {:>10}", - port, name, link, speed, poe, tx, rx - ); - } - } + let label = &rows[0].device_name; + out.print_message(&format!("Ports for {label}:\n")); + let refs: Vec<&PortRow> = rows.iter().collect(); + let dev_w = ports::device_col_width(&refs); + ports::render_text(&refs, false, dev_w, &out); } - out.print_message(&format!("\n{} ports", device.port_table.len())); Ok(()) } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 78d0572..c01c087 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -2,5 +2,6 @@ pub mod clients; pub mod devices; pub mod events; pub mod networks; +pub mod ports; pub mod protect; pub mod system; diff --git a/src/commands/ports.rs b/src/commands/ports.rs new file mode 100644 index 0000000..c0b9ff8 --- /dev/null +++ b/src/commands/ports.rs @@ -0,0 +1,1291 @@ +use owo_colors::OwoColorize; + +use crate::api::{ + ApiError, DeviceWithPorts, LegacyClient, PortEntry, UnifiClient, format_bytes, format_mac, + normalize_mac, +}; +use crate::output::{OutputConfig, use_color}; + +/// One port, flattened with the device that owns it. Every `ports` subcommand +/// renders these, so the filtered and unfiltered listings cannot drift apart. +pub struct PortRow<'a> { + pub device_mac: String, + pub device_name: String, + pub port: &'a PortEntry, +} + +pub struct Pagination { + pub limit: usize, + pub offset: usize, + /// Field names already validated against `fields::PORTS_LIST`. + pub fields: Option>, +} + +/// Derive a port row's formatted device MAC and display name: `name` -> +/// `model` -> `name_fallback`. Shared by `show` and `collect_rows_with_fallback` +/// so this three-tier fallback can never drift between the two call sites. +fn device_identity(device: &DeviceWithPorts, name_fallback: &str) -> (String, String) { + let device_mac = device + .mac + .as_deref() + .map(format_mac) + .unwrap_or_else(|| "-".into()); + let device_name = device + .name + .as_deref() + .or(device.model.as_deref()) + .unwrap_or(name_fallback) + .to_string(); + (device_mac, device_name) +} + +/// Flatten devices into port rows, skipping devices with no port table. +/// `ports list` / `ports find` fall back to `"-"` for a device with neither +/// `name` nor `model`. +pub fn collect_rows(devices: &[DeviceWithPorts]) -> Vec> { + collect_rows_with_fallback(devices, "-") +} + +/// Same flattening as `collect_rows`, but with a caller-chosen fallback for a +/// device that has neither `name` nor `model`. `devices ports` keeps its +/// historical `"Device"` label here rather than duplicating the whole +/// flattening loop just to change one fallback string. +pub fn collect_rows_with_fallback<'a>( + devices: &'a [DeviceWithPorts], + name_fallback: &str, +) -> Vec> { + let mut rows = Vec::new(); + for d in devices { + if d.port_table.is_empty() { + continue; + } + let (device_mac, device_name) = device_identity(d, name_fallback); + for port in &d.port_table { + rows.push(PortRow { + device_mac: device_mac.clone(), + device_name: device_name.clone(), + port, + }); + } + } + rows +} + +/// The `PORTS_LIST` field set for one row. +pub fn row_json(row: &PortRow) -> serde_json::Value { + let p = row.port; + serde_json::json!({ + "device_mac": row.device_mac, + "device_name": row.device_name, + "port_idx": p.port_idx, + "name": p.name, + "media": p.media, + "up": p.up, + "speed": p.speed, + "full_duplex": p.full_duplex, + "poe_enable": p.poe_enable, + "poe_power": p.poe_power, + "port_poe": p.port_poe, + "tx_bytes": p.tx_bytes, + "rx_bytes": p.rx_bytes, + }) +} + +/// Apply a validated `--fields` projection in place. +pub fn project(value: &mut serde_json::Value, fields: &Option>) { + if let Some(keep) = fields + && let Some(map) = value.as_object_mut() + { + map.retain(|k, _| keep.iter().any(|f| f == k)); + } +} + +/// Human-readable PoE cell: draw in watts, or on/off/- . +fn poe_cell(p: &PortEntry) -> String { + if p.poe_enable { + match p.poe_power { + Some(w) if w > 0.0 => format!("{w:.1}W"), + _ => "on".into(), + } + } else if p.port_poe { + "off".into() + } else { + "-".into() + } +} + +fn speed_cell(p: &PortEntry) -> String { + if !p.up { + return "down".into(); + } + match p.speed { + Some(s) => format!("{s}{}", if p.full_duplex { "FD" } else { "HD" }), + None => "up".into(), + } +} + +/// Right-pad a table cell to `width` visible columns, deriving the padding +/// from `plain`'s length rather than `rendered`'s. +/// +/// This exists because `format!("{: String { + let pad = width.saturating_sub(plain.len()); + format!("{rendered}{}", " ".repeat(pad)) +} + +/// "port" for exactly one row, "ports" otherwise — the row-count trailer's +/// singular/plural noun. +fn port_noun(count: usize) -> &'static str { + if count == 1 { "port" } else { "ports" } +} + +/// Device column width, in characters. Callers that paginate must compute +/// this from the full result set, not just the page handed to `render_text` +/// — otherwise two `--offset` pages of the same query can render the column +/// at different widths. +pub fn device_col_width(rows: &[&PortRow]) -> usize { + rows.iter() + .map(|r| r.device_name.len()) + .max() + .unwrap_or(6) + .max(6) + + 2 +} + +/// Render rows as a table. `show_device_col` is true only for the unfiltered +/// listing; the filtered table stays byte-identical to what `devices ports` +/// has always printed. `dev_w` is the Device column width; pass +/// `device_col_width` of the *full* result set, not just `rows`, so a +/// paginated caller renders a stable width across pages. +pub fn render_text(rows: &[&PortRow], show_device_col: bool, dev_w: usize, out: &OutputConfig) { + render_rows(rows, show_device_col, dev_w, None, out); +} + +/// Same table as `render_text`, with an extra `Connected` column (`yes`/`-`) +/// appended after RX, aligned by index with `rows`. Only `ports find` calls +/// this: `find`'s entire purpose is telling the operator which port a device +/// is on *now*, and previously only the connected-first sort order +/// distinguished that from stale history. `list` and `devices ports` keep +/// calling `render_text` above, unaffected by this column's existence. +pub fn render_text_with_connected( + rows: &[&PortRow], + dev_w: usize, + connected: &[bool], + out: &OutputConfig, +) { + render_rows(rows, true, dev_w, Some(connected), out); +} + +/// Shared implementation behind `render_text` and `render_text_with_connected`. +/// `connected` is `None` for `render_text`'s two callers, so their output is +/// untouched; `Some` only from `render_text_with_connected`. +fn render_rows( + rows: &[&PortRow], + show_device_col: bool, + dev_w: usize, + connected: Option<&[bool]>, + out: &OutputConfig, +) { + let color = use_color(); + // Must match the `{:10} {:>10}", + "Device", "Port", "Name", "Link", "Speed", "PoE", "TX", "RX" + ) + } else { + format!( + "{:<6} {:<16} {:<6} {:<10} {:<8} {:>10} {:>10}", + "Port", "Name", "Link", "Speed", "PoE", "TX", "RX" + ) + }; + let mut rule_w = if show_device_col { 70 + dev_w } else { 70 }; + if connected.is_some() { + header.push_str(&format!(" {:<9}", "Connected")); + rule_w += 10; + } + if color { + println!("{}", header.bold()); + println!("{}", "-".repeat(rule_w).dimmed()); + } else { + println!("{header}"); + println!("{}", "-".repeat(rule_w)); + } + + for (i, r) in rows.iter().enumerate() { + let p = r.port; + let port = p + .port_idx + .map(|i| i.to_string()) + .unwrap_or_else(|| "-".into()); + let name = p.name.as_deref().unwrap_or("-"); + let link = if p.up { "up" } else { "down" }; + let link_rendered = if color { + if p.up { + format!("{}", "up".green()) + } else { + format!("{}", "down".dimmed()) + } + } else { + link.to_string() + }; + let link_cell = pad_visible(&link_rendered, link, LINK_W); + let speed = speed_cell(p); + let poe = poe_cell(p); + let tx = p.tx_bytes.map(format_bytes).unwrap_or_else(|| "-".into()); + let rx = p.rx_bytes.map(format_bytes).unwrap_or_else(|| "-".into()); + + let mut line = if show_device_col { + format!( + " {:10} {:>10}", + r.device_name, port, name, speed, poe, tx, rx + ) + } else { + format!( + " {:<5} {:<16} {link_cell} {:<10} {:<8} {:>10} {:>10}", + port, name, speed, poe, tx, rx + ) + }; + if let Some(flags) = connected { + let is_connected = flags[i]; + let plain = if is_connected { "yes" } else { "-" }; + let rendered = if color { + if is_connected { + format!("{}", "yes".green()) + } else { + format!("{}", "-".dimmed()) + } + } else { + plain.to_string() + }; + let cell = pad_visible(&rendered, plain, CONNECTED_W); + line.push_str(&format!(" {cell}")); + } + println!("{line}"); + } + out.print_message(&format!("\n{} {}", rows.len(), port_noun(rows.len()))); +} + +pub async fn list( + client: &UnifiClient, + mac: Option<&str>, + out: OutputConfig, + pagination: Pagination, +) -> Result<(), Box> { + let devices = match mac { + Some(m) => vec![client.get_device_ports(m).await?], + None => client.list_all_device_ports().await?, + }; + let rows = collect_rows(&devices); + let total = rows.len(); + let page: Vec<&PortRow> = rows + .iter() + .skip(pagination.offset) + .take(pagination.limit) + .collect(); + + if out.is_json() { + let items: Vec = page + .iter() + .map(|r| { + let mut v = row_json(r); + project(&mut v, &pagination.fields); + v + }) + .collect(); + out.print_data(&serde_json::to_string_pretty(&serde_json::json!({ + "items": items, + "total": total, + "limit": pagination.limit, + "offset": pagination.offset, + }))?); + } else { + // Computed from the full `rows`, not the paginated `page`, so two + // `--offset` pages of the same query render the Device column at the + // same width. + let full_refs: Vec<&PortRow> = rows.iter().collect(); + let dev_w = device_col_width(&full_refs); + render_text(&page, mac.is_none(), dev_w, &out); + } + Ok(()) +} + +/// Locate a port by index within a device's port table. +pub fn find_port(device: &DeviceWithPorts, port_idx: u32) -> Result<&PortEntry, ApiError> { + device + .port_table + .iter() + .find(|p| p.port_idx == Some(port_idx)) + .ok_or_else(|| { + let mac = device + .mac + .as_deref() + .map(format_mac) + .unwrap_or_else(|| "device".into()); + ApiError::NotFound(format!("Port {port_idx} on {mac}")) + }) +} + +/// Normalize `identifier` and return it only if it already has MAC shape (12 +/// hex digits once separators are stripped). Shared by `resolve_identifier` +/// and `find` so a MAC identifier is recognized identically in both places +/// without duplicating the predicate. +fn identifier_as_mac(identifier: &str) -> Option { + let normalized = normalize_mac(identifier); + (normalized.len() == 12 && normalized.chars().all(|c| c.is_ascii_hexdigit())) + .then_some(normalized) +} + +/// Resolve a MAC, IP, or client name to every candidate MAC it could refer +/// to. Deliberately does *not* decide ambiguity here: two client records can +/// share a name because they are two interfaces (wired and wireless, say) of +/// one physical device, and only one of them may ever appear in a switch's +/// port table. `find` decides ambiguity from port occupancy instead, after +/// looking up every candidate this returns. +/// +/// Ordered, stopping at the first tier that matches: normalized MAC equality, +/// then exact IP, then case-insensitive name/hostname substring (all matches +/// in that last tier are returned together). Follows the +/// `protect cameras show ` precedent rather than the MAC-only +/// convention of `clients show`, because the whole point of `find` is not +/// having to look the MAC up first. +pub fn resolve_candidates( + identifier: &str, + clients: &[LegacyClient], +) -> Result, ApiError> { + if let Some(mac) = identifier_as_mac(identifier) { + return Ok(vec![mac]); + } + + if let Some(c) = clients.iter().find(|c| c.ip.as_deref() == Some(identifier)) + && let Some(mac) = c.mac.as_deref() + { + return Ok(vec![normalize_mac(mac)]); + } + + let wanted = identifier.to_lowercase(); + let by_name: Vec<&LegacyClient> = clients + .iter() + .filter(|c| { + c.name + .as_deref() + .is_some_and(|n| n.to_lowercase().contains(&wanted)) + || c.hostname + .as_deref() + .is_some_and(|h| h.to_lowercase().contains(&wanted)) + }) + .collect(); + + if by_name.is_empty() { + return Err(ApiError::NotFound(format!( + "No client matching '{identifier}'" + ))); + } + + let macs: Vec = by_name + .iter() + .filter_map(|c| c.mac.as_deref().map(normalize_mac)) + .collect(); + if macs.is_empty() { + return Err(ApiError::NotFound(format!( + "Client '{identifier}' has no MAC" + ))); + } + Ok(macs) +} + +/// Describe one `find` candidate for a conflict message: its name/hostname, +/// formatted MAC, and the switch port it was found on (the connected-first +/// row, i.e. `hits[0]`). Only called once a candidate is already known to +/// have at least one port match. +fn candidate_descriptor(mac: &str, clients: &[LegacyClient], row: &PortRow) -> String { + let label = clients + .iter() + .find(|c| c.mac.as_deref().map(normalize_mac).as_deref() == Some(mac)) + .and_then(|c| c.name.as_deref().or(c.hostname.as_deref())) + .unwrap_or("-"); + let port = row + .port + .port_idx + .map(|i| i.to_string()) + .unwrap_or_else(|| "-".into()); + format!( + "{label} ({}) on {} port {port}", + format_mac(mac), + row.device_name + ) +} + +/// Rows whose `last_connection.mac` matches, connected first so a stale record +/// reads as history rather than as the device's current location. +pub fn matching_rows<'a>( + rows: &'a [PortRow<'a>], + normalized_mac: &str, +) -> Vec<(&'a PortRow<'a>, bool)> { + let mut hits: Vec<(&PortRow, bool)> = rows + .iter() + .filter_map(|r| { + let lc = r.port.last_connection.as_ref()?; + let m = lc.mac.as_deref()?; + (normalize_mac(m) == normalized_mac).then(|| (r, lc.connected.unwrap_or(false))) + }) + .collect(); + hits.sort_by_key(|(_, connected)| !*connected); + hits +} + +/// Find which switch port a device is attached to, by MAC, IP, or client +/// name. +/// +/// Resolution and port lookup interleave rather than picking a single client +/// up front: a name can match more than one client record while only one of +/// them is ever attached to a switch port (a device's wired and wireless +/// interfaces commonly share a name and report separately). Ambiguity is +/// judged by port occupancy, computed after fetching the port tables, not by +/// how many client records the name matched. +pub async fn find( + client: &UnifiClient, + identifier: &str, + out: OutputConfig, + fields: Option>, +) -> Result<(), Box> { + // A MAC identifier resolves locally, so the common scripted path stays a + // single round trip: no client lookup is needed to know which MAC to + // look for on the port tables. + let (candidates, clients) = if let Some(mac) = identifier_as_mac(identifier) { + (vec![mac], Vec::new()) + } else { + let clients = client.list_clients_legacy().await?; + let candidates = resolve_candidates(identifier, &clients)?; + (candidates, clients) + }; + + let devices = client.list_all_device_ports().await?; + let rows = collect_rows(&devices); + + // Port matches for every candidate, keeping only the ones actually on a + // port. A candidate that matched the name/IP but never appears in any + // port table (e.g. a client's WiFi interface, when only its wired + // interface is on a switch) is not noise worth surfacing here. + let mut ported: Vec<(String, Vec<(&PortRow, bool)>)> = candidates + .into_iter() + .filter_map(|mac| { + let hits = matching_rows(&rows, &mac); + (!hits.is_empty()).then_some((mac, hits)) + }) + .collect(); + + let hits = match ported.len() { + 0 => { + return Err(Box::new(ApiError::NotFound(format!( + "No switch port with '{identifier}' attached" + )))); + } + 1 => ported.pop().expect("checked len == 1 above").1, + _ => { + let list = ported + .iter() + .map(|(mac, hits)| candidate_descriptor(mac, &clients, hits[0].0)) + .collect::>() + .join(", "); + return Err(Box::new(ApiError::Conflict(format!( + "'{identifier}' matches {} devices on switch ports: {list}", + ported.len() + )))); + } + }; + + if out.is_json() { + let items: Vec = hits + .iter() + .map(|(r, connected)| { + let mut v = row_json(r); + v["connected"] = (*connected).into(); + project(&mut v, &fields); + v + }) + .collect(); + out.print_data(&serde_json::to_string_pretty(&items)?); + } else { + let refs: Vec<&PortRow> = hits.iter().map(|(r, _)| *r).collect(); + let connected: Vec = hits.iter().map(|(_, c)| *c).collect(); + // `find` never paginates, so `refs` is already the full result set. + let dev_w = device_col_width(&refs); + render_text_with_connected(&refs, dev_w, &connected, &out); + } + Ok(()) +} + +pub async fn show( + client: &UnifiClient, + mac: &str, + port_idx: u32, + out: OutputConfig, +) -> Result<(), Box> { + let device = client.get_device_ports(mac).await?; + let p = find_port(&device, port_idx)?; + let (device_mac, device_name) = device_identity(&device, "-"); + let attached_mac = p + .last_connection + .as_ref() + .and_then(|lc| lc.mac.as_deref()) + .map(format_mac); + + if out.is_json() { + out.print_data(&serde_json::to_string_pretty(&serde_json::json!({ + "device_mac": device_mac, + "device_name": device_name, + "port_idx": p.port_idx, + "name": p.name, + "media": p.media, + "up": p.up, + "speed": p.speed, + "full_duplex": p.full_duplex, + "autoneg": p.autoneg, + "enable": p.enable, + "is_uplink": p.is_uplink, + "stp_state": p.stp_state, + "port_poe": p.port_poe, + "poe_enable": p.poe_enable, + "poe_mode": p.poe_mode, + "poe_class": p.poe_class, + "poe_power": p.poe_power, + "poe_voltage": p.poe_voltage, + "poe_current": p.poe_current, + "poe_good": p.poe_good, + "attached_mac": attached_mac, + "tx_bytes": p.tx_bytes, + "rx_bytes": p.rx_bytes, + "tx_errors": p.tx_errors, + "rx_errors": p.rx_errors, + }))?); + return Ok(()); + } + + let color = use_color(); + let label = |l: &str| -> String { + if color { + format!("{}", l.dimmed()) + } else { + l.to_string() + } + }; + let title = format!("Port {port_idx} on {device_name} ({device_mac})"); + if color { + println!("{}", title.bold()); + } else { + println!("{title}"); + } + println!( + " {} {}", + label("Name: "), + p.name.as_deref().unwrap_or("-") + ); + println!( + " {} {}", + label("Link: "), + if p.up { "up" } else { "down" } + ); + println!(" {} {}", label("Speed: "), speed_cell(p)); + println!( + " {} {}", + label("Media: "), + p.media.as_deref().unwrap_or("-") + ); + println!( + " {} {}", + label("PoE: "), + if p.port_poe { + poe_cell(p) + } else { + "not supported".into() + } + ); + if p.port_poe { + println!( + " {} {}", + label("PoE mode:"), + p.poe_mode.as_deref().unwrap_or("-") + ); + println!( + " {} {}", + label("PoE class"), + p.poe_class.as_deref().unwrap_or("-") + ); + if let Some(v) = p.poe_voltage { + println!(" {} {v:.2} V", label("Voltage: ")); + } + if let Some(c) = p.poe_current { + println!(" {} {c:.2} mA", label("Current: ")); + } + } + println!( + " {} {}", + label("Attached:"), + attached_mac.as_deref().unwrap_or("-") + ); + Ok(()) +} + +/// Reject a power-cycle that cannot succeed, before any HTTP call. +pub fn check_cyclable(port: &PortEntry, device_mac: &str) -> Result<(), ApiError> { + let idx = port + .port_idx + .map(|i| i.to_string()) + .unwrap_or_else(|| "?".into()); + let mac = format_mac(device_mac); + + // `port_poe` is `#[serde(default)] bool` (see src/api/types.rs), so + // firmware that simply omits the key also lands here, indistinguishable + // from a genuinely non-PoE port. That is deliberate: for a command that + // cuts power, failing closed is the right direction. It does mean the + // message/hint below can fire for PoE-capable hardware whose firmware + // didn't report the field, not only for true non-PoE ports. + if !port.port_poe { + return Err(ApiError::Conflict(format!( + "Port {idx} on {mac} does not support PoE. \ + Run `unifi ports list {mac}` to see PoE-capable ports." + ))); + } + // Only an explicit "off" blocks. An absent or unrecognised poe_mode + // proceeds: the field is not guaranteed across firmware revisions. + if port.poe_mode.as_deref() == Some("off") { + return Err(ApiError::Conflict(format!( + "PoE is administratively disabled on port {idx} of {mac} (poe_mode=off)" + ))); + } + // Observed against a live UCG-Max controller: a port with port_poe: true, + // poe_mode: "auto", and poe_enable: false rejected `power-cycle` with + // HTTP 400 api.err.InvalidTargetPort. poe_enable was the only attribute + // that differed from ports that do have power to cycle, so it is used + // here as the guard — that inference has not been confirmed by a + // successful cycle against a poe_enable: true port, since doing so would + // require firing at a port with a live device attached. Surfacing this + // locally as `conflict` avoids the alternative: the controller's 400 + // otherwise falls through to `api_error` (see error_for_status), which + // src/schema.rs advertises as retryable even though retrying cannot help. + if !port.poe_enable { + return Err(ApiError::Conflict(format!( + "Port {idx} on {mac} is not currently delivering PoE (poe_enable=false), \ + so there is no power to cycle." + ))); + } + Ok(()) +} + +/// Whether the cycle actually happened. `Declined` is not an error at this +/// layer — the caller decides how to report a refused confirmation. +#[derive(Debug, PartialEq, Eq)] +pub enum CycleOutcome { + Cycled, + Declined, +} + +/// Power-cycle one PoE port. +/// +/// `confirm` receives a human-readable summary of what is about to lose power +/// and returns whether to proceed. Taking it as a callback keeps the device +/// fetch and the guard rails to exactly one pass: the prompt needs the same +/// port data the checks do, so resolving it twice would mean two round trips +/// to the controller and two chances for the answers to disagree. +pub async fn cycle( + client: &UnifiClient, + mac: &str, + port_idx: u32, + out: OutputConfig, + confirm: F, +) -> Result> +where + F: FnOnce(&str) -> std::io::Result, +{ + let device = client.get_device_ports(mac).await?; + let port = find_port(&device, port_idx)?; + let device_mac = device.mac.as_deref().unwrap_or(mac).to_string(); + check_cyclable(port, &device_mac)?; + + if !confirm(&cycle_summary(&device, port))? { + return Ok(CycleOutcome::Declined); + } + + client.power_cycle_port(&device_mac, port_idx).await?; + out.print_result( + &serde_json::json!({ + "status": "ok", + "action": "power-cycle", + "mac": format_mac(&device_mac), + "port_idx": port_idx, + }), + &format!( + "Power-cycling port {port_idx} on {}", + format_mac(&device_mac) + ), + ); + Ok(CycleOutcome::Cycled) +} + +/// One-line description of what is about to lose power, shown at the prompt. +pub fn cycle_summary(device: &DeviceWithPorts, port: &PortEntry) -> String { + let device_mac = device + .mac + .as_deref() + .map(format_mac) + .unwrap_or_else(|| "-".into()); + let device_name = device + .name + .as_deref() + .or(device.model.as_deref()) + .unwrap_or("-"); + let idx = port + .port_idx + .map(|i| i.to_string()) + .unwrap_or_else(|| "?".into()); + let attached = port + .last_connection + .as_ref() + .filter(|lc| lc.connected.unwrap_or(false)) + .and_then(|lc| lc.mac.as_deref()) + .map(format_mac) + .unwrap_or_else(|| "nothing attached".into()); + let draw = match port.poe_power { + Some(w) if w > 0.0 => format!("{w:.2} W"), + _ => "0 W".into(), + }; + let class = port.poe_class.as_deref().unwrap_or("-"); + format!( + "Port {idx} on {device_name} ({device_mac})\n attached: {attached} • {draw} • {class}" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a `DeviceWithPorts` fixture from a JSON literal, exercising the + /// same `Deserialize` impl the API layer uses. + fn device(json: serde_json::Value) -> DeviceWithPorts { + serde_json::from_value(json).expect("test fixture must deserialize as DeviceWithPorts") + } + + #[test] + fn collect_rows_flattens_multiple_devices_and_skips_empty_port_tables() { + let devices = vec![ + device(serde_json::json!({ + "mac": "aa:bb:cc:dd:ee:01", "name": "SwitchA", + "port_table": [{"port_idx": 1}, {"port_idx": 2}] + })), + device(serde_json::json!({ + "mac": "aa:bb:cc:dd:ee:02", "name": "APWithNoPorts", + "port_table": [] + })), + device(serde_json::json!({ + "mac": "aa:bb:cc:dd:ee:03", "name": "SwitchC", + "port_table": [{"port_idx": 1}] + })), + ]; + + let rows = collect_rows(&devices); + + assert_eq!( + rows.len(), + 3, + "device with an empty port_table must contribute no rows" + ); + assert_eq!(rows[0].device_name, "SwitchA"); + assert_eq!(rows[0].port.port_idx, Some(1)); + assert_eq!(rows[1].device_name, "SwitchA"); + assert_eq!(rows[1].port.port_idx, Some(2)); + assert_eq!(rows[2].device_name, "SwitchC"); + assert_eq!(rows[2].port.port_idx, Some(1)); + assert!( + rows.iter().all(|r| r.device_name != "APWithNoPorts"), + "a device with no ports must never appear in the flattened rows" + ); + } + + #[test] + fn collect_rows_formats_device_mac_and_falls_back_to_model_when_name_is_absent() { + let devices = vec![ + device(serde_json::json!({ + "mac": "9c05d6bc0643", "name": "USW-24-PoE", + "port_table": [{"port_idx": 1}] + })), + device(serde_json::json!({ + "mac": "aabbccddeeff", "model": "USW-Lite-8", + "port_table": [{"port_idx": 1}] + })), + device(serde_json::json!({ + "mac": "112233445566", + "port_table": [{"port_idx": 1}] + })), + ]; + + let rows = collect_rows(&devices); + + assert_eq!( + rows[0].device_mac, "9c:05:d6:bc:06:43", + "device_mac must be formatted via format_mac, not passed through raw" + ); + assert_eq!(rows[0].device_name, "USW-24-PoE"); + + assert_eq!(rows[1].device_mac, "aa:bb:cc:dd:ee:ff"); + assert_eq!( + rows[1].device_name, "USW-Lite-8", + "device_name must fall back to model when name is absent" + ); + + assert_eq!(rows[2].device_mac, "11:22:33:44:55:66"); + assert_eq!( + rows[2].device_name, "-", + "device_name must fall back to '-' when both name and model are absent" + ); + } + + #[test] + fn collect_rows_with_fallback_uses_the_caller_supplied_fallback() { + // `devices ports` restores the historical "Device" label for a + // device with neither `name` nor `model`; `collect_rows` (used by + // `ports list` / `ports find`) must keep falling back to "-". + let devices = vec![device(serde_json::json!({ + "mac": "aa:bb:cc:dd:ee:ff", + "port_table": [{"port_idx": 1}] + }))]; + + let fallback_rows = collect_rows_with_fallback(&devices, "Device"); + assert_eq!(fallback_rows[0].device_name, "Device"); + + let default_rows = collect_rows(&devices); + assert_eq!( + default_rows[0].device_name, "-", + "collect_rows must still fall back to '-', unaffected by the new parameter" + ); + } + + #[test] + fn row_json_emits_exactly_the_fields_declared_in_ports_list() { + let devices = vec![device(serde_json::json!({ + "mac": "aa:bb:cc:dd:ee:ff", "name": "SwitchA", + "port_table": [{ + "port_idx": 1, "name": "Port 1", "media": "GE", "up": true, + "speed": 1000, "full_duplex": true, "poe_enable": true, + "poe_power": 4.5, "port_poe": true, "tx_bytes": 100, "rx_bytes": 200 + }] + }))]; + let rows = collect_rows(&devices); + let value = row_json(&rows[0]); + let obj = value.as_object().expect("row_json must emit a JSON object"); + + let mut emitted: Vec<&str> = obj.keys().map(String::as_str).collect(); + emitted.sort_unstable(); + let mut declared: Vec<&str> = crate::fields::names(crate::fields::PORTS_LIST); + declared.sort_unstable(); + + assert_eq!( + emitted, declared, + "row_json keys must exactly match fields::PORTS_LIST, so the two cannot drift" + ); + } + + #[test] + fn project_retains_only_the_requested_fields() { + let mut value = serde_json::json!({"a": 1, "b": 2, "c": 3}); + project(&mut value, &Some(vec!["a".to_string(), "c".to_string()])); + + let obj = value.as_object().unwrap(); + assert_eq!(obj.len(), 2); + assert!(obj.contains_key("a")); + assert!(obj.contains_key("c")); + assert!(!obj.contains_key("b"), "unrequested fields must be dropped"); + } + + #[test] + fn project_is_a_noop_when_fields_is_none() { + let mut value = serde_json::json!({"a": 1, "b": 2, "c": 3}); + let before = value.clone(); + + project(&mut value, &None); + + assert_eq!( + value, before, + "a None projection must leave the value untouched" + ); + } + + // `pad_visible` is what makes coloured Link/Connected cells line up with + // the header at a TTY. `use_color()` reads `stdout().is_terminal()` + // directly with no override, so a unit test cannot force colour on for + // `render_rows` itself without adding a flag/env override the task this + // was written for explicitly ruled out ("no new flags"). Testing the + // padding helper directly, with a hand-built ANSI-escaped string standing + // in for what `owo_colors` would emit, exercises the actual defect + // (padding computed from byte length instead of visible width) without + // needing a real TTY. + #[test] + fn pad_visible_pads_by_plain_width_not_escaped_byte_length() { + // The real thing `render_rows` hands `pad_visible` on the coloured + // path: `"up".green()` rendered to a `String`, several bytes of ANSI + // escapes wrapped around 2 visible characters. A `{:<6}` specifier + // sees this as already over width 6 (that was the bug) and pads with + // nothing at all. + let escaped = format!("{}", "up".green()); + assert!( + escaped.len() > "up".len(), + "fixture must actually carry escape bytes, or this test proves nothing: {escaped:?}" + ); + + let padded = pad_visible(&escaped, "up", 6); + + assert!( + padded.starts_with(&escaped), + "the coloured text itself must be emitted untouched: {padded:?}" + ); + let visible_padding = &padded[escaped.len()..]; + assert_eq!( + visible_padding, " ", + "padding must be derived from \"up\".len() (2), not the escaped \ + string's byte length: {padded:?}" + ); + } + + #[test] + fn pad_visible_matches_the_uncoloured_output_it_replaces() { + // On the uncoloured path every call site passes `rendered == plain`, + // so this must reproduce exactly what the old `{: DeviceWithPorts { + serde_json::from_value(serde_json::json!({ + "mac": "aa:bb:cc:dd:ee:ff", + "name": "SwitchA", + "port_table": ports + })) + .expect("fixture must parse") + } + + #[test] + fn find_port_returns_the_matching_entry() { + let d = device_with(serde_json::json!([ + {"port_idx": 1, "port_poe": true}, + {"port_idx": 5, "port_poe": true, "poe_mode": "auto"} + ])); + let p = find_port(&d, 5).expect("port 5 exists"); + assert_eq!(p.port_idx, Some(5)); + assert_eq!(p.poe_mode.as_deref(), Some("auto")); + } + + #[test] + fn find_port_missing_is_not_found() { + let d = device_with(serde_json::json!([{"port_idx": 1}])); + let err = find_port(&d, 99).expect_err("port 99 does not exist"); + assert!(matches!(err, crate::api::ApiError::NotFound(_))); + } + + #[test] + fn check_cyclable_rejects_non_poe_port() { + let d = device_with(serde_json::json!([{"port_idx": 9, "port_poe": false}])); + let p = find_port(&d, 9).unwrap(); + let err = check_cyclable(p, "aa:bb:cc:dd:ee:ff").expect_err("SFP+ has no PoE"); + match err { + crate::api::ApiError::Conflict(msg) => { + assert!(msg.contains("does not support PoE"), "got: {msg}") + } + other => panic!("expected Conflict, got {other:?}"), + } + } + + #[test] + fn check_cyclable_rejects_poe_mode_off() { + let d = device_with(serde_json::json!([ + {"port_idx": 4, "port_poe": true, "poe_mode": "off"} + ])); + let p = find_port(&d, 4).unwrap(); + let err = check_cyclable(p, "aa:bb:cc:dd:ee:ff").expect_err("PoE is off"); + match err { + crate::api::ApiError::Conflict(msg) => { + assert!(msg.contains("poe_mode=off"), "got: {msg}") + } + other => panic!("expected Conflict, got {other:?}"), + } + } + + #[test] + fn check_cyclable_allows_absent_poe_mode() { + // poe_mode is not guaranteed across firmware. A missing value must not + // block a port that already passed the port_poe check. poe_enable is + // set explicitly here so this test stays about poe_mode alone, not + // about the separate poe_enable guard below. + let d = device_with(serde_json::json!([ + {"port_idx": 4, "port_poe": true, "poe_enable": true} + ])); + let p = find_port(&d, 4).unwrap(); + assert!(check_cyclable(p, "aa:bb:cc:dd:ee:ff").is_ok()); + } + + #[test] + fn check_cyclable_allows_a_port_actually_delivering_power() { + // The genuinely cyclable case: PoE-capable, auto, and delivering + // power right now. + let d = device_with(serde_json::json!([ + {"port_idx": 4, "port_poe": true, "poe_mode": "auto", "poe_enable": true} + ])); + let p = find_port(&d, 4).unwrap(); + assert!(check_cyclable(p, "aa:bb:cc:dd:ee:ff").is_ok()); + } + + #[test] + fn check_cyclable_rejects_poe_enable_false() { + // This fixture is the live UCG-Max finding that prompted this guard: + // PoE-capable, mode "auto", passing both prior checks, but not + // currently delivering power (poe_enable defaults to false here, + // matching what the controller reported). Firing power-cycle at it + // was rejected with HTTP 400 api.err.InvalidTargetPort instead of + // succeeding, which is why this must be rejected locally too rather + // than treated as the earlier "happy path" this test used to assert. + let d = device_with(serde_json::json!([ + {"port_idx": 4, "port_poe": true, "poe_mode": "auto", "up": false} + ])); + let p = find_port(&d, 4).unwrap(); + let err = check_cyclable(p, "aa:bb:cc:dd:ee:fe").expect_err("poe_enable is false"); + match err { + crate::api::ApiError::Conflict(msg) => { + assert!(msg.contains("not currently delivering PoE"), "got: {msg}") + } + other => panic!("expected Conflict, got {other:?}"), + } + } + + #[test] + fn check_cyclable_poe_mode_off_message_wins_over_poe_enable_false() { + // poe_mode: "off" implies poe_enable: false (confirmed explicitly + // here rather than relying on the default), so both guards would + // fire. The administratively-disabled message must win: it is the + // more specific and more useful of the two, and the poe_mode check + // runs first in `check_cyclable`. + let d = device_with(serde_json::json!([ + {"port_idx": 4, "port_poe": true, "poe_mode": "off", "poe_enable": false} + ])); + let p = find_port(&d, 4).unwrap(); + let err = check_cyclable(p, "aa:bb:cc:dd:ee:ff").expect_err("PoE is off"); + match err { + crate::api::ApiError::Conflict(msg) => { + assert!(msg.contains("poe_mode=off"), "got: {msg}"); + assert!( + !msg.contains("not currently delivering PoE"), + "the administratively-disabled message must win over the \ + poe_enable=false message: {msg}" + ); + } + other => panic!("expected Conflict, got {other:?}"), + } + } + + // `cycle_summary` is the text a human reads before authorising a power + // cut. Untested, it carries real logic that would be easy to invert or + // drop silently: the `connected` filter on `last_connection`, and the + // watt formatting. + + #[test] + fn cycle_summary_shows_the_attached_mac_when_connected() { + let d = device_with(serde_json::json!([{ + "port_idx": 4, "port_poe": true, + "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": true} + }])); + let p = find_port(&d, 4).unwrap(); + let summary = cycle_summary(&d, p); + assert!( + summary.contains("aa:bb:cc:dd:ee:10"), + "a connected last_connection must show the formatted attached MAC: {summary}" + ); + } + + #[test] + fn cycle_summary_reads_nothing_attached_for_a_stale_record() { + // connected: false is history, not the device's current location; the + // summary must not read as if a live device would lose power. + let d = device_with(serde_json::json!([{ + "port_idx": 4, "port_poe": true, + "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": false} + }])); + let p = find_port(&d, 4).unwrap(); + let summary = cycle_summary(&d, p); + assert!( + summary.contains("nothing attached"), + "a stale (disconnected) last_connection must read as unattached: {summary}" + ); + assert!( + !summary.contains("aa:bb:cc:dd:ee:10"), + "a stale MAC must not appear as if it were live: {summary}" + ); + } + + #[test] + fn cycle_summary_reads_nothing_attached_when_no_last_connection() { + let d = device_with(serde_json::json!([{"port_idx": 4, "port_poe": true}])); + let p = find_port(&d, 4).unwrap(); + let summary = cycle_summary(&d, p); + assert!( + summary.contains("nothing attached"), + "an absent last_connection must read as unattached: {summary}" + ); + } + + #[test] + fn cycle_summary_shows_the_wattage_for_a_powered_port() { + let d = device_with(serde_json::json!([{ + "port_idx": 4, "port_poe": true, "poe_enable": true, + "poe_power": 5.25, "poe_class": "4" + }])); + let p = find_port(&d, 4).unwrap(); + let summary = cycle_summary(&d, p); + assert!( + summary.contains("5.25 W"), + "draw must be formatted to two decimal places: {summary}" + ); + } + + // `_id` is required by `LegacyClient` (every other fixture in this codebase + // supplies it); the plan's literal fixture omitted it, so it is added here + // to make the fixture actually deserialize. + fn clients_fixture() -> Vec { + serde_json::from_value(serde_json::json!([ + {"_id": "1", "mac": "aa:bb:cc:dd:ee:10", "name": "garage-pi", "ip": "10.0.0.5"}, + {"_id": "2", "mac": "aa:bb:cc:dd:ee:20", "name": "office-ap", "ip": "10.0.0.6"}, + {"_id": "3", "mac": "aa:bb:cc:dd:ee:21", "name": "Main-Office", "ip": "10.0.0.7"} + ])) + .expect("fixture must parse") + } + + #[test] + fn resolve_candidates_accepts_any_mac_format() { + let c = clients_fixture(); + // A MAC resolves without consulting the client list at all. + assert_eq!( + resolve_candidates("AA-BB-CC-DD-EE-10", &c).unwrap(), + vec!["aabbccddee10"] + ); + } + + #[test] + fn resolve_candidates_matches_ip_then_name() { + let c = clients_fixture(); + assert_eq!( + resolve_candidates("10.0.0.5", &c).unwrap(), + vec!["aabbccddee10"] + ); + assert_eq!( + resolve_candidates("GARAGE-PI", &c).unwrap(), + vec!["aabbccddee10"] + ); + } + + // `resolve_candidates` no longer decides ambiguity by itself — a name + // matching multiple client records is not an error here, since `find` + // only calls it a conflict once it also knows more than one candidate + // sits on a switch port. This replaces the old + // `resolve_identifier_ambiguous_name_is_conflict`, which asserted the + // opposite (that a name-match count alone was a `Conflict`). + #[test] + fn resolve_candidates_returns_every_name_match_without_erroring() { + let c = clients_fixture(); + let macs = resolve_candidates("office", &c).expect("both are valid candidates"); + assert_eq!( + macs, + vec!["aabbccddee20".to_string(), "aabbccddee21".to_string()], + "both office-ap and Main-Office must come back as candidates" + ); + } + + #[test] + fn resolve_candidates_unknown_is_not_found() { + let c = clients_fixture(); + let err = resolve_candidates("nothing-here", &c).expect_err("unknown"); + assert!(matches!(err, crate::api::ApiError::NotFound(_))); + } + + #[test] + fn matching_rows_sort_connected_first() { + let devices: Vec = serde_json::from_value(serde_json::json!([{ + "mac": "aa:bb:cc:dd:ee:ff", + "name": "SwitchA", + "port_table": [ + {"port_idx": 2, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": false}}, + {"port_idx": 7, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": true}}, + {"port_idx": 9, "last_connection": {"mac": "11:22:33:44:55:66", "connected": true}} + ] + }])) + .expect("fixture must parse"); + let rows = collect_rows(&devices); + let hits = matching_rows(&rows, "aabbccddee10"); + assert_eq!(hits.len(), 2, "device appears on two ports"); + assert_eq!( + hits[0].0.port.port_idx, + Some(7), + "connected port sorts first" + ); + assert!(hits[0].1, "first hit is connected"); + assert!(!hits[1].1, "second hit is the stale record"); + } + + #[test] + fn find_json_row_matches_exactly_the_fields_declared_in_ports_find() { + // Exercises the same construction `find` uses (`row_json` plus the + // manually-inserted `connected` key) without needing an HTTP mock, so + // a drift between the two can never sneak past this test. + let devices = vec![device(serde_json::json!({ + "mac": "aa:bb:cc:dd:ee:ff", "name": "SwitchA", + "port_table": [{ + "port_idx": 7, + "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": true} + }] + }))]; + let rows = collect_rows(&devices); + let hits = matching_rows(&rows, "aabbccddee10"); + let (row, connected) = hits[0]; + let mut value = row_json(row); + value["connected"] = connected.into(); + + let obj = value.as_object().expect("must emit a JSON object"); + let mut emitted: Vec<&str> = obj.keys().map(String::as_str).collect(); + emitted.sort_unstable(); + let mut declared: Vec<&str> = crate::fields::names(crate::fields::PORTS_FIND); + declared.sort_unstable(); + + assert_eq!( + emitted, declared, + "find's emitted keys must exactly match fields::PORTS_FIND" + ); + } +} diff --git a/src/fields.rs b/src/fields.rs index a54aeb3..1d38d7b 100644 --- a/src/fields.rs +++ b/src/fields.rs @@ -67,6 +67,42 @@ pub const NETWORKS_LIST: &[Field] = &[ ("default", "boolean"), ]; +pub const PORTS_LIST: &[Field] = &[ + ("device_mac", "string"), + ("device_name", "string"), + ("port_idx", "integer"), + ("name", "string"), + ("media", "string"), + ("up", "boolean"), + ("speed", "integer"), + ("full_duplex", "boolean"), + ("poe_enable", "boolean"), + ("poe_power", "number"), + ("port_poe", "boolean"), + ("tx_bytes", "integer"), + ("rx_bytes", "integer"), +]; + +/// `ports find` rows: the `PORTS_LIST` set plus `connected`, which +/// distinguishes a live attachment from a stale record. Kept separate from +/// `PORTS_LIST` so the `devices ports` alias gains exactly two new keys. +pub const PORTS_FIND: &[Field] = &[ + ("device_mac", "string"), + ("device_name", "string"), + ("port_idx", "integer"), + ("name", "string"), + ("media", "string"), + ("up", "boolean"), + ("speed", "integer"), + ("full_duplex", "boolean"), + ("poe_enable", "boolean"), + ("poe_power", "number"), + ("port_poe", "boolean"), + ("tx_bytes", "integer"), + ("rx_bytes", "integer"), + ("connected", "boolean"), +]; + /// A `--fields` request naming one or more unknown fields. #[derive(Debug, PartialEq, Eq)] pub struct InvalidFields { @@ -197,6 +233,8 @@ mod tests { DEVICES_LIST, EVENTS_LIST, NETWORKS_LIST, + PORTS_LIST, + PORTS_FIND, ] { let mut seen = names(table); let before = seen.len(); @@ -214,10 +252,12 @@ mod tests { DEVICES_LIST, EVENTS_LIST, NETWORKS_LIST, + PORTS_LIST, + PORTS_FIND, ] { for (name, ty) in table { assert!( - ["string", "integer", "boolean"].contains(ty), + ["string", "integer", "boolean", "number"].contains(ty), "field {name} has unexpected type {ty}" ); } diff --git a/src/main.rs b/src/main.rs index 677484b..ff06a47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,6 +75,10 @@ enum Command { command: Option, }, + /// Inspect and manage switch ports + #[command(subcommand)] + Ports(PortsCommand), + /// View controller events #[command(subcommand)] Events(EventsCommand), @@ -229,6 +233,52 @@ enum DevicesCommand { }, } +#[derive(Subcommand)] +enum PortsCommand { + /// List ports for one device, or across all devices + List { + /// MAC address of a switch or router. Omit to list every device's ports. + mac: Option, + /// Maximum number of results to return + #[arg(long, default_value = "100")] + limit: usize, + /// Number of results to skip + #[arg(long, default_value = "0")] + offset: usize, + /// Comma-separated list of fields to include in output (see `unifi schema`) + #[arg(long)] + fields: Option, + /// Live-updating TUI view of port status (requires MAC) + #[arg(long, requires = "mac")] + live: bool, + /// Refresh interval in seconds (only with --live) + #[arg(short = 'i', long, default_value = "2")] + interval: u64, + }, + /// Show details for a single port + Show { + /// MAC address of the switch or router + mac: String, + /// Port index (see `unifi ports list `) + port: u32, + }, + /// Find which switch port a device is attached to + Find { + /// MAC address, IP address, or client name + identifier: String, + /// Comma-separated list of fields to include in output (see `unifi schema`) + #[arg(long)] + fields: Option, + }, + /// Power-cycle a single PoE port + Cycle { + /// MAC address of the switch (not the attached device) + mac: String, + /// Port index (see `unifi ports list `) + port: u32, + }, +} + #[derive(Subcommand)] enum ConfigCommand { /// Create or update the configuration file interactively @@ -335,6 +385,33 @@ fn require_confirmation(yes: bool, action: &str) { } } +/// Prompt for confirmation of a destructive action. Returns true only on an +/// explicit yes; an empty line, EOF, or anything else declines. +/// +/// Separate from `prompt_line`, which returns `InitError` and belongs to the +/// config-init flow. Reader/writer are injected so this is unit-testable +/// without a TTY. +fn confirm_destructive( + reader: &mut dyn std::io::BufRead, + writer: &mut dyn std::io::Write, + summary: &str, +) -> std::io::Result { + writeln!(writer, "{summary}")?; + write!(writer, "Power-cycle this port? (y/N): ")?; + writer.flush()?; + let mut line = String::new(); + reader.read_line(&mut line)?; + // The user's Enter is echoed by the terminal, not written to this stream, + // so without this the prompt line above stays unterminated on our writer. + // With stdin a TTY and stderr redirected, a decline's error envelope + // (printed with `eprintln!` right after) would then land on the same + // physical line as the prompt instead of starting fresh — breaking the + // "envelope is the last line of stderr" contract (tests/cli_contract.rs, + // `error_envelope_last_line_is_json` in tests/spec_compliance.rs). + writeln!(writer)?; + Ok(matches!(line.trim().to_lowercase().as_str(), "y" | "yes")) +} + fn print_schema() { schema::print_schema(Cli::command()); } @@ -346,6 +423,8 @@ fn validate_requested_fields(command: &Command) -> Result>, I Command::Clients(ClientsCommand::List { fields, .. }) => (fields, fields::CLIENTS_LIST), Command::Devices(DevicesCommand::List { fields, .. }) => (fields, fields::DEVICES_LIST), Command::Events(EventsCommand::List { fields, .. }) => (fields, fields::EVENTS_LIST), + Command::Ports(PortsCommand::List { fields, .. }) => (fields, fields::PORTS_LIST), + Command::Ports(PortsCommand::Find { fields, .. }) => (fields, fields::PORTS_FIND), _ => return Ok(None), }; @@ -1268,6 +1347,66 @@ async fn main() { } }, Command::Networks { .. } => commands::networks::list(&mut client, out).await, + Command::Ports(cmd) => match cmd { + PortsCommand::List { + mac, + limit, + offset, + fields: _, + live, + interval, + } => { + if live { + let mac = mac.expect("clap requires --live to be paired with a MAC"); + unifi_cli::tui::run_ports(&client, &mac, interval).await + } else { + let pagination = commands::ports::Pagination { + limit, + offset, + fields: requested_fields, + }; + commands::ports::list(&client, mac.as_deref(), out, pagination).await + } + } + PortsCommand::Show { mac, port } => { + commands::ports::show(&client, &mac, port, out).await + } + PortsCommand::Find { identifier, .. } => { + commands::ports::find(&client, &identifier, out, requested_fields).await + } + PortsCommand::Cycle { mac, port } => { + require_confirmation(cli.yes, "power-cycle"); + let skip_prompt = cli.yes; + let outcome = commands::ports::cycle(&client, &mac, port, out, |summary| { + if skip_prompt { + return Ok(true); + } + // Reached only on a TTY: require_confirmation already + // exited for the piped-without---yes case. + let mut stdin = std::io::stdin().lock(); + let mut stderr = std::io::stderr(); + confirm_destructive(&mut stdin, &mut stderr, summary) + }) + .await; + + // main() returns (), so `?` cannot be used here; match keeps + // this arm's value the same `Result<(), Box>` every + // other arm produces, so errors still flow through the single + // `if let Err(e) = result` handler below. + match outcome { + Ok(commands::ports::CycleOutcome::Cycled) => Ok(()), + Ok(commands::ports::CycleOutcome::Declined) => { + print_error_envelope( + "confirmation_required", + "Aborted: confirmation declined.", + None, + ); + std::process::exit(exit_codes::CONFIRMATION_REQUIRED); + } + Err(e) => Err(e), + } + } + }, Command::Events(cmd) => match cmd { EventsCommand::List { limit, @@ -1497,6 +1636,60 @@ api_key = "work_key" assert_eq!(host.as_deref(), Some("work.example.com")); } + // --- confirm_destructive --- + + #[test] + fn confirm_destructive_accepts_y_and_yes() { + for input in ["y\n", "Y\n", "yes\n", "YES\n"] { + let mut reader = std::io::BufReader::new(input.as_bytes()); + let mut writer: Vec = Vec::new(); + assert!( + confirm_destructive(&mut reader, &mut writer, "Port 4").unwrap(), + "{input:?} must confirm" + ); + } + } + + #[test] + fn confirm_destructive_declines_everything_else() { + for input in ["n\n", "no\n", "\n", "maybe\n", ""] { + let mut reader = std::io::BufReader::new(input.as_bytes()); + let mut writer: Vec = Vec::new(); + assert!( + !confirm_destructive(&mut reader, &mut writer, "Port 4").unwrap(), + "{input:?} must decline" + ); + } + } + + #[test] + fn confirm_destructive_shows_the_summary_and_default_no() { + let mut reader = std::io::BufReader::new(&b"n\n"[..]); + let mut writer: Vec = Vec::new(); + confirm_destructive(&mut reader, &mut writer, "Port 4 on SwitchA").unwrap(); + let shown = String::from_utf8(writer).unwrap(); + assert!(shown.contains("Port 4 on SwitchA"), "got: {shown}"); + assert!(shown.contains("(y/N)"), "default must read as No: {shown}"); + } + + #[test] + fn confirm_destructive_terminates_the_prompt_line_with_a_newline() { + // The user's Enter is echoed by the terminal, not by this writer, so + // the prompt's own `write!` leaves the stream mid-line unless + // `confirm_destructive` terminates it itself. A subsequent + // `eprintln!` (e.g. the confirmation_required envelope printed on + // decline) must start on a fresh line, not get appended to the + // prompt. + let mut reader = std::io::BufReader::new(&b"n\n"[..]); + let mut writer: Vec = Vec::new(); + confirm_destructive(&mut reader, &mut writer, "Port 4 on SwitchA").unwrap(); + let shown = String::from_utf8(writer).unwrap(); + assert!( + shown.ends_with('\n'), + "prompt output must end with a newline so a following line starts clean: {shown:?}" + ); + } + // --- mask_api_key --- #[test] @@ -2489,6 +2682,34 @@ api_key = "work_key" } } + #[test] + fn cli_parses_ports_list_without_mac() { + let cli = Cli::parse_from(["unifi", "ports", "list"]); + match cli.command { + Command::Ports(PortsCommand::List { mac, limit, .. }) => { + assert!(mac.is_none()); + assert_eq!(limit, 100); + } + _ => panic!("expected Ports List"), + } + } + + #[test] + fn cli_parses_ports_list_with_mac() { + let cli = Cli::parse_from(["unifi", "ports", "list", "aa:bb:cc:dd:ee:ff"]); + match cli.command { + Command::Ports(PortsCommand::List { mac, .. }) => { + assert_eq!(mac.as_deref(), Some("aa:bb:cc:dd:ee:ff")); + } + _ => panic!("expected Ports List"), + } + } + + #[test] + fn cli_rejects_ports_live_without_mac() { + assert!(Cli::try_parse_from(["unifi", "ports", "list", "--live"]).is_err()); + } + #[test] fn cli_devices_upgrade() { let cli = parse(&[ diff --git a/src/output.rs b/src/output.rs index ebe69fb..13a33bc 100644 --- a/src/output.rs +++ b/src/output.rs @@ -104,6 +104,7 @@ pub mod exit_codes { pub const AUTH_ERROR: i32 = 3; pub const NOT_FOUND: i32 = 4; pub const API_ERROR: i32 = 5; + pub const CONFLICT: i32 = 6; } /// Map an error to a specific exit code by downcasting to ApiError. @@ -113,6 +114,7 @@ pub fn exit_code_for_error(err: &(dyn std::error::Error + 'static)) -> i32 { crate::api::ApiError::Auth(_) => exit_codes::AUTH_ERROR, crate::api::ApiError::NotFound(_) => exit_codes::NOT_FOUND, crate::api::ApiError::Api { .. } => exit_codes::API_ERROR, + crate::api::ApiError::Conflict(_) => exit_codes::CONFLICT, crate::api::ApiError::Http(_) | crate::api::ApiError::Other(_) => { exit_codes::GENERAL_ERROR } @@ -129,6 +131,7 @@ pub fn error_kind_and_code(err: &(dyn std::error::Error + 'static)) -> (&'static crate::api::ApiError::Auth(_) => ("auth_error", exit_codes::AUTH_ERROR), crate::api::ApiError::NotFound(_) => ("not_found", exit_codes::NOT_FOUND), crate::api::ApiError::Api { .. } => ("api_error", exit_codes::API_ERROR), + crate::api::ApiError::Conflict(_) => ("conflict", exit_codes::CONFLICT), crate::api::ApiError::Http(_) | crate::api::ApiError::Other(_) => { ("general_error", exit_codes::GENERAL_ERROR) } @@ -215,4 +218,18 @@ mod tests { assert!(envelope["error"]["kind"].as_str().is_some()); assert!(envelope["error"]["message"].as_str().is_some()); } + + #[test] + fn exit_code_for_conflict() { + let err = ApiError::Conflict("port has no PoE".into()); + assert_eq!(exit_code_for_error(&err), exit_codes::CONFLICT); + } + + #[test] + fn error_kind_and_code_conflict() { + let err = ApiError::Conflict("port has no PoE".into()); + let (kind, code) = error_kind_and_code(&err); + assert_eq!(kind, "conflict"); + assert_eq!(code, 6); + } } diff --git a/src/schema.rs b/src/schema.rs index 7b982ef..a75296d 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -156,30 +156,70 @@ fn command_metadata() -> HashMap<&'static str, CommandMeta> { ); m.insert( "devices ports", + f( + fields::PORTS_LIST, + false, + Some("Alias for `ports list`; returns a bare JSON array for backward compatibility."), + ), + ); + m.insert( + "devices upgrade", + f( + &[ + ("status", "string"), + ("action", "string"), + ("mac", "string"), + ], + true, + None, + ), + ); + + // ports + m.insert("ports list", f(fields::PORTS_LIST, false, None)); + m.insert( + "ports show", f( &[ + ("device_mac", "string"), + ("device_name", "string"), ("port_idx", "integer"), ("name", "string"), ("media", "string"), ("up", "boolean"), ("speed", "integer"), ("full_duplex", "boolean"), + ("autoneg", "boolean"), + ("enable", "boolean"), + ("is_uplink", "boolean"), + ("stp_state", "string"), + ("port_poe", "boolean"), ("poe_enable", "boolean"), + ("poe_mode", "string"), + ("poe_class", "string"), ("poe_power", "number"), + ("poe_voltage", "number"), + ("poe_current", "number"), + ("poe_good", "boolean"), + ("attached_mac", "string"), ("tx_bytes", "integer"), ("rx_bytes", "integer"), + ("tx_errors", "integer"), + ("rx_errors", "integer"), ], false, None, ), ); + m.insert("ports find", f(fields::PORTS_FIND, false, None)); m.insert( - "devices upgrade", + "ports cycle", f( &[ ("status", "string"), ("action", "string"), ("mac", "string"), + ("port_idx", "integer"), ], true, None, @@ -507,7 +547,7 @@ pub fn print_schema(cmd: clap::Command) { "kind": "confirmation_required", "exit_code": 2, "retryable": false, - "description": "Destructive command requires --yes flag when stdin is not a terminal", + "description": "Confirmation for a destructive command was not obtained: either --yes was omitted while stdin is not a terminal, or the operator declined at an interactive confirmation prompt", }, { "kind": "auth_error", diff --git a/tests/cli_contract.rs b/tests/cli_contract.rs index b83716a..81de144 100644 --- a/tests/cli_contract.rs +++ b/tests/cli_contract.rs @@ -159,6 +159,59 @@ fn clients_list_accepts_every_documented_field() { } } +// --- `ports` surface --- + +#[test] +fn ports_list_rejects_unknown_field() { + let out = unifi() + .args(["ports", "list", "--fields", "bogus"]) + .output() + .expect("failed to run binary"); + + assert_eq!( + out.status.code(), + Some(2), + "expected usage exit code 2, stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert_eq!( + error_envelope(&out.stderr)["error"]["kind"].as_str(), + Some("config_error") + ); +} + +#[test] +fn devices_ports_and_ports_list_are_the_same_command() { + // Both spellings must accept a MAC and reach the network layer, not fail + // at argument parsing. Pointed at an unroutable host, so no controller. + for args in [ + vec!["devices", "ports", "aa:bb:cc:dd:ee:ff"], + vec!["ports", "list", "aa:bb:cc:dd:ee:ff"], + ] { + let out = unifi().args(&args).output().expect("failed to run binary"); + assert_ne!( + out.status.code(), + Some(2), + "{args:?} must not be a usage error, stderr: {}", + String::from_utf8_lossy(&out.stderr) + ); + } +} + +#[test] +fn ports_live_requires_a_mac() { + let out = unifi() + .args(["ports", "list", "--live"]) + .output() + .expect("failed to run binary"); + + assert_eq!( + out.status.code(), + Some(2), + "--live without a MAC must be a usage error" + ); +} + // --- subcommand surface consistency --- #[test] @@ -245,3 +298,18 @@ fn every_published_output_field_is_accepted_by_fields() { ); } } + +#[test] +fn ports_cycle_requires_yes_without_a_tty() { + let out = unifi() + .args(["ports", "cycle", "aa:bb:cc:dd:ee:ff", "5"]) + .stdin(std::process::Stdio::null()) + .output() + .expect("failed to run binary"); + + assert_eq!(out.status.code(), Some(2)); + assert_eq!( + error_envelope(&out.stderr)["error"]["kind"].as_str(), + Some("confirmation_required") + ); +} diff --git a/tests/mock_server.rs b/tests/mock_server.rs index 7b9ca44..95ca7ea 100644 --- a/tests/mock_server.rs +++ b/tests/mock_server.rs @@ -1,4 +1,4 @@ -use wiremock::matchers::{method, path, path_regex}; +use wiremock::matchers::{body_json, method, path, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; // Helper to create a UnifiClient pointing at the mock server @@ -342,6 +342,32 @@ mod client_api { client.restart_device("aa:bb:cc:dd:ee:ff").await.unwrap(); } + #[tokio::test] + async fn power_cycle_port_sends_correct_command() { + let server = MockServer::start().await; + + Mock::given(method("POST")) + .and(path("/proxy/network/api/s/default/cmd/devmgr")) + .and(body_json(serde_json::json!({ + "cmd": "power-cycle", + "mac": "aa:bb:cc:dd:ee:ff", + "port_idx": 5 + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [] + }))) + .expect(1) + .mount(&server) + .await; + + let client = mock_client(&server).await; + client + .power_cycle_port("AA-BB-CC-DD-EE-FF", 5) + .await + .unwrap(); + } + #[tokio::test] async fn upgrade_device_sends_correct_command() { let server = MockServer::start().await; @@ -496,6 +522,31 @@ mod client_api { let err = client.get_sysinfo().await.unwrap_err(); assert!(err.to_string().contains("No sysinfo returned")); } + + #[tokio::test] + async fn list_all_device_ports_returns_every_device() { + let server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [ + {"mac": "aa:bb:cc:dd:ee:ff", "name": "SwitchA", + "port_table": [{"port_idx": 1, "port_poe": true}]}, + {"mac": "11:22:33:44:55:66", "name": "SwitchB", + "port_table": [{"port_idx": 1}, {"port_idx": 2}]} + ] + }))) + .expect(1) + .mount(&server) + .await; + + let client = mock_client(&server).await; + let devices = client.list_all_device_ports().await.unwrap(); + assert_eq!(devices.len(), 2); + assert_eq!(devices[1].port_table.len(), 2); + } } // --- Error handling tests --- @@ -1741,6 +1792,1301 @@ mod command_output { assert!(err.to_string().contains("Not found")); } + // `devices::ports` used to derive its own `name -> model -> "Device"` + // device-label fallback; routing it through the shared `collect_rows` + // silently changed the fallback to "-" for a device with neither `name` + // nor `model`, and nothing caught it. Drives the real binary (JSON is + // easiest to assert on) so the regression is locked in at the command + // level, not just in the `collect_rows_with_fallback` unit test in + // `src/commands/ports.rs`. + #[tokio::test] + async fn devices_ports_falls_back_to_device_label_when_name_and_model_absent() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "aa:bb:cc:dd:ee:ff", + "port_table": [{"port_idx": 1}] + }] + }))) + .mount(&server) + .await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "devices", + "ports", + "aa:bb:cc:dd:ee:ff", + "-o", + "json", + ]) + .output() + .expect("failed to run the unifi binary"); + assert!( + output.status.success(), + "devices ports failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let items = body + .as_array() + .expect("devices ports must emit a bare JSON array"); + assert_eq!( + items[0]["device_name"], "Device", + "devices ports must keep its historical \"Device\" fallback, not \"-\": {items:?}" + ); + } + + // --- Ports show (single-port detail) --- + + #[tokio::test] + async fn ports_show_table() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE", + "port_table": [ + {"port_idx": 1, "name": "Port 1", "media": "GE", "up": true}, + { + "port_idx": 5, "name": "Port 5", "media": "GE", "up": true, + "speed": 1000, "full_duplex": true, "autoneg": true, "enable": true, + "is_uplink": false, "stp_state": "forwarding", + "port_poe": true, "poe_enable": true, "poe_mode": "auto", + "poe_class": "4", "poe_power": 5.2, "poe_voltage": 53.5, + "poe_current": 120.3, "poe_good": true, + "last_connection": {"mac": "aabbccddeeff", "connected": true}, + "tx_bytes": 100, "rx_bytes": 200, "tx_errors": 0, "rx_errors": 2 + } + ] + }] + }))) + .mount(&server) + .await; + + let client = mock_client(&server).await; + unifi_cli::commands::ports::show(&client, "9c:05:d6:bc:06:43", 5, out_table()) + .await + .unwrap(); + } + + // `ports_show_table` above only smoke-tests that the text branch doesn't + // panic (matching the pre-existing convention for other `show`-style + // commands in this file). But `ports show`'s text branch is new code with + // real formatting logic (speed_cell, poe_cell, voltage/current, the + // attached MAC), so it gets its own test that spawns the real binary + // (same pattern as `ports_list_pagination_reports_full_total_and_truncated_items` + // and `devices_ports_bare_array_vs_ports_list_envelope`) and asserts on + // the actual rendered text. + #[tokio::test] + async fn ports_show_text_output_renders_expected_fields() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE", + "port_table": [{ + "port_idx": 5, "name": "Port 5", "media": "GE", "up": true, + "speed": 1000, "full_duplex": true, + "port_poe": true, "poe_enable": true, "poe_mode": "auto", + "poe_class": "4", "poe_power": 5.2, "poe_voltage": 53.5, + "poe_current": 120.3, + "last_connection": {"mac": "aabbccddeeff", "connected": true} + }] + }] + }))) + .mount(&server) + .await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "ports", + "show", + "9c:05:d6:bc:06:43", + "5", + "--output", + "text", + ]) + .output() + .expect("failed to run the unifi binary"); + assert!( + output.status.success(), + "ports show failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let text = String::from_utf8_lossy(&output.stdout); + assert!( + text.contains("Port 5 on USW-24-PoE (9c:05:d6:bc:06:43)"), + "title line: {text}" + ); + assert!(text.contains("Port 5"), "port name: {text}"); + assert!(text.contains("1000FD"), "speed+duplex formatting: {text}"); + assert!(text.contains("GE"), "media: {text}"); + assert!(text.contains("5.2W"), "PoE wattage: {text}"); + assert!(text.contains("auto"), "PoE mode: {text}"); + assert!(text.contains("53.50 V"), "PoE voltage: {text}"); + assert!(text.contains("120.30 mA"), "PoE current: {text}"); + assert!(text.contains("aa:bb:cc:dd:ee:ff"), "attached MAC: {text}"); + } + + // Drives the real `unifi` binary so the JSON this command actually prints + // can be inspected, and cross-checks it against what `unifi schema` + // publishes for "ports show" — the two are supposed to be the same + // contract, and nothing else in this suite would catch them drifting + // apart. + #[tokio::test] + async fn ports_show_json_matches_schema_output_fields() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE", + "port_table": [{ + "port_idx": 5, "name": "Port 5", "media": "GE", "up": true, + "speed": 1000, "full_duplex": true, "autoneg": true, "enable": true, + "is_uplink": false, "stp_state": "forwarding", + "port_poe": true, "poe_enable": true, "poe_mode": "auto", + "poe_class": "4", "poe_power": 5.2, "poe_voltage": 53.5, + "poe_current": 120.3, "poe_good": true, + "last_connection": {"mac": "aabbccddeeff", "connected": true}, + "tx_bytes": 100, "rx_bytes": 200, "tx_errors": 0, "rx_errors": 2 + }] + }] + }))) + .mount(&server) + .await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "ports", + "show", + "9c:05:d6:bc:06:43", + "5", + "--output", + "json", + ]) + .output() + .expect("failed to run the unifi binary"); + assert!( + output.status.success(), + "ports show failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| { + panic!( + "stdout was not valid JSON ({e}): {}", + String::from_utf8_lossy(&output.stdout) + ) + }); + let obj = body + .as_object() + .expect("ports show must emit a JSON object"); + + // Values that were previously fetched and thrown away. + assert_eq!(obj["device_mac"], "9c:05:d6:bc:06:43"); + assert_eq!(obj["port_idx"], 5); + assert_eq!(obj["poe_mode"], "auto"); + assert_eq!(obj["poe_class"], "4"); + assert_eq!(obj["poe_voltage"], 53.5); + assert_eq!(obj["poe_current"], 120.3); + assert_eq!(obj["poe_good"], true); + assert_eq!( + obj["attached_mac"], "aa:bb:cc:dd:ee:ff", + "attached_mac must be read from last_connection.mac and formatted" + ); + assert_eq!(obj["tx_errors"], 0); + assert_eq!(obj["rx_errors"], 2); + + // The schema's published output_fields must exactly match the keys + // this JSON branch actually emits: no undiscoverable field, and no + // documented field that never appears. + let schema_output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .arg("schema") + .output() + .expect("failed to run unifi schema"); + let schema: serde_json::Value = serde_json::from_slice(&schema_output.stdout) + .expect("unifi schema must print valid JSON"); + let ports_show = schema["commands"] + .as_array() + .expect("schema must have a commands array") + .iter() + .find(|c| c["name"] == "ports show") + .expect("schema must publish a \"ports show\" command"); + let mut declared: Vec<&str> = ports_show["output_fields"] + .as_array() + .expect("ports show must declare output_fields") + .iter() + .map(|f| f["name"].as_str().expect("output field must have a name")) + .collect(); + declared.sort_unstable(); + let mut emitted: Vec<&str> = obj.keys().map(String::as_str).collect(); + emitted.sort_unstable(); + assert_eq!( + emitted, declared, + "ports show output_fields in the schema must exactly match the JSON branch's keys" + ); + } + + // `autoneg`/`enable`/`is_uplink`/`poe_good` are tri-state: a firmware that + // omits the key must serialize as JSON null, not fall back to `false`, + // since a missing key must not read as a confident "disabled". Likewise + // `attached_mac` must be null when no device has ever linked to the port. + #[tokio::test] + async fn ports_show_omitted_tri_state_fields_serialize_as_null() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "aa:bb:cc:dd:ee:ff", "name": "USW-Lite-8", + "port_table": [ + {"port_idx": 3, "name": "Port 3", "media": "GE", "up": false} + ] + }] + }))) + .mount(&server) + .await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "ports", + "show", + "aa:bb:cc:dd:ee:ff", + "3", + "--output", + "json", + ]) + .output() + .expect("failed to run the unifi binary"); + assert!(output.status.success()); + + let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + for field in ["autoneg", "enable", "is_uplink", "poe_good", "attached_mac"] { + assert!( + body[field].is_null(), + "{field} must be null when firmware omits it, not false: {body}" + ); + } + } + + // Locates `unifi ports cycle 99` uses the same `find_port` lookup; + // a bogus port index must be reported as not-found (exit 4) rather than + // firing a command at the controller for a port that does not exist. + #[tokio::test] + async fn ports_show_not_found() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "aa:bb:cc:dd:ee:ff", "name": "USW-Lite-8", + "port_table": [{"port_idx": 1}] + }] + }))) + .mount(&server) + .await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "ports", + "show", + "aa:bb:cc:dd:ee:ff", + "99", + ]) + .output() + .expect("failed to run the unifi binary"); + + assert_eq!( + output.status.code(), + Some(4), + "a nonexistent port must exit 4 (not found), got {:?}\nstderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Not found"), + "stderr must explain the port was not found: {stderr}" + ); + } + + // The row-count trailer must read "1 port" for a single row and "N ports" + // otherwise — it used to say "1 ports" unconditionally. Spawns the real + // binary (rather than calling `render_text` in-process) so this observes + // literal stderr text, the same surface an operator actually reads. + #[tokio::test] + async fn ports_list_trailer_is_singular_for_exactly_one_row() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{"mac": "aa:bb:cc:dd:ee:01", "name": "SwitchA", + "port_table": [{"port_idx": 1}]}] + }))) + .mount(&server) + .await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "ports", + "list", + "--output", + "text", + ]) + .output() + .expect("failed to run the unifi binary"); + assert!( + output.status.success(), + "ports list failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.trim_end().ends_with("1 port"), + "a single row must be reported as \"1 port\", not \"1 ports\": {stderr:?}" + ); + } + + #[tokio::test] + async fn ports_list_trailer_is_plural_for_multiple_rows() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{"mac": "aa:bb:cc:dd:ee:01", "name": "SwitchA", + "port_table": [{"port_idx": 1}, {"port_idx": 2}]}] + }))) + .mount(&server) + .await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "ports", + "list", + "--output", + "text", + ]) + .output() + .expect("failed to run the unifi binary"); + assert!( + output.status.success(), + "ports list failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.trim_end().ends_with("2 ports"), + "two rows must be reported as \"2 ports\": {stderr:?}" + ); + } + + // --- Ports list (top-level) --- + // + // Drives the real `unifi` binary against a wiremock server so the JSON + // envelope it actually prints can be inspected. A regression that computed + // `total` from the truncated page (instead of the full flattened result) + // would let an agent mistake a partial page for a complete one, so this + // must observe real stdout rather than call `commands::ports::list` + // in-process and only check that it returns `Ok`. + #[tokio::test] + async fn ports_list_pagination_reports_full_total_and_truncated_items() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [ + {"mac": "aa:bb:cc:dd:ee:01", "name": "SwitchA", + "port_table": [{"port_idx": 1}, {"port_idx": 2}]}, + {"mac": "aa:bb:cc:dd:ee:02", "name": "SwitchB", + "port_table": [{"port_idx": 1}, {"port_idx": 2}, {"port_idx": 3}]} + ] + }))) + .mount(&server) + .await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "ports", + "list", + "--output", + "json", + "--limit", + "3", + "--offset", + "1", + ]) + .output() + .expect("failed to run the unifi binary"); + + assert!( + output.status.success(), + "ports list failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| { + panic!( + "stdout was not valid JSON ({e}): {}", + String::from_utf8_lossy(&output.stdout) + ) + }); + + let items = body["items"] + .as_array() + .expect("envelope must have an items array"); + assert_eq!( + items.len(), + 3, + "the page must be truncated to the requested limit" + ); + assert_eq!( + body["total"], 5, + "total must reflect every port across every device, not just this page" + ); + assert_ne!( + body["total"].as_u64().unwrap(), + items.len() as u64, + "an agent must be able to tell a truncated page from a complete result" + ); + assert_eq!(body["limit"], 3); + assert_eq!(body["offset"], 1); + } + + // `render_text`'s Device column width used to be derived from whatever + // page it was handed, which for `ports list` is the already-paginated + // page. Two `--offset` pages of the same query could then render the + // column at different widths. The device names below are chosen so the + // longest one falls on the second page only; if the width regressed back + // to being page-local, the two headers would render at different widths + // and this comparison would fail. + #[tokio::test] + async fn ports_list_device_column_width_is_stable_across_pages() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [ + {"mac": "aa:bb:cc:dd:ee:01", "name": "SwitchA", + "port_table": [{"port_idx": 1}, {"port_idx": 2}]}, + {"mac": "aa:bb:cc:dd:ee:02", "name": "A-Very-Long-Switch-Name", + "port_table": [{"port_idx": 1}]} + ] + }))) + .mount(&server) + .await; + + let run_text = |limit: &str, offset: &str| -> String { + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "ports", + "list", + "--output", + "text", + "--limit", + limit, + "--offset", + offset, + ]) + .output() + .expect("failed to run the unifi binary"); + assert!(output.status.success()); + String::from_utf8_lossy(&output.stdout).into_owned() + }; + + // Page 1: only SwitchA's two ports (the long name lives on page 2). + let page1 = run_text("2", "0"); + // Page 2: only the long-named device's one port. + let page2 = run_text("1", "2"); + + fn header(s: &str) -> &str { + s.lines() + .find(|l| l.contains("Device")) + .expect("text output must have a header row containing \"Device\"") + } + assert_eq!( + header(&page1), + header(&page2), + "the Device column width must come from the full result set, not the page, \ + so two --offset pages of the same query render an identical header:\n\ + page1: {page1}\npage2: {page2}" + ); + } + + // `devices ports ` is documented as an alias for `ports list ` + // that deliberately keeps the historical bare JSON array shape, while + // `ports list` emits the paginated `{items,total,limit,offset}` envelope. + // Wrapping `devices ports` in the envelope would break any consumer that + // indexes the top level, which the design explicitly forbids. + // + // Nothing else in this suite would catch that regression: the in-process + // `devices_ports_*` tests above only `.unwrap()`/`.unwrap_err()` and never + // capture stdout, and `devices_ports_and_ports_list_are_the_same_command` + // in `tests/cli_contract.rs` only asserts the exit code isn't a usage + // error. So this spawns the real compiled binary against a wiremock + // server (same pattern as `ports_list_pagination_reports_full_total_and_truncated_items` + // above) and parses actual stdout as JSON to assert on shape. + #[tokio::test] + async fn devices_ports_bare_array_vs_ports_list_envelope() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE", + "port_table": [ + {"port_idx": 1, "name": "Port 1", "media": "GE", "up": true, "speed": 1000, "full_duplex": true, "poe_enable": true, "poe_power": 5.2, "port_poe": true, "tx_bytes": 123456789, "rx_bytes": 987654321}, + {"port_idx": 2, "name": "Port 2", "media": "GE", "up": true, "speed": 100, "full_duplex": false, "poe_enable": false, "port_poe": true, "tx_bytes": 1000, "rx_bytes": 2000} + ] + }] + }))) + .mount(&server) + .await; + + let run_json = |args: &[&str]| -> serde_json::Value { + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args(["--host", &server.uri(), "--api-key", "test-key"]) + .args(args) + .output() + .expect("failed to run the unifi binary"); + assert!( + output.status.success(), + "{args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap_or_else(|e| { + panic!( + "{args:?} stdout was not valid JSON ({e}): {}", + String::from_utf8_lossy(&output.stdout) + ) + }) + }; + + let alias = run_json(&["devices", "ports", "9c:05:d6:bc:06:43", "-o", "json"]); + let canonical = run_json(&["ports", "list", "9c:05:d6:bc:06:43", "-o", "json"]); + + // 1. `devices ports` must be a bare array, and rows must carry the + // device_mac/device_name fields shared with `ports list`. + let alias_items = alias + .as_array() + .unwrap_or_else(|| panic!("devices ports must emit a bare JSON array, got: {alias}")); + assert!( + !alias_items.is_empty(), + "expected at least one port row from devices ports" + ); + let alias_row = alias_items[0] + .as_object() + .expect("devices ports row must be a JSON object"); + assert!( + alias_row.contains_key("device_mac"), + "devices ports row must carry device_mac: {alias_row:?}" + ); + assert!( + alias_row.contains_key("device_name"), + "devices ports row must carry device_name: {alias_row:?}" + ); + + // 2. `ports list` must be the {items,total,limit,offset} envelope. + assert!( + canonical.is_object(), + "ports list must emit an {{items,total,limit,offset}} envelope object, got: {canonical}" + ); + let items = canonical["items"] + .as_array() + .expect("ports list envelope must have an items array"); + assert!( + canonical.get("total").is_some(), + "ports list envelope must have a total field" + ); + assert!( + canonical.get("limit").is_some(), + "ports list envelope must have a limit field" + ); + assert!( + canonical.get("offset").is_some(), + "ports list envelope must have an offset field" + ); + assert!( + !items.is_empty(), + "expected at least one port row from ports list" + ); + + // 3. The two spellings must carry the same key set per row, locking + // in the shared-field-set property alongside the envelope split. + let mut alias_keys: Vec<&str> = alias_row.keys().map(String::as_str).collect(); + alias_keys.sort_unstable(); + let mut canonical_keys: Vec<&str> = items[0] + .as_object() + .expect("ports list row must be a JSON object") + .keys() + .map(String::as_str) + .collect(); + canonical_keys.sort_unstable(); + + assert_eq!( + alias_keys, canonical_keys, + "devices ports and ports list must share the same per-row field set" + ); + } + + // --- Ports find (reverse lookup) --- + + // A MAC identifier must resolve locally so the common scripted path stays + // a single round trip; mounting `/stat/sta` with `.expect(0)` turns an + // accidental client-list fetch into a test failure instead of a silent, + // unnoticed second request. This also locks in connected-first sorting + // and the exact `PORTS_FIND` field set end to end, through the real + // binary and JSON output, not just the in-process helpers. + #[tokio::test] + async fn ports_find_by_mac_sorts_connected_first_and_skips_client_lookup() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE", + "port_table": [ + {"port_idx": 2, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": false}}, + {"port_idx": 7, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": true}}, + {"port_idx": 9, "last_connection": {"mac": "11:22:33:44:55:66", "connected": true}} + ] + }] + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/sta")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, "data": [] + }))) + .expect(0) + .mount(&server) + .await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "ports", + "find", + "aa:bb:cc:dd:ee:10", + "-o", + "json", + ]) + .output() + .expect("failed to run the unifi binary"); + assert!( + output.status.success(), + "ports find failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let body: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| { + panic!( + "stdout was not valid JSON ({e}): {}", + String::from_utf8_lossy(&output.stdout) + ) + }); + let items = body + .as_array() + .expect("ports find must emit a bare JSON array, like `networks list`"); + assert_eq!(items.len(), 2, "the device appears on two ports"); + assert_eq!( + items[0]["port_idx"], 7, + "the connected port must sort first" + ); + assert_eq!(items[0]["connected"], true); + assert_eq!(items[1]["port_idx"], 2, "the stale record sorts last"); + assert_eq!(items[1]["connected"], false); + + let mut emitted: Vec<&str> = items[0] + .as_object() + .expect("row must be a JSON object") + .keys() + .map(String::as_str) + .collect(); + emitted.sort_unstable(); + let mut declared: Vec<&str> = unifi_cli::fields::names(unifi_cli::fields::PORTS_FIND); + declared.sort_unstable(); + assert_eq!( + emitted, declared, + "ports find rows must carry exactly the PORTS_FIND field set" + ); + } + + // Ambiguity is judged by port occupancy, not by how many client records a + // name matches: `office` genuinely matches two devices here, and both + // are actually attached to a switch port (unlike the "one interface + // never shows up" fixtures below), so this must still exit 6 (conflict) + // and name both candidates. Modeled on a live-controller case — two + // physically distinct office devices on the same switch — reported + // against the pre-fix behavior in the original bug report. + #[tokio::test] + async fn ports_find_ambiguous_name_exits_with_conflict() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/sta")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [ + {"_id": "1", "mac": "aa:bb:cc:dd:ee:20", "name": "office-ap", "ip": "10.0.0.6"}, + {"_id": "2", "mac": "aa:bb:cc:dd:ee:21", "name": "Main-Office", "ip": "10.0.0.7"} + ] + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "9c:05:d6:bc:06:43", "name": "USW Pro XG 8 PoE", + "port_table": [ + {"port_idx": 3, "last_connection": {"mac": "aa:bb:cc:dd:ee:20", "connected": true}}, + {"port_idx": 4, "last_connection": {"mac": "aa:bb:cc:dd:ee:21", "connected": true}} + ] + }] + }))) + .mount(&server) + .await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "ports", + "find", + "office", + ]) + .output() + .expect("failed to run the unifi binary"); + + assert_eq!( + output.status.code(), + Some(6), + "an ambiguous name must exit 6 (conflict), got {:?}\nstderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let last_line = stderr.trim_end().lines().last().unwrap_or(""); + let envelope: serde_json::Value = + serde_json::from_str(last_line).expect("last stderr line must be valid JSON"); + assert_eq!(envelope["error"]["kind"], "conflict"); + let message = envelope["error"]["message"] + .as_str() + .expect("error envelope must carry a message"); + assert!(message.contains("office-ap"), "got: {message}"); + assert!(message.contains("Main-Office"), "got: {message}"); + } + + // A device whose wired and wireless interfaces share a name: `garage-pi` + // matches two client records (a Raspberry Pi's wired and wireless + // interfaces, MACs one bit apart in the last octet), but only the wired + // interface ever shows up in a port table. That must resolve cleanly to + // the one candidate that is actually on a port, not conflict. + #[tokio::test] + async fn ports_find_name_matches_two_clients_only_one_on_a_port_resolves_without_conflict() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/sta")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [ + {"_id": "1", "mac": "aa:bb:cc:dd:ee:10", "name": "garage-pi", "ip": "10.0.0.5"}, + {"_id": "2", "mac": "aa:bb:cc:dd:ee:11", "name": "garage-pi", "ip": "10.0.0.9"} + ] + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "9c:05:d6:bc:06:43", "name": "USW Pro XG 8 PoE", + "port_table": [ + {"port_idx": 5, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": true}} + ] + }] + }))) + .mount(&server) + .await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "ports", + "find", + "garage-pi", + "-o", + "json", + ]) + .output() + .expect("failed to run the unifi binary"); + assert!( + output.status.success(), + "ports find must resolve the single ported candidate, not conflict: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let items: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|e| { + panic!( + "stdout was not valid JSON ({e}): {}", + String::from_utf8_lossy(&output.stdout) + ) + }); + let items = items + .as_array() + .expect("ports find must emit a bare JSON array"); + assert_eq!(items.len(), 1, "only the wired interface is on a port"); + assert_eq!(items[0]["port_idx"], 5); + assert_eq!(items[0]["connected"], true); + } + + // The other client record sharing the name never appears in any port + // table at all — not "only the wireless interface", but no candidate on + // a port whatsoever — so this must be not_found, not a conflict. + #[tokio::test] + async fn ports_find_name_matches_clients_none_on_a_port_is_not_found() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/sta")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [ + {"_id": "1", "mac": "aa:bb:cc:dd:ee:10", "name": "lobby-display", "ip": "10.0.0.15"}, + {"_id": "2", "mac": "aa:bb:cc:dd:ee:11", "name": "lobby-display", "ip": "10.0.0.16"} + ] + }))) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "9c:05:d6:bc:06:43", "name": "USW Pro XG 8 PoE", + "port_table": [ + {"port_idx": 1, "last_connection": {"mac": "11:22:33:44:55:66", "connected": true}} + ] + }] + }))) + .mount(&server) + .await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "ports", + "find", + "lobby-display", + ]) + .output() + .expect("failed to run the unifi binary"); + + assert_eq!( + output.status.code(), + Some(4), + "neither candidate is on any port, so this must exit 4 (not_found), got {:?}\nstderr: {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8_lossy(&output.stderr); + let last_line = stderr.trim_end().lines().last().unwrap_or(""); + let envelope: serde_json::Value = + serde_json::from_str(last_line).expect("last stderr line must be valid JSON"); + assert_eq!(envelope["error"]["kind"], "not_found"); + let message = envelope["error"]["message"] + .as_str() + .expect("error envelope must carry a message"); + assert!(message.contains("lobby-display"), "got: {message}"); + } + + // `find`'s JSON output has always carried `connected`; only the text + // table lacked it, leaving the connected-first sort order as the sole + // (easy-to-miss) signal for which row is the device's *current* port — + // a distinction that matters because this lookup feeds the destructive + // `ports cycle`. Two distinctly-named single-port devices (rather than + // one device with two ports) so each rendered row can be identified by + // its device name, independent of the connected-first sort this test + // does not itself re-verify (that is `ports_find_by_mac_sorts_connected_first_and_skips_client_lookup`'s job). + #[tokio::test] + async fn ports_find_text_output_shows_connected_column() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [ + {"mac": "aa:bb:cc:dd:ee:01", "name": "SwitchConnected", + "port_table": [ + {"port_idx": 7, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": true}} + ]}, + {"mac": "aa:bb:cc:dd:ee:02", "name": "SwitchStale", + "port_table": [ + {"port_idx": 2, "last_connection": {"mac": "aa:bb:cc:dd:ee:10", "connected": false}} + ]} + ] + }))) + .mount(&server) + .await; + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_unifi")) + .args([ + "--host", + &server.uri(), + "--api-key", + "test-key", + "ports", + "find", + "aa:bb:cc:dd:ee:10", + "-o", + "text", + ]) + .output() + .expect("failed to run the unifi binary"); + assert!( + output.status.success(), + "ports find failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); + + let header = stdout + .lines() + .find(|l| l.contains("Device")) + .expect("text output must have a header row containing \"Device\""); + assert!( + header.contains("Connected"), + "find's header must carry a Connected column: {header}" + ); + + let connected_row = stdout + .lines() + .find(|l| l.contains("SwitchConnected")) + .expect("expected a row for the connected device"); + let stale_row = stdout + .lines() + .find(|l| l.contains("SwitchStale")) + .expect("expected a row for the stale device"); + + assert!( + connected_row.trim_end().ends_with("yes"), + "the connected row's Connected column must render \"yes\": {connected_row}" + ); + assert!( + stale_row.trim_end().ends_with('-'), + "the stale row's Connected column must render \"-\": {stale_row}" + ); + } + + // --- Ports cycle (mutation orchestration) --- + // + // `power_cycle_port_sends_correct_command` (in `client_api` above) only + // covers the client method's endpoint and body. Nothing exercised the + // orchestration in `commands::ports::cycle` that decides *whether* to call + // it at all — and that orchestration is the only place in this CLI that + // cuts power to physical hardware. These four cases pin down the guard-rail + // ordering (find_port -> check_cyclable -> confirm -> POST) as a tested + // property rather than a code-reading exercise: the `.expect(0)` mounts on + // decline/conflict/not-found assert, via wiremock's mount-drop + // verification, that no HTTP write happens on any of the three + // non-cycling paths. + + #[tokio::test] + async fn ports_cycle_confirmed_cycles_the_port() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE", + "port_table": [{ + "port_idx": 5, "port_poe": true, "poe_mode": "auto", + "poe_enable": true + }] + }] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/proxy/network/api/s/default/cmd/devmgr")) + .and(body_json(serde_json::json!({ + "cmd": "power-cycle", + "mac": "9c:05:d6:bc:06:43", + "port_idx": 5 + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [] + }))) + .expect(1) + .mount(&server) + .await; + + let client = mock_client(&server).await; + let outcome = + unifi_cli::commands::ports::cycle(&client, "9c:05:d6:bc:06:43", 5, out_table(), |_| { + Ok(true) + }) + .await + .unwrap(); + assert_eq!(outcome, unifi_cli::commands::ports::CycleOutcome::Cycled); + } + + #[tokio::test] + async fn ports_cycle_declined_never_posts() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE", + "port_table": [{ + "port_idx": 5, "port_poe": true, "poe_mode": "auto", + "poe_enable": true + }] + }] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/proxy/network/api/s/default/cmd/devmgr")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [] + }))) + .expect(0) + .mount(&server) + .await; + + let client = mock_client(&server).await; + let outcome = + unifi_cli::commands::ports::cycle(&client, "9c:05:d6:bc:06:43", 5, out_table(), |_| { + Ok(false) + }) + .await + .unwrap(); + assert_eq!(outcome, unifi_cli::commands::ports::CycleOutcome::Declined); + } + + #[tokio::test] + async fn ports_cycle_non_poe_port_is_conflict_and_never_posts() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "9c:05:d6:bc:06:43", "name": "USW-Lite-8", + "port_table": [{"port_idx": 9, "port_poe": false}] + }] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/proxy/network/api/s/default/cmd/devmgr")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [] + }))) + .expect(0) + .mount(&server) + .await; + + let client = mock_client(&server).await; + // The confirm callback returns `Ok(true)` deliberately: `check_cyclable` + // must reject before `confirm` is ever consulted, so a callback that + // would approve proves nothing about ordering unless it's wired to run + // second. + let err = + unifi_cli::commands::ports::cycle(&client, "9c:05:d6:bc:06:43", 9, out_table(), |_| { + Ok(true) + }) + .await + .unwrap_err(); + let api_err = err + .downcast_ref::() + .unwrap_or_else(|| { + panic!("cycle must reject a non-PoE port as an ApiError, got {err}") + }); + assert!( + matches!(api_err, unifi_cli::api::ApiError::Conflict(_)), + "expected Conflict, got {api_err:?}" + ); + } + + // Mirrors `ports_cycle_non_poe_port_is_conflict_and_never_posts` for the + // third guard rail: a port that is PoE-capable and not administratively + // off, but that the controller reports as not currently delivering power + // (poe_enable: false). This is the fixture from the live UCG-Max finding + // that motivated the guard — see `check_cyclable` in + // `src/commands/ports.rs` for what was actually observed. + #[tokio::test] + async fn ports_cycle_poe_enable_false_is_conflict_and_never_posts() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "aa:bb:cc:dd:ee:fe", "name": "USW Lite 8 PoE", + "port_table": [{ + "port_idx": 4, "port_poe": true, "poe_mode": "auto", + "poe_enable": false, "poe_power": 0.0, "up": false + }] + }] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/proxy/network/api/s/default/cmd/devmgr")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [] + }))) + .expect(0) + .mount(&server) + .await; + + let client = mock_client(&server).await; + // `Ok(true)` deliberately, same reasoning as the non-PoE case above: + // proves `check_cyclable` rejects before `confirm` is ever consulted. + let err = + unifi_cli::commands::ports::cycle(&client, "aa:bb:cc:dd:ee:fe", 4, out_table(), |_| { + Ok(true) + }) + .await + .unwrap_err(); + let api_err = err + .downcast_ref::() + .unwrap_or_else(|| { + panic!("cycle must reject a poe_enable=false port as an ApiError, got {err}") + }); + assert!( + matches!(api_err, unifi_cli::api::ApiError::Conflict(_)), + "expected Conflict, got {api_err:?}" + ); + } + + #[tokio::test] + async fn ports_cycle_missing_port_is_not_found_and_never_posts() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proxy/network/api/s/default/stat/device")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [{ + "mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE", + "port_table": [{"port_idx": 1, "port_poe": true}] + }] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/proxy/network/api/s/default/cmd/devmgr")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "meta": {"rc": "ok"}, + "data": [] + }))) + .expect(0) + .mount(&server) + .await; + + let client = mock_client(&server).await; + let err = unifi_cli::commands::ports::cycle( + &client, + "9c:05:d6:bc:06:43", + 99, + out_table(), + |_| Ok(true), + ) + .await + .unwrap_err(); + let api_err = err + .downcast_ref::() + .unwrap_or_else(|| { + panic!("cycle must report a missing port as an ApiError, got {err}") + }); + assert!( + matches!(api_err, unifi_cli::api::ApiError::NotFound(_)), + "expected NotFound, got {api_err:?}" + ); + } + #[tokio::test] async fn list_events_returns_stat_event_records() { let server = MockServer::start().await;