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