unifi-cli 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/CHANGELOG.md +12 -0
  2. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/Cargo.lock +1 -1
  3. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/Cargo.toml +1 -1
  4. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/PKG-INFO +43 -14
  5. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/README.md +42 -13
  6. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/api/client.rs +2 -2
  7. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/api/tests.rs +1 -1
  8. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/ports.rs +87 -42
  9. unifi_cli-0.3.2/src/lib.rs +25 -0
  10. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/main.rs +165 -16
  11. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/output.rs +54 -0
  12. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/schema.rs +27 -3
  13. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/tests/cli_contract.rs +80 -10
  14. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/tests/mock_server.rs +147 -20
  15. unifi_cli-0.3.1/src/lib.rs +0 -5
  16. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/.github/workflows/ci.yml +0 -0
  17. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/.github/workflows/release.yml +0 -0
  18. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/.gitignore +0 -0
  19. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/LICENSE +0 -0
  20. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/Makefile +0 -0
  21. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/prek.toml +0 -0
  22. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/pyproject.toml +0 -0
  23. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/api/mod.rs +0 -0
  24. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/api/types.rs +0 -0
  25. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/clients.rs +0 -0
  26. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/devices.rs +0 -0
  27. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/events.rs +0 -0
  28. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/mod.rs +0 -0
  29. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/networks.rs +0 -0
  30. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/protect.rs +0 -0
  31. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/system.rs +0 -0
  32. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/fields.rs +0 -0
  33. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/tui.rs +0 -0
  34. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/tests/fixtures/clispec-v0.2.json +0 -0
  35. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/tests/spec_compliance.rs +0 -0
  36. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/unifi_cli/__init__.py +0 -0
  37. {unifi_cli-0.3.1 → unifi_cli-0.3.2}/unifi_cli/__main__.py +0 -0
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.3.2](https://github.com/rvben/unifi-cli/compare/v0.3.1...v0.3.2) - 2026-08-10
6
+
7
+ ### Fixed
8
+
9
+ - **config**: write the config atomically on Windows too ([0c2977e](https://github.com/rvben/unifi-cli/commit/0c2977e238ccb09ca2949560568c4f8b8fd528db))
10
+ - **config**: replace the config file atomically instead of writing in place ([9ba19fb](https://github.com/rvben/unifi-cli/commit/9ba19fbde8b498488f43b1af1ab1d2771189d672))
11
+ - **output**: keep 408 and 429 retryable instead of permanent client errors ([1068bae](https://github.com/rvben/unifi-cli/commit/1068baed75ce232028fec0277c1ca38324723a35))
12
+ - **ports**: keep the attachment flag tri-state instead of reading absence as gone ([de2e770](https://github.com/rvben/unifi-cli/commit/de2e770d30cbc3cba64cf5b06640549a9655c776))
13
+ - **config**: create the credentials file with owner-only permissions ([8b7789d](https://github.com/rvben/unifi-cli/commit/8b7789d2d87edc5819e950573130000370c6939f))
14
+ - **schema**: publish an error and confirmation contract that matches runtime ([5176ccf](https://github.com/rvben/unifi-cli/commit/5176ccfefeb08df4fecd0d7a6d83882f5e06c57f))
15
+ - **ports**: report a stale last connection as unattached ([e80cda5](https://github.com/rvben/unifi-cli/commit/e80cda542fce27db385fc43bd69093c265ead464))
16
+
5
17
  ## [0.3.1](https://github.com/rvben/unifi-cli/compare/v0.3.0...v0.3.1) - 2026-08-10
6
18
 
7
19
  ### Added
@@ -2786,7 +2786,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
2786
2786
 
2787
2787
  [[package]]
2788
2788
  name = "unifi-cli"
2789
- version = "0.3.1"
2789
+ version = "0.3.2"
2790
2790
  dependencies = [
2791
2791
  "clap",
2792
2792
  "clap_complete",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "unifi-cli"
3
- version = "0.3.1"
3
+ version = "0.3.2"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "CLI for UniFi Network controller"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unifi-cli
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: System Administrators
@@ -79,7 +79,17 @@ export UNIFI_ACCEPT_INVALID_CERTS=true
79
79
 
80
80
  ### Config file
81
81
 
82
- `~/.config/unifi/config.toml`:
82
+ Linux: `~/.config/unifi/config.toml`.
83
+ macOS: `~/Library/Application Support/unifi/config.toml`.
84
+ Windows: `%APPDATA%\unifi\config.toml`.
85
+
86
+ `unifi config init` writes a new file and renames it over any existing config
87
+ instead of writing in place, so a failed write leaves the previous config
88
+ intact. On Linux and macOS the new file is created with mode 0600, since it
89
+ holds an API key and optionally a Protect password. Because the credentials go
90
+ straight into a file that is already 0600, they are never readable by another
91
+ local account, not even for the moment between the write and a chmod. On
92
+ Windows the file inherits its directory's ACL.
83
93
 
84
94
  ```toml
85
95
  host = "https://unifi.example.com"
@@ -131,6 +141,11 @@ act. On a terminal you get a yes/no question naming the target; declining exits
131
141
  terminal there is nobody to ask, so they refuse with the same error unless you
132
142
  pass `--yes`, which skips the question everywhere.
133
143
 
144
+ `unifi schema` marks exactly these commands `confirmation_required: true`, so a
145
+ caller can tell them apart from mutating commands that act immediately
146
+ (`devices locate`, `clients set-fixed-ip`, `protect rtsps create`) without
147
+ hardcoding the list.
148
+
134
149
  ## TUI dashboard
135
150
 
136
151
  ```bash
@@ -211,9 +226,17 @@ A device that has moved between switch ports appears once per port it has
211
226
  ever used, with a `connected` field distinguishing its current port from
212
227
  stale history.
213
228
 
214
- `ports show` exposes PoE telemetry the CLI previously discarded:
215
- `poe_mode`, `poe_class`, `poe_voltage`, `poe_current`, `poe_good`, and the
216
- MAC of the attached device (`attached_mac`).
229
+ `ports show` exposes the port's PoE telemetry: `poe_mode`, `poe_class`,
230
+ `poe_voltage`, `poe_current`, `poe_good`, and the MAC of the attached device
231
+ (`attached_mac`). The controller keeps a port's last connection record after
232
+ the device is unplugged, so `attached_mac` is set only when the controller
233
+ affirms the record is live. The MAC is still reported as
234
+ `attached_last_seen_mac` and the controller's own flag as `attached_connected`
235
+ (true, false, or null when the firmware does not report it), so a caller can
236
+ tell "gone" from "not reported". The text output renders the three cases as
237
+ `aa:bb:cc:dd:ee:ff`, `- (last seen aa:bb:cc:dd:ee:ff)` and
238
+ `unknown (last seen aa:bb:cc:dd:ee:ff)`. Nothing that has been unplugged is
239
+ ever presented as currently attached.
217
240
 
218
241
  `ports cycle` is destructive. On a terminal it shows what is about to lose
219
242
  power and asks for confirmation; when piped it requires `--yes` and
@@ -326,15 +349,21 @@ unifi schema # Dumps all commands, arguments, output fields as JSON
326
349
 
327
350
  ### Distinct exit codes
328
351
 
329
- | Code | Meaning |
330
- |------|---------|
331
- | 0 | Success |
332
- | 1 | General error |
333
- | 2 | Configuration error |
334
- | 3 | Authentication error (401/403) |
335
- | 4 | Not found (404) |
336
- | 5 | API error (server error) |
337
- | 6 | Conflict (ambiguous match or failed precondition) |
352
+ | Code | `kind` | Meaning |
353
+ |------|--------|---------|
354
+ | 0 | - | Success |
355
+ | 1 | `general_error` | General error (including transport failures) |
356
+ | 2 | `config_error` | Configuration or usage error |
357
+ | 2 | `confirmation_required` | A destructive command ran without `--yes` and without a TTY |
358
+ | 3 | `auth_error` | Authentication error (401/403) |
359
+ | 4 | `not_found` | Not found (404) |
360
+ | 5 | `client_error` | The controller rejected the request itself (4xx other than 401/403/404/408/429); retrying it unchanged cannot help |
361
+ | 5 | `retry_later` | The controller invited a retry (429 rate limited, 408 request timeout); back off and send the same request again |
362
+ | 5 | `api_error` | The controller failed to serve the request (5xx); may be transient |
363
+ | 6 | `conflict` | The request cannot succeed against the resource's current state, refused locally before any API call |
364
+
365
+ `unifi schema` publishes the same table under `errors`, with a `retryable` flag
366
+ per kind, so an agent can branch on it without parsing prose.
338
367
 
339
368
  ## Development
340
369
 
@@ -58,7 +58,17 @@ export UNIFI_ACCEPT_INVALID_CERTS=true
58
58
 
59
59
  ### Config file
60
60
 
61
- `~/.config/unifi/config.toml`:
61
+ Linux: `~/.config/unifi/config.toml`.
62
+ macOS: `~/Library/Application Support/unifi/config.toml`.
63
+ Windows: `%APPDATA%\unifi\config.toml`.
64
+
65
+ `unifi config init` writes a new file and renames it over any existing config
66
+ instead of writing in place, so a failed write leaves the previous config
67
+ intact. On Linux and macOS the new file is created with mode 0600, since it
68
+ holds an API key and optionally a Protect password. Because the credentials go
69
+ straight into a file that is already 0600, they are never readable by another
70
+ local account, not even for the moment between the write and a chmod. On
71
+ Windows the file inherits its directory's ACL.
62
72
 
63
73
  ```toml
64
74
  host = "https://unifi.example.com"
@@ -110,6 +120,11 @@ act. On a terminal you get a yes/no question naming the target; declining exits
110
120
  terminal there is nobody to ask, so they refuse with the same error unless you
111
121
  pass `--yes`, which skips the question everywhere.
112
122
 
123
+ `unifi schema` marks exactly these commands `confirmation_required: true`, so a
124
+ caller can tell them apart from mutating commands that act immediately
125
+ (`devices locate`, `clients set-fixed-ip`, `protect rtsps create`) without
126
+ hardcoding the list.
127
+
113
128
  ## TUI dashboard
114
129
 
115
130
  ```bash
@@ -190,9 +205,17 @@ A device that has moved between switch ports appears once per port it has
190
205
  ever used, with a `connected` field distinguishing its current port from
191
206
  stale history.
192
207
 
193
- `ports show` exposes PoE telemetry the CLI previously discarded:
194
- `poe_mode`, `poe_class`, `poe_voltage`, `poe_current`, `poe_good`, and the
195
- MAC of the attached device (`attached_mac`).
208
+ `ports show` exposes the port's PoE telemetry: `poe_mode`, `poe_class`,
209
+ `poe_voltage`, `poe_current`, `poe_good`, and the MAC of the attached device
210
+ (`attached_mac`). The controller keeps a port's last connection record after
211
+ the device is unplugged, so `attached_mac` is set only when the controller
212
+ affirms the record is live. The MAC is still reported as
213
+ `attached_last_seen_mac` and the controller's own flag as `attached_connected`
214
+ (true, false, or null when the firmware does not report it), so a caller can
215
+ tell "gone" from "not reported". The text output renders the three cases as
216
+ `aa:bb:cc:dd:ee:ff`, `- (last seen aa:bb:cc:dd:ee:ff)` and
217
+ `unknown (last seen aa:bb:cc:dd:ee:ff)`. Nothing that has been unplugged is
218
+ ever presented as currently attached.
196
219
 
197
220
  `ports cycle` is destructive. On a terminal it shows what is about to lose
198
221
  power and asks for confirmation; when piped it requires `--yes` and
@@ -305,15 +328,21 @@ unifi schema # Dumps all commands, arguments, output fields as JSON
305
328
 
306
329
  ### Distinct exit codes
307
330
 
308
- | Code | Meaning |
309
- |------|---------|
310
- | 0 | Success |
311
- | 1 | General error |
312
- | 2 | Configuration error |
313
- | 3 | Authentication error (401/403) |
314
- | 4 | Not found (404) |
315
- | 5 | API error (server error) |
316
- | 6 | Conflict (ambiguous match or failed precondition) |
331
+ | Code | `kind` | Meaning |
332
+ |------|--------|---------|
333
+ | 0 | - | Success |
334
+ | 1 | `general_error` | General error (including transport failures) |
335
+ | 2 | `config_error` | Configuration or usage error |
336
+ | 2 | `confirmation_required` | A destructive command ran without `--yes` and without a TTY |
337
+ | 3 | `auth_error` | Authentication error (401/403) |
338
+ | 4 | `not_found` | Not found (404) |
339
+ | 5 | `client_error` | The controller rejected the request itself (4xx other than 401/403/404/408/429); retrying it unchanged cannot help |
340
+ | 5 | `retry_later` | The controller invited a retry (429 rate limited, 408 request timeout); back off and send the same request again |
341
+ | 5 | `api_error` | The controller failed to serve the request (5xx); may be transient |
342
+ | 6 | `conflict` | The request cannot succeed against the resource's current state, refused locally before any API call |
343
+
344
+ `unifi schema` publishes the same table under `errors`, with a `retryable` flag
345
+ per kind, so an agent can branch on it without parsing prose.
317
346
 
318
347
  ## Development
319
348
 
@@ -105,7 +105,7 @@ impl UnifiClient {
105
105
  .get_integration("/proxy/network/integration/v1/sites")
106
106
  .await?;
107
107
  let site = resp.data.into_iter().next().ok_or_else(|| {
108
- ApiError::Other("No sites found — check that the API key has site access".into())
108
+ ApiError::Other("No sites found. Check that the API key has site access".into())
109
109
  })?;
110
110
  self.site_id = Some(site.id);
111
111
  }
@@ -577,7 +577,7 @@ impl ProtectSession {
577
577
  ) -> Result<Self, ApiError> {
578
578
  let base_url = normalize_base_url(host)?;
579
579
 
580
- // Don't use cookie_provider — the `partitioned` cookie attribute
580
+ // Don't use cookie_provider: the `partitioned` cookie attribute
581
581
  // isn't handled by reqwest's jar. We extract the token manually.
582
582
  let http = reqwest::Client::builder()
583
583
  .danger_accept_invalid_certs(options.accept_invalid_certs)
@@ -773,7 +773,7 @@ fn client_clean_name_falls_back_to_hostname() {
773
773
 
774
774
  #[test]
775
775
  fn client_clean_name_hyphenated_not_stripped() {
776
- // "host-ee:ff" has no space before the hex — it's part of the hostname, not a MAC suffix
776
+ // "host-ee:ff" has no space before the hex, so it's part of the hostname, not a MAC suffix
777
777
  let json = r#"{"hostname": "host-ee:ff", "macAddress": "aa:bb:cc:dd:ee:ff"}"#;
778
778
  let client: Client = serde_json::from_str(json).unwrap();
779
779
  assert_eq!(client.clean_name(), "host-ee:ff");
@@ -124,6 +124,52 @@ fn speed_cell(p: &PortEntry) -> String {
124
124
  }
125
125
  }
126
126
 
127
+ /// What a port's `last_connection` record says, split into what is attached
128
+ /// *now* and what the record names regardless of state.
129
+ struct Attachment {
130
+ /// The MAC only when the controller affirms the record is live.
131
+ attached_mac: Option<String>,
132
+ /// The MAC whenever the record names one, live or not.
133
+ last_seen_mac: Option<String>,
134
+ /// The controller's own `connected` flag, kept tri-state: `None` means the
135
+ /// firmware did not report it, which is not the same fact as `false`.
136
+ connected: Option<bool>,
137
+ }
138
+
139
+ /// Read a port's `last_connection` record.
140
+ ///
141
+ /// The controller keeps the record after the device is unplugged, marking it
142
+ /// `connected: false`. A stale record therefore names a device that may have
143
+ /// been gone for months, and reporting it as the attachment would tell an
144
+ /// operator a port is in use moments before they cut its power. So
145
+ /// `attached_mac` is set only when the controller affirms `connected: true`;
146
+ /// a firmware that omits the flag says nothing about the present, which is
147
+ /// not grounds to claim an attachment either. Nothing is lost: the MAC is
148
+ /// always available as `last_seen_mac` and the raw flag as `connected`, so a
149
+ /// caller can tell "gone" from "not reported". `cycle_summary` applies the
150
+ /// same rule to the confirmation prompt.
151
+ fn attachment(p: &PortEntry) -> Attachment {
152
+ let lc = match p.last_connection.as_ref() {
153
+ Some(lc) => lc,
154
+ None => {
155
+ return Attachment {
156
+ attached_mac: None,
157
+ last_seen_mac: None,
158
+ connected: None,
159
+ };
160
+ }
161
+ };
162
+ let mac = lc.mac.as_deref().map(format_mac);
163
+ Attachment {
164
+ attached_mac: match lc.connected {
165
+ Some(true) => mac.clone(),
166
+ _ => None,
167
+ },
168
+ last_seen_mac: mac,
169
+ connected: lc.connected,
170
+ }
171
+ }
172
+
127
173
  /// Right-pad a table cell to `width` visible columns, deriving the padding
128
174
  /// from `plain`'s length rather than `rendered`'s.
129
175
  ///
@@ -134,23 +180,23 @@ fn speed_cell(p: &PortEntry) -> String {
134
180
  /// go through this instead of a `{:<N}` specifier.
135
181
  ///
136
182
  /// `rendered` is `plain` itself on the uncoloured path (see call sites), so
137
- /// that path pads identically to the `{:<width$}` it replaces — this must
183
+ /// that path pads identically to the `{:<width$}` it replaces, and this must
138
184
  /// stay true, since existing tests assert on uncoloured output byte-for-byte.
139
185
  fn pad_visible(rendered: &str, plain: &str, width: usize) -> String {
140
186
  let pad = width.saturating_sub(plain.len());
141
187
  format!("{rendered}{}", " ".repeat(pad))
142
188
  }
143
189
 
144
- /// "port" for exactly one row, "ports" otherwise — the row-count trailer's
190
+ /// "port" for exactly one row, "ports" otherwise: the row-count trailer's
145
191
  /// singular/plural noun.
146
192
  fn port_noun(count: usize) -> &'static str {
147
193
  if count == 1 { "port" } else { "ports" }
148
194
  }
149
195
 
150
196
  /// Device column width, in characters. Callers that paginate must compute
151
- /// this from the full result set, not just the page handed to `render_text`
152
- /// — otherwise two `--offset` pages of the same query can render the column
153
- /// at different widths.
197
+ /// this from the full result set, not just the page handed to `render_text`.
198
+ /// Otherwise two `--offset` pages of the same query can render the column at
199
+ /// different widths.
154
200
  pub fn device_col_width(rows: &[&PortRow]) -> usize {
155
201
  rows.iter()
156
202
  .map(|r| r.device_name.len())
@@ -537,11 +583,11 @@ pub async fn show(
537
583
  let device = client.get_device_ports(mac).await?;
538
584
  let p = find_port(&device, port_idx)?;
539
585
  let (device_mac, device_name) = device_identity(&device, "-");
540
- let attached_mac = p
541
- .last_connection
542
- .as_ref()
543
- .and_then(|lc| lc.mac.as_deref())
544
- .map(format_mac);
586
+ let Attachment {
587
+ attached_mac,
588
+ last_seen_mac,
589
+ connected: attached_connected,
590
+ } = attachment(p);
545
591
 
546
592
  if out.is_json() {
547
593
  out.print_data(&serde_json::to_string_pretty(&serde_json::json!({
@@ -566,6 +612,8 @@ pub async fn show(
566
612
  "poe_current": p.poe_current,
567
613
  "poe_good": p.poe_good,
568
614
  "attached_mac": attached_mac,
615
+ "attached_last_seen_mac": last_seen_mac,
616
+ "attached_connected": attached_connected,
569
617
  "tx_bytes": p.tx_bytes,
570
618
  "rx_bytes": p.rx_bytes,
571
619
  "tx_errors": p.tx_errors,
@@ -631,11 +679,17 @@ pub async fn show(
631
679
  println!(" {} {c:.2} mA", label("Current: "));
632
680
  }
633
681
  }
634
- println!(
635
- " {} {}",
636
- label("Attached: "),
637
- attached_mac.as_deref().unwrap_or("-")
638
- );
682
+ // Only an affirmed connection prints as a bare MAC, so the line can never
683
+ // be read as "this device is plugged in right now" unless it is. A stale
684
+ // record and an unreported one are both qualified, and differently: the
685
+ // first knows the device is gone, the second knows nothing.
686
+ let attached_cell = match (&attached_mac, &last_seen_mac, attached_connected) {
687
+ (Some(mac), _, _) => mac.clone(),
688
+ (None, Some(seen), Some(false)) => format!("- (last seen {seen})"),
689
+ (None, Some(seen), _) => format!("unknown (last seen {seen})"),
690
+ (None, None, _) => "-".to_string(),
691
+ };
692
+ println!(" {} {}", label("Attached: "), attached_cell);
639
693
  Ok(())
640
694
  }
641
695
 
@@ -666,16 +720,13 @@ pub fn check_cyclable(port: &PortEntry, device_mac: &str) -> Result<(), ApiError
666
720
  "PoE is administratively disabled on port {idx} of {mac} (poe_mode=off)"
667
721
  )));
668
722
  }
669
- // Observed against a live UCG-Max controller: a port with port_poe: true,
670
- // poe_mode: "auto", and poe_enable: false rejected `power-cycle` with
671
- // HTTP 400 api.err.InvalidTargetPort. poe_enable was the only attribute
672
- // that differed from ports that do have power to cycle, so it is used
673
- // here as the guard — that inference has not been confirmed by a
674
- // successful cycle against a poe_enable: true port, since doing so would
675
- // require firing at a port with a live device attached. Surfacing this
676
- // locally as `conflict` avoids the alternative: the controller's 400
677
- // otherwise falls through to `api_error` (see error_for_status), which
678
- // src/schema.rs advertises as retryable even though retrying cannot help.
723
+ // `poe_enable` is the controller's own precondition, confirmed in both
724
+ // directions against a live UCG-Max: a port with port_poe: true,
725
+ // poe_mode: "auto" and poe_enable: false rejects `power-cycle` with HTTP
726
+ // 400 api.err.InvalidTargetPort, while the same command against a port
727
+ // with poe_enable: true succeeds and reboots the attached device.
728
+ // Checking it here turns that 400 into a local `conflict` naming the
729
+ // reason, instead of an opaque status from the controller.
679
730
  if !port.poe_enable {
680
731
  return Err(ApiError::Conflict(format!(
681
732
  "Port {idx} on {mac} is not currently delivering PoE (poe_enable=false), \
@@ -686,7 +737,7 @@ pub fn check_cyclable(port: &PortEntry, device_mac: &str) -> Result<(), ApiError
686
737
  }
687
738
 
688
739
  /// Whether the cycle actually happened. `Declined` is not an error at this
689
- /// layer — the caller decides how to report a refused confirmation.
740
+ /// layer; the caller decides how to report a refused confirmation.
690
741
  #[derive(Debug, PartialEq, Eq)]
691
742
  pub enum CycleOutcome {
692
743
  Cycled,
@@ -925,12 +976,10 @@ mod tests {
925
976
  // `pad_visible` is what makes coloured Link/Connected cells line up with
926
977
  // the header at a TTY. `use_color()` reads `stdout().is_terminal()`
927
978
  // directly with no override, so a unit test cannot force colour on for
928
- // `render_rows` itself without adding a flag/env override the task this
929
- // was written for explicitly ruled out ("no new flags"). Testing the
930
- // padding helper directly, with a hand-built ANSI-escaped string standing
931
- // in for what `owo_colors` would emit, exercises the actual defect
932
- // (padding computed from byte length instead of visible width) without
933
- // needing a real TTY.
979
+ // `render_rows` itself. Testing the padding helper directly, with a
980
+ // hand-built ANSI-escaped string standing in for what `owo_colors` would
981
+ // emit, exercises the defect it guards against (padding computed from
982
+ // byte length instead of visible width) without needing a real TTY.
934
983
  #[test]
935
984
  fn pad_visible_pads_by_plain_width_not_escaped_byte_length() {
936
985
  // The real thing `render_rows` hands `pad_visible` on the coloured
@@ -962,7 +1011,7 @@ mod tests {
962
1011
  fn pad_visible_matches_the_uncoloured_output_it_replaces() {
963
1012
  // On the uncoloured path every call site passes `rendered == plain`,
964
1013
  // so this must reproduce exactly what the old `{:<width$}` specifier
965
- // produced — the uncoloured path must not change at all.
1014
+ // produced: the uncoloured path must not change at all.
966
1015
  assert_eq!(pad_visible("up", "up", 6), format!("{:<6}", "up"));
967
1016
  assert_eq!(pad_visible("down", "down", 6), format!("{:<6}", "down"));
968
1017
  assert_eq!(pad_visible("yes", "yes", 9), format!("{:<9}", "yes"));
@@ -1176,9 +1225,8 @@ mod tests {
1176
1225
  );
1177
1226
  }
1178
1227
 
1179
- // `_id` is required by `LegacyClient` (every other fixture in this codebase
1180
- // supplies it); the plan's literal fixture omitted it, so it is added here
1181
- // to make the fixture actually deserialize.
1228
+ // `_id` is required by `LegacyClient`, so every record here supplies one
1229
+ // or the fixture will not deserialize.
1182
1230
  fn clients_fixture() -> Vec<crate::api::LegacyClient> {
1183
1231
  serde_json::from_value(serde_json::json!([
1184
1232
  {"_id": "1", "mac": "aa:bb:cc:dd:ee:10", "name": "garage-pi", "ip": "10.0.0.5"},
@@ -1211,12 +1259,9 @@ mod tests {
1211
1259
  );
1212
1260
  }
1213
1261
 
1214
- // `resolve_candidates` no longer decides ambiguity by itself — a name
1215
- // matching multiple client records is not an error here, since `find`
1216
- // only calls it a conflict once it also knows more than one candidate
1217
- // sits on a switch port. This replaces the old
1218
- // `resolve_identifier_ambiguous_name_is_conflict`, which asserted the
1219
- // opposite (that a name-match count alone was a `Conflict`).
1262
+ // A name matching multiple client records is not an error here.
1263
+ // `resolve_candidates` returns every candidate; `find` calls it a conflict
1264
+ // only once it also knows more than one of them sits on a switch port.
1220
1265
  #[test]
1221
1266
  fn resolve_candidates_returns_every_name_match_without_erroring() {
1222
1267
  let c = clients_fixture();
@@ -0,0 +1,25 @@
1
+ pub mod api;
2
+ pub mod commands;
3
+ pub mod fields;
4
+ pub mod output;
5
+ pub mod tui;
6
+
7
+ /// Commands that refuse to act without confirmation: they ask on a terminal,
8
+ /// and exit `confirmation_required` when stdin is not one unless `--yes` is
9
+ /// passed.
10
+ ///
11
+ /// One list, three consumers: `unifi schema` publishes it so an agent can tell
12
+ /// which invocations need `--yes` before it runs them, `main` gates these
13
+ /// commands on it, and the contract tests drive every entry to prove the gate
14
+ /// is really there. A mutating command is not automatically on this list;
15
+ /// `devices locate` only blinks an LED, and `clients set-fixed-ip` and
16
+ /// `protect rtsps create` add configuration rather than taking something away.
17
+ pub const CONFIRMATION_GATED_COMMANDS: &[&str] = &[
18
+ "clients block",
19
+ "clients unblock",
20
+ "clients kick",
21
+ "devices restart",
22
+ "devices upgrade",
23
+ "ports cycle",
24
+ "protect rtsps delete",
25
+ ];
@@ -808,15 +808,7 @@ fn run_init_with_io(
808
808
  }
809
809
  let toml_str = toml::to_string_pretty(&config)
810
810
  .map_err(|e| InitError(format!("Failed to serialize config: {e}")))?;
811
- std::fs::write(config_path, &toml_str)
812
- .map_err(|e| InitError(format!("Failed to write config: {e}")))?;
813
-
814
- // Restrict config file permissions (contains secrets)
815
- #[cfg(unix)]
816
- {
817
- use std::os::unix::fs::PermissionsExt;
818
- let _ = std::fs::set_permissions(config_path, std::fs::Permissions::from_mode(0o600));
819
- }
811
+ write_config_file(config_path, &toml_str)?;
820
812
 
821
813
  Ok(InitOutcome::Saved {
822
814
  profile: profile_name,
@@ -855,14 +847,85 @@ fn enable_accept_invalid_certs_in_config(
855
847
 
856
848
  let toml_str = toml::to_string_pretty(&config)
857
849
  .map_err(|e| InitError(format!("Failed to serialize config: {e}")))?;
858
- std::fs::write(config_path, &toml_str)
859
- .map_err(|e| InitError(format!("Failed to write config: {e}")))?;
860
- #[cfg(unix)]
861
- {
862
- use std::os::unix::fs::PermissionsExt;
863
- let _ = std::fs::set_permissions(config_path, std::fs::Permissions::from_mode(0o600));
850
+ write_config_file(config_path, &toml_str)
851
+ }
852
+
853
+ /// Write the config file so that only its owner can read it.
854
+ ///
855
+ /// The file holds an API key and optionally a password, so it is never written
856
+ /// in place. `OpenOptions::mode` applies only to a file the call creates, so
857
+ /// opening an existing config would fill a possibly world-readable file with
858
+ /// fresh credentials and only tighten it afterwards, leaving a window in which
859
+ /// any local account can read the key (and leaving it readable for good if the
860
+ /// chmod fails). Instead the content goes into a new 0600 file beside the
861
+ /// destination and is renamed over it. The rename is atomic: the credentials
862
+ /// exist only inside a 0600 file, and a concurrent reader sees either the old
863
+ /// config or the new one, never a half-written one.
864
+ ///
865
+ /// Off unix the write goes through the same temp file and rename, so the config
866
+ /// is never left half-written, but the mode is not set: the file takes the
867
+ /// inherited ACL of its directory.
868
+ fn write_config_file(config_path: &std::path::Path, toml_str: &str) -> Result<(), InitError> {
869
+ use std::io::Write;
870
+
871
+ // The temp file must share a directory with the destination, since rename
872
+ // does not cross filesystems.
873
+ let dir = match config_path.parent() {
874
+ Some(p) if !p.as_os_str().is_empty() => p,
875
+ _ => std::path::Path::new("."),
876
+ };
877
+ let name = config_path
878
+ .file_name()
879
+ .map(|n| n.to_string_lossy().into_owned())
880
+ .unwrap_or_else(|| "config.toml".to_string());
881
+ // Unique per process and per call, so two writers never pick the same name.
882
+ static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
883
+ let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
884
+ let tmp_path = dir.join(format!(".{name}.{}.{seq}.tmp", std::process::id()));
885
+
886
+ let open_tmp = || {
887
+ let mut opts = std::fs::OpenOptions::new();
888
+ opts.write(true).create_new(true);
889
+ #[cfg(unix)]
890
+ {
891
+ use std::os::unix::fs::OpenOptionsExt;
892
+ opts.mode(0o600);
893
+ }
894
+ opts.open(&tmp_path)
895
+ };
896
+ let opened = match open_tmp() {
897
+ // A temp file left behind by a killed run. Reclaim the name;
898
+ // `create_new` still guarantees we write a file we created ourselves.
899
+ Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
900
+ std::fs::remove_file(&tmp_path)
901
+ .map_err(|e| InitError(format!("Failed to write config: {e}")))?;
902
+ open_tmp()
903
+ }
904
+ other => other,
905
+ };
906
+
907
+ let write_tmp = |mut file: std::fs::File| -> std::io::Result<()> {
908
+ #[cfg(unix)]
909
+ {
910
+ // The umask can clear bits from the requested mode, so pin it here.
911
+ // The file is still empty, so no secret has reached the disk yet.
912
+ use std::os::unix::fs::PermissionsExt;
913
+ file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
914
+ }
915
+ file.write_all(toml_str.as_bytes())?;
916
+ file.sync_all()
917
+ };
918
+
919
+ match opened.and_then(write_tmp) {
920
+ Ok(()) => std::fs::rename(&tmp_path, config_path).map_err(|e| {
921
+ let _ = std::fs::remove_file(&tmp_path);
922
+ InitError(format!("Failed to write config: {e}"))
923
+ }),
924
+ Err(e) => {
925
+ let _ = std::fs::remove_file(&tmp_path);
926
+ Err(InitError(format!("Failed to write config: {e}")))
927
+ }
864
928
  }
865
- Ok(())
866
929
  }
867
930
 
868
931
  /// Prompt for a yes/no answer on stderr (where init status is shown) and read
@@ -1847,6 +1910,92 @@ api_key = "work_key"
1847
1910
  assert!(!display.contains("secret"));
1848
1911
  }
1849
1912
 
1913
+ #[cfg(unix)]
1914
+ fn mode_of(path: &std::path::Path) -> u32 {
1915
+ use std::os::unix::fs::PermissionsExt;
1916
+ std::fs::metadata(path).unwrap().permissions().mode() & 0o777
1917
+ }
1918
+
1919
+ #[cfg(unix)]
1920
+ #[test]
1921
+ fn init_writes_credentials_readable_only_by_owner() {
1922
+ let dir = tempfile::tempdir().unwrap();
1923
+ let path = dir.path().join("config.toml");
1924
+ let mut reader = std::io::Cursor::new(b"\nhttps://unifi.local\nmy-api-key\n\ny\n".to_vec());
1925
+ let mut output = Vec::new();
1926
+
1927
+ run_init_with_io(&mut reader, &mut output, &path, false, false).unwrap();
1928
+
1929
+ assert!(
1930
+ std::fs::read_to_string(&path)
1931
+ .unwrap()
1932
+ .contains("my-api-key")
1933
+ );
1934
+ assert_eq!(mode_of(&path), 0o600);
1935
+ }
1936
+
1937
+ #[cfg(unix)]
1938
+ #[test]
1939
+ fn init_tightens_permissions_of_a_preexisting_world_readable_config() {
1940
+ use std::os::unix::fs::PermissionsExt;
1941
+ let dir = tempfile::tempdir().unwrap();
1942
+ let path = dir.path().join("config.toml");
1943
+ std::fs::write(&path, "host = \"https://unifi.local\"\napi_key = \"old\"\n").unwrap();
1944
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1945
+
1946
+ let mut reader = std::io::Cursor::new(b"\nhttps://unifi.local\nnew-key\n\ny\n".to_vec());
1947
+ let mut output = Vec::new();
1948
+ run_init_with_io(&mut reader, &mut output, &path, false, false).unwrap();
1949
+
1950
+ assert_eq!(mode_of(&path), 0o600);
1951
+ }
1952
+
1953
+ #[cfg(unix)]
1954
+ #[test]
1955
+ fn init_keeps_credentials_out_of_a_preexisting_world_readable_file() {
1956
+ use std::os::unix::fs::PermissionsExt;
1957
+ let dir = tempfile::tempdir().unwrap();
1958
+ let path = dir.path().join("config.toml");
1959
+ std::fs::write(&path, "host = \"https://unifi.local\"\napi_key = \"old\"\n").unwrap();
1960
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1961
+ // A second name for the same inode. Writing the new key into the file
1962
+ // that already exists, even for the instant before a chmod, shows up
1963
+ // here; replacing that file by rename cannot.
1964
+ let witness = dir.path().join("witness.toml");
1965
+ std::fs::hard_link(&path, &witness).unwrap();
1966
+
1967
+ let mut reader = std::io::Cursor::new(b"\nhttps://unifi.local\nnew-key\n\ny\n".to_vec());
1968
+ let mut output = Vec::new();
1969
+ run_init_with_io(&mut reader, &mut output, &path, false, false).unwrap();
1970
+
1971
+ let exposed = std::fs::read_to_string(&witness).unwrap();
1972
+ assert!(
1973
+ !exposed.contains("new-key"),
1974
+ "the API key was written into a world-readable file: {exposed}"
1975
+ );
1976
+ assert_eq!(
1977
+ mode_of(&witness),
1978
+ 0o644,
1979
+ "the world-readable file was the one that got written"
1980
+ );
1981
+ assert!(std::fs::read_to_string(&path).unwrap().contains("new-key"));
1982
+ assert_eq!(mode_of(&path), 0o600);
1983
+ }
1984
+
1985
+ #[cfg(unix)]
1986
+ #[test]
1987
+ fn enable_accept_invalid_certs_keeps_the_config_owner_only() {
1988
+ use std::os::unix::fs::PermissionsExt;
1989
+ let dir = tempfile::tempdir().unwrap();
1990
+ let path = dir.path().join("config.toml");
1991
+ std::fs::write(&path, "host = \"https://unifi.local\"\napi_key = \"k\"\n").unwrap();
1992
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
1993
+
1994
+ enable_accept_invalid_certs_in_config(&path, None).unwrap();
1995
+
1996
+ assert_eq!(mode_of(&path), 0o600);
1997
+ }
1998
+
1850
1999
  #[test]
1851
2000
  fn init_persists_explicit_invalid_cert_opt_out() {
1852
2001
  let dir = tempfile::tempdir().unwrap();
@@ -130,6 +130,18 @@ pub fn error_kind_and_code(err: &(dyn std::error::Error + 'static)) -> (&'static
130
130
  match api_err {
131
131
  crate::api::ApiError::Auth(_) => ("auth_error", exit_codes::AUTH_ERROR),
132
132
  crate::api::ApiError::NotFound(_) => ("not_found", exit_codes::NOT_FOUND),
133
+ // 408 and 429 are the two 4xx that invite the same request again,
134
+ // so they stay retryable even though they are client errors.
135
+ crate::api::ApiError::Api {
136
+ status: 408 | 429, ..
137
+ } => ("retry_later", exit_codes::API_ERROR),
138
+ // Any other 4xx means the request itself was rejected, so retrying
139
+ // it unchanged cannot help. It shares exit code 5 with api_error
140
+ // but reports a distinct kind, so an agent branching on
141
+ // `retryable` does not loop on a permanent failure.
142
+ crate::api::ApiError::Api { status, .. } if (400..500).contains(status) => {
143
+ ("client_error", exit_codes::API_ERROR)
144
+ }
133
145
  crate::api::ApiError::Api { .. } => ("api_error", exit_codes::API_ERROR),
134
146
  crate::api::ApiError::Conflict(_) => ("conflict", exit_codes::CONFLICT),
135
147
  crate::api::ApiError::Http(_) | crate::api::ApiError::Other(_) => {
@@ -207,6 +219,48 @@ mod tests {
207
219
  assert_eq!(code, exit_codes::NOT_FOUND);
208
220
  }
209
221
 
222
+ #[test]
223
+ fn error_kind_and_code_client_error_for_a_rejected_request() {
224
+ // The controller answers `power-cycle` on a PoE-disabled port with
225
+ // HTTP 400 api.err.InvalidTargetPort. Retrying that unchanged can only
226
+ // fail again, so it must not be published as the retryable api_error.
227
+ let err = ApiError::Api {
228
+ status: 400,
229
+ message: "api.err.InvalidTargetPort".into(),
230
+ };
231
+ let (kind, code) = error_kind_and_code(&err);
232
+ assert_eq!(kind, "client_error");
233
+ assert_eq!(code, exit_codes::API_ERROR);
234
+ }
235
+
236
+ #[test]
237
+ fn error_kind_and_code_keeps_408_and_429_retryable() {
238
+ // Both statuses ask for the same request again, so they must not land
239
+ // in the permanent client_error bucket an agent gives up on.
240
+ for status in [408u16, 429] {
241
+ let err = ApiError::Api {
242
+ status,
243
+ message: "slow down".into(),
244
+ };
245
+ let (kind, code) = error_kind_and_code(&err);
246
+ assert_eq!(kind, "retry_later", "status {status}");
247
+ assert_eq!(code, exit_codes::API_ERROR, "status {status}");
248
+ }
249
+ }
250
+
251
+ #[test]
252
+ fn error_kind_and_code_api_error_stays_for_server_side_failures() {
253
+ for status in [500u16, 502, 503] {
254
+ let err = ApiError::Api {
255
+ status,
256
+ message: "upstream failure".into(),
257
+ };
258
+ let (kind, code) = error_kind_and_code(&err);
259
+ assert_eq!(kind, "api_error", "status {status}");
260
+ assert_eq!(code, exit_codes::API_ERROR, "status {status}");
261
+ }
262
+ }
263
+
210
264
  #[test]
211
265
  fn error_envelope_is_valid_json() {
212
266
  let envelope = serde_json::json!({
@@ -202,6 +202,8 @@ fn command_metadata() -> HashMap<&'static str, CommandMeta> {
202
202
  ("poe_current", "number"),
203
203
  ("poe_good", "boolean"),
204
204
  ("attached_mac", "string"),
205
+ ("attached_last_seen_mac", "string"),
206
+ ("attached_connected", "boolean"),
205
207
  ("tx_bytes", "integer"),
206
208
  ("rx_bytes", "integer"),
207
209
  ("tx_errors", "integer"),
@@ -411,7 +413,7 @@ fn build_global_args(cmd: &clap::Command) -> Vec<serde_json::Value> {
411
413
  "name": "--yes",
412
414
  "type": "boolean",
413
415
  "required": false,
414
- "description": "Skip confirmation prompt for destructive commands (required without a TTY)",
416
+ "description": "Skip the confirmation prompt. Required for any command with confirmation_required: true when stdin is not a terminal; without it those commands exit 2 with kind confirmation_required and send nothing",
415
417
  }));
416
418
  args
417
419
  }
@@ -506,6 +508,16 @@ fn walk_commands(
506
508
  entry["output_fields"] = arr.into();
507
509
  }
508
510
  entry["mutating"] = meta.mutating.into();
511
+ // Published only for mutating commands, where the answer is
512
+ // the difference between a run that works unattended and one
513
+ // that exits 2. The three mutating commands that do not ask
514
+ // say so explicitly rather than leaving an agent to guess
515
+ // from the absence of a key.
516
+ if meta.mutating {
517
+ entry["confirmation_required"] = unifi_cli::CONFIRMATION_GATED_COMMANDS
518
+ .contains(&path.as_str())
519
+ .into();
520
+ }
509
521
  if let Some(note) = meta.note {
510
522
  entry["note"] = note.into();
511
523
  }
@@ -565,17 +577,29 @@ pub fn print_schema(cmd: clap::Command) {
565
577
  "retryable": false,
566
578
  "description": "Requested resource not found (404)",
567
579
  },
580
+ {
581
+ "kind": "client_error",
582
+ "exit_code": 5,
583
+ "retryable": false,
584
+ "description": "API rejected the request itself (4xx other than 401/403/404/408/429). The request will not succeed unchanged, so retrying cannot help; the HTTP status is in the error message",
585
+ },
586
+ {
587
+ "kind": "retry_later",
588
+ "exit_code": 5,
589
+ "retryable": true,
590
+ "description": "API declined to serve the request now and invited a retry (429 rate limited, 408 request timeout). Back off and retry the same request",
591
+ },
568
592
  {
569
593
  "kind": "api_error",
570
594
  "exit_code": 5,
571
595
  "retryable": true,
572
- "description": "API returned a server-side error (5xx)",
596
+ "description": "API returned a server-side error (5xx). May be transient",
573
597
  },
574
598
  {
575
599
  "kind": "conflict",
576
600
  "exit_code": 6,
577
601
  "retryable": false,
578
- "description": "Resource already exists with incompatible configuration",
602
+ "description": "The request cannot succeed against the resource's current state, rejected locally before any API call: an ambiguous match, or a precondition the target does not meet",
579
603
  },
580
604
  ],
581
605
  });
@@ -299,17 +299,87 @@ fn every_published_output_field_is_accepted_by_fields() {
299
299
  }
300
300
  }
301
301
 
302
- #[test]
303
- fn ports_cycle_requires_yes_without_a_tty() {
304
- let out = unifi()
305
- .args(["ports", "cycle", "aa:bb:cc:dd:ee:ff", "5"])
306
- .stdin(std::process::Stdio::null())
307
- .output()
308
- .expect("failed to run binary");
302
+ /// One invocation per gated command, with the argument set it needs.
303
+ const GATED_INVOCATIONS: &[(&str, &[&str])] = &[
304
+ ("clients block", &["aa:bb:cc:dd:ee:ff"]),
305
+ ("clients unblock", &["aa:bb:cc:dd:ee:ff"]),
306
+ ("clients kick", &["aa:bb:cc:dd:ee:ff"]),
307
+ ("devices restart", &["aa:bb:cc:dd:ee:ff"]),
308
+ ("devices upgrade", &["aa:bb:cc:dd:ee:ff"]),
309
+ ("ports cycle", &["aa:bb:cc:dd:ee:ff", "5"]),
310
+ ("protect rtsps delete", &["front-door"]),
311
+ ];
309
312
 
310
- assert_eq!(out.status.code(), Some(2));
313
+ /// Every command the schema publishes as `confirmation_required` must refuse to
314
+ /// act when there is no `--yes` and no TTY to ask on, and must refuse locally.
315
+ /// The host here is unroutable, so a `confirmation_required` envelope is also
316
+ /// proof that nothing was sent to the controller: a command that fell through
317
+ /// to HTTP would report a connection failure instead.
318
+ #[test]
319
+ fn every_confirmation_gated_command_refuses_without_yes_and_no_tty() {
320
+ let covered: Vec<&str> = GATED_INVOCATIONS.iter().map(|(c, _)| *c).collect();
321
+ for command in unifi_cli::CONFIRMATION_GATED_COMMANDS {
322
+ assert!(
323
+ covered.contains(command),
324
+ "`{command}` is confirmation-gated but has no invocation in GATED_INVOCATIONS"
325
+ );
326
+ }
311
327
  assert_eq!(
312
- error_envelope(&out.stderr)["error"]["kind"].as_str(),
313
- Some("confirmation_required")
328
+ covered.len(),
329
+ unifi_cli::CONFIRMATION_GATED_COMMANDS.len(),
330
+ "GATED_INVOCATIONS lists a command that is not in CONFIRMATION_GATED_COMMANDS"
314
331
  );
332
+
333
+ for (command, extra) in GATED_INVOCATIONS {
334
+ let mut args: Vec<&str> = command.split(' ').collect();
335
+ args.extend_from_slice(extra);
336
+
337
+ let out = unifi()
338
+ .args(&args)
339
+ .stdin(std::process::Stdio::null())
340
+ .output()
341
+ .expect("failed to run binary");
342
+
343
+ assert_eq!(
344
+ out.status.code(),
345
+ Some(2),
346
+ "`{command}` should exit 2 without --yes, stderr: {}",
347
+ String::from_utf8_lossy(&out.stderr)
348
+ );
349
+ assert_eq!(
350
+ error_envelope(&out.stderr)["error"]["kind"].as_str(),
351
+ Some("confirmation_required"),
352
+ "`{command}` should report confirmation_required, stderr: {}",
353
+ String::from_utf8_lossy(&out.stderr)
354
+ );
355
+ }
356
+ }
357
+
358
+ /// A mutating command that is not gated must not start asking for confirmation:
359
+ /// it reaches the controller (and fails on the unroutable host) instead. This is
360
+ /// the negative control for the test above, which would otherwise still pass if
361
+ /// every command refused everything.
362
+ #[test]
363
+ fn ungated_mutating_commands_do_not_ask_for_confirmation() {
364
+ for args in [
365
+ vec!["devices", "locate", "aa:bb:cc:dd:ee:ff"],
366
+ vec!["clients", "set-fixed-ip", "aa:bb:cc:dd:ee:ff", "192.0.2.10"],
367
+ ] {
368
+ let out = unifi()
369
+ .args(&args)
370
+ .stdin(std::process::Stdio::null())
371
+ .output()
372
+ .expect("failed to run binary");
373
+
374
+ let kind = error_envelope(&out.stderr)["error"]["kind"]
375
+ .as_str()
376
+ .unwrap_or_default()
377
+ .to_string();
378
+ assert_ne!(
379
+ kind,
380
+ "confirmation_required",
381
+ "`{}` is not in CONFIRMATION_GATED_COMMANDS but asked for confirmation",
382
+ args.join(" ")
383
+ );
384
+ }
315
385
  }
@@ -1879,14 +1879,10 @@ mod command_output {
1879
1879
  .unwrap();
1880
1880
  }
1881
1881
 
1882
- // `ports_show_table` above only smoke-tests that the text branch doesn't
1883
- // panic (matching the pre-existing convention for other `show`-style
1884
- // commands in this file). But `ports show`'s text branch is new code with
1885
- // real formatting logic (speed_cell, poe_cell, voltage/current, the
1886
- // attached MAC), so it gets its own test that spawns the real binary
1887
- // (same pattern as `ports_list_pagination_reports_full_total_and_truncated_items`
1888
- // and `devices_ports_bare_array_vs_ports_list_envelope`) and asserts on
1889
- // the actual rendered text.
1882
+ // `ports_show_table` above only smoke-tests that the text branch does not
1883
+ // panic. The text branch carries real formatting logic (speed_cell,
1884
+ // poe_cell, voltage/current, the attached MAC), so it also gets a test that
1885
+ // spawns the real binary and asserts on the rendered text.
1890
1886
  #[tokio::test]
1891
1887
  async fn ports_show_text_output_renders_expected_fields() {
1892
1888
  let server = MockServer::start().await;
@@ -1947,7 +1943,7 @@ mod command_output {
1947
1943
 
1948
1944
  // Drives the real `unifi` binary so the JSON this command actually prints
1949
1945
  // can be inspected, and cross-checks it against what `unifi schema`
1950
- // publishes for "ports show" — the two are supposed to be the same
1946
+ // publishes for "ports show": the two are supposed to be the same
1951
1947
  // contract, and nothing else in this suite would catch them drifting
1952
1948
  // apart.
1953
1949
  #[tokio::test]
@@ -2097,6 +2093,138 @@ mod command_output {
2097
2093
  }
2098
2094
  }
2099
2095
 
2096
+ // A `last_connection` the controller has marked `connected: false` is
2097
+ // history, not an attachment: the device may have been unplugged months
2098
+ // ago. Reporting it as attached would tell an operator a port is in use
2099
+ // moments before they cut its power, so `attached_mac` must be null and the
2100
+ // MAC must survive only as `attached_last_seen_mac`.
2101
+ #[tokio::test]
2102
+ async fn ports_show_reports_a_stale_last_connection_as_unattached() {
2103
+ let server = MockServer::start().await;
2104
+ Mock::given(method("GET"))
2105
+ .and(path("/proxy/network/api/s/default/stat/device"))
2106
+ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2107
+ "meta": {"rc": "ok"},
2108
+ "data": [{
2109
+ "mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE",
2110
+ "port_table": [{
2111
+ "port_idx": 5, "name": "Port 5", "media": "GE", "up": false,
2112
+ "port_poe": true, "poe_enable": true, "poe_mode": "auto",
2113
+ "last_connection": {"mac": "aabbccddeeff", "connected": false}
2114
+ }]
2115
+ }]
2116
+ })))
2117
+ .mount(&server)
2118
+ .await;
2119
+
2120
+ let run = |format: &str| {
2121
+ std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
2122
+ .args([
2123
+ "--host",
2124
+ &server.uri(),
2125
+ "--api-key",
2126
+ "test-key",
2127
+ "ports",
2128
+ "show",
2129
+ "9c:05:d6:bc:06:43",
2130
+ "5",
2131
+ "--output",
2132
+ format,
2133
+ ])
2134
+ .output()
2135
+ .expect("failed to run the unifi binary")
2136
+ };
2137
+
2138
+ let json_out = run("json");
2139
+ assert!(
2140
+ json_out.status.success(),
2141
+ "ports show failed: {}",
2142
+ String::from_utf8_lossy(&json_out.stderr)
2143
+ );
2144
+ let body: serde_json::Value = serde_json::from_slice(&json_out.stdout).unwrap();
2145
+ assert!(
2146
+ body["attached_mac"].is_null(),
2147
+ "a stale last_connection must not be published as attached: {body}"
2148
+ );
2149
+ assert_eq!(
2150
+ body["attached_last_seen_mac"], "aa:bb:cc:dd:ee:ff",
2151
+ "the stale MAC must stay available as history: {body}"
2152
+ );
2153
+ assert_eq!(
2154
+ body["attached_connected"], false,
2155
+ "the controller's own flag must be reported as it stands: {body}"
2156
+ );
2157
+
2158
+ let text_out = run("text");
2159
+ assert!(text_out.status.success());
2160
+ let text = String::from_utf8_lossy(&text_out.stdout);
2161
+ assert!(
2162
+ text.contains("- (last seen aa:bb:cc:dd:ee:ff)"),
2163
+ "the text branch must qualify a stale MAC rather than print it bare: {text}"
2164
+ );
2165
+ }
2166
+
2167
+ // A firmware that reports `last_connection.mac` without a `connected` flag
2168
+ // has said nothing about the present, which is not the same fact as
2169
+ // "disconnected". It is still not grounds to claim an attachment, so
2170
+ // `attached_mac` stays null, but the tri-state `attached_connected` and the
2171
+ // text output both distinguish "not reported" from "gone".
2172
+ #[tokio::test]
2173
+ async fn ports_show_distinguishes_an_unreported_connection_from_a_stale_one() {
2174
+ let server = MockServer::start().await;
2175
+ Mock::given(method("GET"))
2176
+ .and(path("/proxy/network/api/s/default/stat/device"))
2177
+ .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2178
+ "meta": {"rc": "ok"},
2179
+ "data": [{
2180
+ "mac": "9c:05:d6:bc:06:43", "name": "USW-24-PoE",
2181
+ "port_table": [{
2182
+ "port_idx": 5, "name": "Port 5", "media": "GE", "up": true,
2183
+ "last_connection": {"mac": "aabbccddeeff"}
2184
+ }]
2185
+ }]
2186
+ })))
2187
+ .mount(&server)
2188
+ .await;
2189
+
2190
+ let run = |format: &str| {
2191
+ std::process::Command::new(env!("CARGO_BIN_EXE_unifi"))
2192
+ .args([
2193
+ "--host",
2194
+ &server.uri(),
2195
+ "--api-key",
2196
+ "test-key",
2197
+ "ports",
2198
+ "show",
2199
+ "9c:05:d6:bc:06:43",
2200
+ "5",
2201
+ "--output",
2202
+ format,
2203
+ ])
2204
+ .output()
2205
+ .expect("failed to run the unifi binary")
2206
+ };
2207
+
2208
+ let json_out = run("json");
2209
+ assert!(json_out.status.success());
2210
+ let body: serde_json::Value = serde_json::from_slice(&json_out.stdout).unwrap();
2211
+ assert!(
2212
+ body["attached_mac"].is_null(),
2213
+ "an unreported connection must not be claimed as attached: {body}"
2214
+ );
2215
+ assert!(
2216
+ body["attached_connected"].is_null(),
2217
+ "a missing connected flag must stay null, not become false: {body}"
2218
+ );
2219
+ assert_eq!(body["attached_last_seen_mac"], "aa:bb:cc:dd:ee:ff");
2220
+
2221
+ let text = String::from_utf8_lossy(&run("text").stdout).to_string();
2222
+ assert!(
2223
+ text.contains("unknown (last seen aa:bb:cc:dd:ee:ff)"),
2224
+ "the text branch must say the state is unknown, not that the device is gone: {text}"
2225
+ );
2226
+ }
2227
+
2100
2228
  // Locates `unifi ports cycle <MAC> 99` uses the same `find_port` lookup;
2101
2229
  // a bogus port index must be reported as not-found (exit 4) rather than
2102
2230
  // firing a command at the controller for a port that does not exist.
@@ -2144,9 +2272,9 @@ mod command_output {
2144
2272
  }
2145
2273
 
2146
2274
  // The row-count trailer must read "1 port" for a single row and "N ports"
2147
- // otherwise — it used to say "1 ports" unconditionally. Spawns the real
2148
- // binary (rather than calling `render_text` in-process) so this observes
2149
- // literal stderr text, the same surface an operator actually reads.
2275
+ // otherwise. Spawns the real binary (rather than calling `render_text`
2276
+ // in-process) so this observes literal stderr text, the same surface an
2277
+ // operator actually reads.
2150
2278
  #[tokio::test]
2151
2279
  async fn ports_list_trailer_is_singular_for_exactly_one_row() {
2152
2280
  let server = MockServer::start().await;
@@ -2574,9 +2702,8 @@ mod command_output {
2574
2702
  // name matches: `office` genuinely matches two devices here, and both
2575
2703
  // are actually attached to a switch port (unlike the "one interface
2576
2704
  // never shows up" fixtures below), so this must still exit 6 (conflict)
2577
- // and name both candidates. Modeled on a live-controller case — two
2578
- // physically distinct office devices on the same switch — reported
2579
- // against the pre-fix behavior in the original bug report.
2705
+ // and name both candidates. Modeled on a live-controller case: two
2706
+ // physically distinct office devices sharing a name on the same switch.
2580
2707
  #[tokio::test]
2581
2708
  async fn ports_find_ambiguous_name_exits_with_conflict() {
2582
2709
  let server = MockServer::start().await;
@@ -2706,8 +2833,8 @@ mod command_output {
2706
2833
  }
2707
2834
 
2708
2835
  // The other client record sharing the name never appears in any port
2709
- // table at all — not "only the wireless interface", but no candidate on
2710
- // a port whatsoever — so this must be not_found, not a conflict.
2836
+ // table at all: not "only the wireless interface", but no candidate on a
2837
+ // port whatsoever, so this must be not_found, not a conflict.
2711
2838
  #[tokio::test]
2712
2839
  async fn ports_find_name_matches_clients_none_on_a_port_is_not_found() {
2713
2840
  let server = MockServer::start().await;
@@ -2769,7 +2896,7 @@ mod command_output {
2769
2896
 
2770
2897
  // `find`'s JSON output has always carried `connected`; only the text
2771
2898
  // table lacked it, leaving the connected-first sort order as the sole
2772
- // (easy-to-miss) signal for which row is the device's *current* port —
2899
+ // (easy-to-miss) signal for which row is the device's *current* port,
2773
2900
  // a distinction that matters because this lookup feeds the destructive
2774
2901
  // `ports cycle`. Two distinctly-named single-port devices (rather than
2775
2902
  // one device with two ports) so each rendered row can be identified by
@@ -2851,7 +2978,7 @@ mod command_output {
2851
2978
  // `power_cycle_port_sends_correct_command` (in `client_api` above) only
2852
2979
  // covers the client method's endpoint and body. Nothing exercised the
2853
2980
  // orchestration in `commands::ports::cycle` that decides *whether* to call
2854
- // it at all — and that orchestration is the only place in this CLI that
2981
+ // it at all, and that orchestration is the only place in this CLI that
2855
2982
  // cuts power to physical hardware. These four cases pin down the guard-rail
2856
2983
  // ordering (find_port -> check_cyclable -> confirm -> POST) as a tested
2857
2984
  // property rather than a code-reading exercise: the `.expect(0)` mounts on
@@ -2991,7 +3118,7 @@ mod command_output {
2991
3118
  // third guard rail: a port that is PoE-capable and not administratively
2992
3119
  // off, but that the controller reports as not currently delivering power
2993
3120
  // (poe_enable: false). This is the fixture from the live UCG-Max finding
2994
- // that motivated the guard — see `check_cyclable` in
3121
+ // that motivated the guard; see `check_cyclable` in
2995
3122
  // `src/commands/ports.rs` for what was actually observed.
2996
3123
  #[tokio::test]
2997
3124
  async fn ports_cycle_poe_enable_false_is_conflict_and_never_posts() {
@@ -1,5 +0,0 @@
1
- pub mod api;
2
- pub mod commands;
3
- pub mod fields;
4
- pub mod output;
5
- pub mod tui;
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes