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.
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/CHANGELOG.md +12 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/Cargo.lock +1 -1
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/Cargo.toml +1 -1
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/PKG-INFO +43 -14
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/README.md +42 -13
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/api/client.rs +2 -2
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/api/tests.rs +1 -1
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/ports.rs +87 -42
- unifi_cli-0.3.2/src/lib.rs +25 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/main.rs +165 -16
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/output.rs +54 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/schema.rs +27 -3
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/tests/cli_contract.rs +80 -10
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/tests/mock_server.rs +147 -20
- unifi_cli-0.3.1/src/lib.rs +0 -5
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/.github/workflows/ci.yml +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/.github/workflows/release.yml +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/.gitignore +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/LICENSE +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/Makefile +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/prek.toml +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/pyproject.toml +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/api/mod.rs +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/api/types.rs +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/clients.rs +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/devices.rs +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/events.rs +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/mod.rs +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/networks.rs +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/protect.rs +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/commands/system.rs +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/fields.rs +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/src/tui.rs +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/tests/fixtures/clispec-v0.2.json +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/tests/spec_compliance.rs +0 -0
- {unifi_cli-0.3.1 → unifi_cli-0.3.2}/unifi_cli/__init__.py +0 -0
- {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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: unifi-cli
|
|
3
|
-
Version: 0.3.
|
|
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
|
|
215
|
-
`
|
|
216
|
-
|
|
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
|
-
|
|
|
335
|
-
|
|
|
336
|
-
|
|
|
337
|
-
|
|
|
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
|
|
194
|
-
`
|
|
195
|
-
|
|
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
|
-
|
|
|
314
|
-
|
|
|
315
|
-
|
|
|
316
|
-
|
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
///
|
|
153
|
-
///
|
|
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
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
//
|
|
670
|
-
//
|
|
671
|
-
//
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
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
|
|
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
|
|
929
|
-
//
|
|
930
|
-
//
|
|
931
|
-
//
|
|
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
|
|
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
|
|
1180
|
-
//
|
|
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
|
-
//
|
|
1215
|
-
//
|
|
1216
|
-
// only
|
|
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
|
-
|
|
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
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
|
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": "
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
|
1883
|
-
// panic
|
|
1884
|
-
//
|
|
1885
|
-
// real
|
|
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"
|
|
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
|
|
2148
|
-
//
|
|
2149
|
-
//
|
|
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
|
|
2578
|
-
// physically distinct office devices on the same switch
|
|
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
|
|
2710
|
-
//
|
|
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
|
|
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
|
|
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() {
|
unifi_cli-0.3.1/src/lib.rs
DELETED
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|