Skip to main content

scope_web/api/
config_status.rs

1//! Config status API handler.
2
3use crate::AppState;
4use axum::Json;
5use axum::extract::State;
6use axum::http::StatusCode;
7use axum::response::IntoResponse;
8use scope::config::Config;
9use serde::Deserialize;
10use std::sync::Arc;
11
12/// GET /api/config/status — Returns config status (which keys are set).
13pub async fn handle(State(state): State<Arc<AppState>>) -> impl IntoResponse {
14    let config = &state.config;
15    let config_path = Config::config_path()
16        .map(|p| p.display().to_string())
17        .unwrap_or_else(|| "unknown".to_string());
18
19    let config_exists = Config::config_path().map(|p| p.exists()).unwrap_or(false);
20
21    // Report which API keys are configured (without exposing values)
22    let api_keys_status: serde_json::Value = serde_json::json!({
23        "etherscan": config.chains.api_keys.contains_key("etherscan") ||
24            std::env::var("ETHERSCAN_API_KEY").is_ok(),
25        "polygonscan": config.chains.api_keys.contains_key("polygonscan"),
26        "bscscan": config.chains.api_keys.contains_key("bscscan"),
27        "solscan": config.chains.api_keys.contains_key("solscan"),
28        "tronscan": config.chains.api_keys.contains_key("tronscan"),
29    });
30
31    let rpc_status = serde_json::json!({
32        "ethereum_rpc": config.chains.ethereum_rpc.is_some(),
33        "bsc_rpc": config.chains.bsc_rpc.is_some(),
34        "solana_rpc": config.chains.solana_rpc.is_some(),
35        "tron_api": config.chains.tron_api.is_some(),
36    });
37
38    Json(serde_json::json!({
39        "config_path": config_path,
40        "config_exists": config_exists,
41        "output_format": format!("{:?}", config.output.format),
42        "color_enabled": config.output.color,
43        "api_keys": api_keys_status,
44        "rpc_endpoints": rpc_status,
45        "version": scope::VERSION,
46    }))
47    .into_response()
48}
49
50/// Request body for saving configuration.
51#[derive(Debug, Deserialize)]
52pub struct SaveConfigRequest {
53    /// API keys to set (key name -> value).
54    #[serde(default)]
55    pub api_keys: std::collections::HashMap<String, String>,
56    /// RPC endpoints to set.
57    #[serde(default)]
58    pub rpc_endpoints: std::collections::HashMap<String, String>,
59}
60
61/// POST /api/config — Save API keys and RPC endpoints.
62pub async fn handle_save(
63    State(_state): State<Arc<AppState>>,
64    Json(req): Json<SaveConfigRequest>,
65) -> impl IntoResponse {
66    // Load existing config or create new
67    let mut config = Config::load(None).unwrap_or_default();
68
69    // Update API keys
70    for (key, value) in &req.api_keys {
71        if !value.is_empty() {
72            config.chains.api_keys.insert(key.clone(), value.clone());
73        }
74    }
75
76    // Update RPC endpoints
77    for (key, value) in &req.rpc_endpoints {
78        if !value.is_empty() {
79            match key.as_str() {
80                "ethereum_rpc" => config.chains.ethereum_rpc = Some(value.clone()),
81                "bsc_rpc" => config.chains.bsc_rpc = Some(value.clone()),
82                "solana_rpc" => config.chains.solana_rpc = Some(value.clone()),
83                "tron_api" => config.chains.tron_api = Some(value.clone()),
84                _ => {}
85            }
86        }
87    }
88
89    // Save to disk
90    let config_path = match Config::config_path() {
91        Some(p) => p,
92        None => {
93            return (
94                StatusCode::INTERNAL_SERVER_ERROR,
95                Json(serde_json::json!({ "error": "Cannot determine config path" })),
96            )
97                .into_response();
98        }
99    };
100
101    // Ensure parent directory exists
102    if let Some(parent) = config_path.parent()
103        && let Err(e) = std::fs::create_dir_all(parent)
104    {
105        return (
106            StatusCode::INTERNAL_SERVER_ERROR,
107            Json(serde_json::json!({ "error": format!("Failed to create config dir: {}", e) })),
108        )
109            .into_response();
110    }
111
112    match serde_yaml::to_string(&config) {
113        Ok(yaml) => {
114            if let Err(e) = std::fs::write(&config_path, yaml) {
115                return (
116                    StatusCode::INTERNAL_SERVER_ERROR,
117                    Json(serde_json::json!({ "error": format!("Failed to write config: {}", e) })),
118                )
119                    .into_response();
120            }
121            Json(serde_json::json!({
122                "status": "saved",
123                "path": config_path.display().to_string(),
124            }))
125            .into_response()
126        }
127        Err(e) => (
128            StatusCode::INTERNAL_SERVER_ERROR,
129            Json(serde_json::json!({ "error": format!("Failed to serialize config: {}", e) })),
130        )
131            .into_response(),
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    // `handle_save` resolves the config directory from the global `HOME` env
140    // var. The tests below mutate `HOME` across an `.await`, so without
141    // serialization they race under parallel execution (one test's restore of
142    // `HOME` lands inside another's window, flipping an expected 500 into a
143    // 200). A shared async lock forces these HOME-dependent tests to run one at
144    // a time. `tokio::sync::Mutex` (not `std::sync`) is used deliberately so it
145    // can be held across `.await` without tripping `clippy::await_holding_lock`.
146    static HOME_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
147
148    #[test]
149    fn test_deserialize_save_config_full() {
150        let json = serde_json::json!({
151            "api_keys": {
152                "etherscan": "test_key_123",
153                "polygonscan": "test_key_456"
154            },
155            "rpc_endpoints": {
156                "ethereum_rpc": "https://eth.example.com",
157                "bsc_rpc": "https://bsc.example.com"
158            }
159        });
160        let req: SaveConfigRequest = serde_json::from_value(json).unwrap();
161        assert_eq!(req.api_keys.len(), 2);
162        assert_eq!(
163            req.api_keys.get("etherscan"),
164            Some(&"test_key_123".to_string())
165        );
166        assert_eq!(
167            req.api_keys.get("polygonscan"),
168            Some(&"test_key_456".to_string())
169        );
170        assert_eq!(req.rpc_endpoints.len(), 2);
171        assert_eq!(
172            req.rpc_endpoints.get("ethereum_rpc"),
173            Some(&"https://eth.example.com".to_string())
174        );
175        assert_eq!(
176            req.rpc_endpoints.get("bsc_rpc"),
177            Some(&"https://bsc.example.com".to_string())
178        );
179    }
180
181    #[test]
182    fn test_deserialize_save_config_empty() {
183        let json = serde_json::json!({});
184        let req: SaveConfigRequest = serde_json::from_value(json).unwrap();
185        assert_eq!(req.api_keys.len(), 0);
186        assert_eq!(req.rpc_endpoints.len(), 0);
187    }
188
189    #[test]
190    fn test_deserialize_save_config_partial() {
191        let json = serde_json::json!({
192            "api_keys": {
193                "etherscan": "test_key_123"
194            }
195        });
196        let req: SaveConfigRequest = serde_json::from_value(json).unwrap();
197        assert_eq!(req.api_keys.len(), 1);
198        assert_eq!(
199            req.api_keys.get("etherscan"),
200            Some(&"test_key_123".to_string())
201        );
202        assert_eq!(req.rpc_endpoints.len(), 0);
203    }
204
205    #[tokio::test]
206    async fn test_handle_config_status() {
207        use crate::AppState;
208        use axum::extract::State;
209        use axum::response::IntoResponse;
210        use scope::chains::DefaultClientFactory;
211        use scope::config::Config;
212
213        let config = Config::default();
214        let http: std::sync::Arc<dyn scope::http::HttpClient> =
215            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
216        let factory = DefaultClientFactory {
217            chains_config: config.chains.clone(),
218            http,
219        };
220        let state = std::sync::Arc::new(AppState { config, factory });
221        let response = handle(State(state)).await.into_response();
222        assert_eq!(response.status(), axum::http::StatusCode::OK);
223
224        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
225            .await
226            .unwrap();
227        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
228        assert!(json.get("config_path").is_some());
229        assert!(json.get("api_keys").is_some());
230        assert!(json.get("rpc_endpoints").is_some());
231        assert!(json.get("version").is_some());
232    }
233
234    #[tokio::test]
235    async fn test_handle_save_config() {
236        use crate::AppState;
237        use axum::extract::State;
238        use axum::response::IntoResponse;
239        use scope::chains::DefaultClientFactory;
240        use scope::config::Config;
241
242        let config = Config::default();
243        let http: std::sync::Arc<dyn scope::http::HttpClient> =
244            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
245        let factory = DefaultClientFactory {
246            chains_config: config.chains.clone(),
247            http,
248        };
249        let state = std::sync::Arc::new(AppState { config, factory });
250        let req = SaveConfigRequest {
251            api_keys: std::collections::HashMap::new(),
252            rpc_endpoints: std::collections::HashMap::new(),
253        };
254        let response = handle_save(State(state), axum::Json(req))
255            .await
256            .into_response();
257        // May succeed or fail depending on filesystem
258        let status = response.status();
259        assert!(
260            status == axum::http::StatusCode::OK
261                || status == axum::http::StatusCode::INTERNAL_SERVER_ERROR
262        );
263    }
264
265    #[test]
266    fn test_save_config_request_debug() {
267        let req = SaveConfigRequest {
268            api_keys: std::collections::HashMap::new(),
269            rpc_endpoints: std::collections::HashMap::new(),
270        };
271        let debug = format!("{:?}", req);
272        assert!(debug.contains("SaveConfigRequest"));
273    }
274
275    #[tokio::test]
276    async fn test_handle_save_config_path_none() {
277        use crate::AppState;
278        use axum::extract::State;
279        use axum::http::StatusCode;
280        use axum::response::IntoResponse;
281        use scope::chains::DefaultClientFactory;
282        use scope::config::Config;
283
284        let _home_guard = HOME_LOCK.lock().await;
285
286        let config = Config::default();
287        let http: std::sync::Arc<dyn scope::http::HttpClient> =
288            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
289        let factory = DefaultClientFactory {
290            chains_config: config.chains.clone(),
291            http,
292        };
293        let state = std::sync::Arc::new(AppState { config, factory });
294
295        let req = SaveConfigRequest {
296            api_keys: std::collections::HashMap::new(),
297            rpc_endpoints: std::collections::HashMap::new(),
298        };
299
300        let old_home = std::env::var_os("HOME");
301        let old_userprofile = std::env::var_os("USERPROFILE");
302        unsafe {
303            std::env::remove_var("HOME");
304            std::env::remove_var("USERPROFILE");
305        }
306
307        let response = handle_save(State(state), axum::Json(req))
308            .await
309            .into_response();
310
311        if let Some(h) = old_home {
312            unsafe { std::env::set_var("HOME", h) };
313        }
314        if let Some(u) = old_userprofile {
315            unsafe { std::env::set_var("USERPROFILE", u) };
316        }
317
318        if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
319            let body = axum::body::to_bytes(response.into_body(), 1_000_000)
320                .await
321                .unwrap();
322            let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
323            assert!(
324                json["error"]
325                    .as_str()
326                    .unwrap()
327                    .contains("Cannot determine config path")
328                    || json["error"]
329                        .as_str()
330                        .unwrap()
331                        .contains("Failed to create config dir")
332                    || json["error"]
333                        .as_str()
334                        .unwrap()
335                        .contains("Failed to write config")
336            );
337        }
338    }
339
340    #[tokio::test]
341    async fn test_handle_save_config_create_dir_fails() {
342        use crate::AppState;
343        use axum::extract::State;
344        use axum::http::StatusCode;
345        use axum::response::IntoResponse;
346        use scope::chains::DefaultClientFactory;
347        use scope::config::Config;
348
349        let _home_guard = HOME_LOCK.lock().await;
350
351        let tmp = tempfile::tempdir().unwrap();
352        let fake_home = tmp.path().join("fake_home");
353        std::fs::create_dir_all(&fake_home).unwrap();
354        let config_as_file = fake_home.join(".config");
355        std::fs::File::create(&config_as_file).unwrap();
356
357        let config = Config::default();
358        let http: std::sync::Arc<dyn scope::http::HttpClient> =
359            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
360        let factory = DefaultClientFactory {
361            chains_config: config.chains.clone(),
362            http,
363        };
364        let state = std::sync::Arc::new(AppState { config, factory });
365
366        let req = SaveConfigRequest {
367            api_keys: std::collections::HashMap::new(),
368            rpc_endpoints: std::collections::HashMap::new(),
369        };
370
371        let old_home = std::env::var_os("HOME");
372        unsafe { std::env::set_var("HOME", &fake_home) };
373
374        let response = handle_save(State(state), axum::Json(req))
375            .await
376            .into_response();
377
378        if let Some(h) = old_home {
379            unsafe { std::env::set_var("HOME", h) };
380        } else {
381            unsafe { std::env::remove_var("HOME") };
382        }
383
384        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
385        let body = axum::body::to_bytes(response.into_body(), 1_000_000)
386            .await
387            .unwrap();
388        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
389        assert!(
390            json["error"]
391                .as_str()
392                .unwrap()
393                .contains("Failed to create config dir")
394        );
395    }
396
397    #[tokio::test]
398    async fn test_handle_save_config_with_api_keys_and_rpc() {
399        use crate::AppState;
400        use axum::extract::State;
401        use axum::response::IntoResponse;
402        use scope::chains::DefaultClientFactory;
403        use scope::config::Config;
404
405        let _home_guard = HOME_LOCK.lock().await;
406
407        let config = Config::default();
408        let http: std::sync::Arc<dyn scope::http::HttpClient> =
409            std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
410        let factory = DefaultClientFactory {
411            chains_config: config.chains.clone(),
412            http,
413        };
414        let state = std::sync::Arc::new(AppState { config, factory });
415
416        let mut api_keys = std::collections::HashMap::new();
417        api_keys.insert("etherscan".to_string(), "test_key_abc".to_string());
418        api_keys.insert("polygonscan".to_string(), "test_key_def".to_string());
419        api_keys.insert("empty_key".to_string(), "".to_string()); // empty value - should be skipped
420
421        let mut rpc_endpoints = std::collections::HashMap::new();
422        rpc_endpoints.insert(
423            "ethereum_rpc".to_string(),
424            "https://eth.example.com".to_string(),
425        );
426        rpc_endpoints.insert("bsc_rpc".to_string(), "https://bsc.example.com".to_string());
427        rpc_endpoints.insert(
428            "solana_rpc".to_string(),
429            "https://sol.example.com".to_string(),
430        );
431        rpc_endpoints.insert(
432            "tron_api".to_string(),
433            "https://tron.example.com".to_string(),
434        );
435        rpc_endpoints.insert(
436            "unknown_key".to_string(),
437            "https://unknown.example.com".to_string(),
438        );
439        rpc_endpoints.insert("empty_rpc".to_string(), "".to_string()); // empty value
440
441        let req = SaveConfigRequest {
442            api_keys,
443            rpc_endpoints,
444        };
445
446        let response = handle_save(State(state), axum::Json(req))
447            .await
448            .into_response();
449        let status = response.status();
450        // May succeed or fail depending on filesystem permissions, but we cover the code paths
451        assert!(
452            status == axum::http::StatusCode::OK
453                || status == axum::http::StatusCode::INTERNAL_SERVER_ERROR
454        );
455    }
456}