1use 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#[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 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#[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 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 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}