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!(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 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 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}