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!(
127            caps.iter()
128                .any(|cap| cap.action == "clients" && cap.source == ApiSourceFamily::Internal)
129        );
130        assert!(
131            caps.iter()
132                .any(|cap| cap.action == "list_clients" && cap.source == ApiSourceFamily::Hybrid)
133        );
134    }
135
136    #[test]
137    fn unifi_block_client_is_excluded_until_its_real_endpoint_is_verified() {
138        // The bundled inventory declares this mutating admin action with the
139        // same GET path as the read-only client listing — dispatching it
140        // would silently no-op instead of blocking anything. It must stay
141        // `runtime: false` (and therefore absent here) until fixed.
142        let caps = capabilities();
143
144        assert!(!caps.iter().any(|cap| cap.action == "unifi_block_client"));
145    }
146
147    #[test]
148    fn no_dispatchable_mutating_action_shares_a_get_path_with_a_read_only_action() {
149        // A mutating admin action declared as a GET against the exact same
150        // path a read-only action already uses cannot actually mutate
151        // anything: dispatching it just re-runs the read and returns a
152        // misleadingly successful result (this is precisely the bug
153        // `unifi_block_client`, and 20 siblings alongside it, shipped with —
154        // see the `evidence` field on any `unverified_path_mismatch` entry
155        // in data/unifi_internal_endpoint_models.json for the fix history).
156        // This is a catalog-wide invariant, not a one-action regression pin:
157        // it catches the whole bug class, including future additions.
158        let caps = capabilities();
159        let read_only_paths: std::collections::HashSet<&str> = caps
160            .iter()
161            .filter(|cap| !cap.mutating)
162            .filter_map(|cap| cap.path.as_deref())
163            .collect();
164
165        let offenders: Vec<&str> = caps
166            .iter()
167            .filter(|cap| cap.mutating)
168            .filter(|cap| {
169                cap.method
170                    .as_deref()
171                    .is_some_and(|m| m.eq_ignore_ascii_case("GET"))
172                    && cap
173                        .path
174                        .as_deref()
175                        .is_some_and(|p| read_only_paths.contains(p))
176            })
177            .map(|cap| cap.action.as_str())
178            .collect();
179
180        assert!(
181            offenders.is_empty(),
182            "mutating actions with a GET path identical to a read-only action's path \
183             (cannot actually mutate anything; disable via runtime:false in the JSON \
184             inventory until the real endpoint is confirmed): {offenders:?}"
185        );
186    }
187
188    #[test]
189    fn legacy_aliases_are_read_scoped_and_non_mutating() {
190        let cap = legacy("clients", "Clients", "GET", "/stat/sta");
191
192        assert_eq!(cap.auth_scope, AuthScope::Read);
193        assert!(!cap.mutating);
194    }
195
196    #[test]
197    fn hybrid_entries_have_no_method_or_path() {
198        let cap = hybrid("list_clients", "List Clients");
199
200        assert_eq!(cap.source, ApiSourceFamily::Hybrid);
201        assert!(cap.method.is_none());
202        assert!(cap.path.is_none());
203    }
204
205    #[test]
206    #[should_panic(expected = "unknown internal auth_scope")]
207    fn auth_scope_panics_on_an_unrecognized_value() {
208        auth_scope("write");
209    }
210}