Skip to main content

unifi/capabilities/
internal_network.rs

1//! Builds [`Capability`] entries from the internal-API endpoint inventory
2//! baked into `data/unifi_internal_endpoint_models.json`, plus a handful of
3//! hand-written legacy and hybrid aliases.
4
5use serde::Deserialize;
6
7use crate::api::ApiSourceFamily;
8use crate::capabilities::{AuthScope, Capability};
9
10#[derive(Debug, Deserialize)]
11struct Inventory {
12    tools: Vec<Tool>,
13}
14
15#[derive(Debug, Deserialize)]
16struct Tool {
17    action: String,
18    method: String,
19    path: String,
20    title: String,
21    mutating: bool,
22    runtime: bool,
23    auth_scope: String,
24    verification_mode: String,
25}
26
27/// One [`Capability`] per `runtime`-flagged tool in the bundled endpoint
28/// inventory, plus the fixed legacy aliases ([`UnifiClient`](crate::UnifiClient)'s
29/// named methods) and hybrid entries this crate resolves at dispatch time.
30///
31/// # Panics
32/// Panics if the bundled inventory JSON fails to parse, or if it contains an
33/// `auth_scope` other than `"read"`/`"admin"` — see [`super::all_capabilities`]
34/// for why that can only happen from a broken build, not at runtime.
35#[allow(clippy::expect_used)]
36pub fn capabilities() -> Vec<Capability> {
37    let inventory: Inventory = serde_json::from_str(include_str!(
38        "../../data/unifi_internal_endpoint_models.json"
39    ))
40    .expect("internal UniFi endpoint models should be valid JSON");
41    let mut caps = inventory
42        .tools
43        .into_iter()
44        .filter(|tool| tool.runtime)
45        .map(|tool| Capability {
46            action: tool.action,
47            title: tool.title,
48            source: ApiSourceFamily::Internal,
49            method: Some(tool.method),
50            path: Some(tool.path),
51            mutating: tool.mutating,
52            auth_scope: auth_scope(&tool.auth_scope),
53            verification_mode: Some(tool.verification_mode),
54        })
55        .collect::<Vec<_>>();
56    caps.extend([
57        legacy("clients", "Clients", "GET", "/stat/sta"),
58        legacy("devices", "Devices", "GET", "/stat/device"),
59        legacy("wlans", "WLANs", "GET", "/rest/wlanconf"),
60        legacy("health", "Health", "GET", "/stat/health"),
61        // Matches UnifiClient::alarms()'s actual call in client.rs — a
62        // catalog/{legacy alias} path mismatch here was found in review;
63        // "alarms" is dispatched through the named handler in
64        // actions/internal.rs, so this path is discovery metadata only,
65        // but it must describe what will really be called.
66        legacy("alarms", "Alarms", "GET", "/rest/alarm"),
67        legacy("events", "Events", "GET", "/rest/event"),
68        legacy("sysinfo", "System Info", "GET", "/stat/sysinfo"),
69        legacy("me", "Current User", "GET", "/api/self"),
70        hybrid("list_clients", "List Clients"),
71        hybrid("list_devices", "List Devices"),
72        hybrid("list_networks", "List Networks"),
73        hybrid("list_wifi", "List WiFi"),
74        hybrid("get_system_info", "Get System Info"),
75    ]);
76    caps
77}
78
79fn legacy(action: &str, title: &str, method: &str, path: &str) -> Capability {
80    Capability {
81        action: action.to_string(),
82        title: title.to_string(),
83        source: ApiSourceFamily::Internal,
84        method: Some(method.to_string()),
85        path: Some(path.to_string()),
86        mutating: false,
87        auth_scope: AuthScope::Read,
88        verification_mode: Some("legacy_alias".to_string()),
89    }
90}
91
92fn hybrid(action: &str, title: &str) -> Capability {
93    Capability {
94        action: action.to_string(),
95        title: title.to_string(),
96        source: ApiSourceFamily::Hybrid,
97        method: None,
98        path: None,
99        mutating: false,
100        auth_scope: AuthScope::Read,
101        verification_mode: Some("contract_ok".to_string()),
102    }
103}
104
105/// # Panics
106/// Panics on any `scope` other than `"read"`/`"admin"` — only ever called
107/// with a field from the bundled, crate-owned inventory JSON, never with
108/// caller input; see [`capabilities`]'s own `# Panics` section.
109#[allow(clippy::panic)]
110fn auth_scope(scope: &str) -> AuthScope {
111    match scope {
112        "read" => AuthScope::Read,
113        "admin" => AuthScope::Admin,
114        other => panic!("unknown internal auth_scope {other} in bundled inventory JSON"),
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn capabilities_parses_the_bundled_inventory_and_appends_aliases() {
124        let caps = capabilities();
125
126        assert!(caps
127            .iter()
128            .any(|cap| cap.action == "clients" && cap.source == ApiSourceFamily::Internal));
129        assert!(caps
130            .iter()
131            .any(|cap| cap.action == "list_clients" && cap.source == ApiSourceFamily::Hybrid));
132    }
133
134    #[test]
135    fn unifi_block_client_is_excluded_until_its_real_endpoint_is_verified() {
136        // The bundled inventory declares this mutating admin action with the
137        // same GET path as the read-only client listing — dispatching it
138        // would silently no-op instead of blocking anything. It must stay
139        // `runtime: false` (and therefore absent here) until fixed.
140        let caps = capabilities();
141
142        assert!(!caps.iter().any(|cap| cap.action == "unifi_block_client"));
143    }
144
145    #[test]
146    fn no_dispatchable_mutating_action_shares_a_get_path_with_a_read_only_action() {
147        // A mutating admin action declared as a GET against the exact same
148        // path a read-only action already uses cannot actually mutate
149        // anything: dispatching it just re-runs the read and returns a
150        // misleadingly successful result (this is precisely the bug
151        // `unifi_block_client`, and 20 siblings alongside it, shipped with —
152        // see the `evidence` field on any `unverified_path_mismatch` entry
153        // in data/unifi_internal_endpoint_models.json for the fix history).
154        // This is a catalog-wide invariant, not a one-action regression pin:
155        // it catches the whole bug class, including future additions.
156        let caps = capabilities();
157        let read_only_paths: std::collections::HashSet<&str> = caps
158            .iter()
159            .filter(|cap| !cap.mutating)
160            .filter_map(|cap| cap.path.as_deref())
161            .collect();
162
163        let offenders: Vec<&str> = caps
164            .iter()
165            .filter(|cap| cap.mutating)
166            .filter(|cap| {
167                cap.method
168                    .as_deref()
169                    .is_some_and(|m| m.eq_ignore_ascii_case("GET"))
170                    && cap
171                        .path
172                        .as_deref()
173                        .is_some_and(|p| read_only_paths.contains(p))
174            })
175            .map(|cap| cap.action.as_str())
176            .collect();
177
178        assert!(
179            offenders.is_empty(),
180            "mutating actions with a GET path identical to a read-only action's path \
181             (cannot actually mutate anything; disable via runtime:false in the JSON \
182             inventory until the real endpoint is confirmed): {offenders:?}"
183        );
184    }
185
186    #[test]
187    fn legacy_aliases_are_read_scoped_and_non_mutating() {
188        let cap = legacy("clients", "Clients", "GET", "/stat/sta");
189
190        assert_eq!(cap.auth_scope, AuthScope::Read);
191        assert!(!cap.mutating);
192    }
193
194    #[test]
195    fn hybrid_entries_have_no_method_or_path() {
196        let cap = hybrid("list_clients", "List Clients");
197
198        assert_eq!(cap.source, ApiSourceFamily::Hybrid);
199        assert!(cap.method.is_none());
200        assert!(cap.path.is_none());
201    }
202
203    #[test]
204    #[should_panic(expected = "unknown internal auth_scope")]
205    fn auth_scope_panics_on_an_unrecognized_value() {
206        auth_scope("write");
207    }
208}