Skip to main content

msg_gateway/
admin.rs

1//! Admin API endpoints for credential management
2//!
3//! All endpoints require admin_token authentication.
4
5use axum::{
6    Json,
7    extract::{Path, State},
8    http::StatusCode,
9    response::IntoResponse,
10};
11use serde::{Deserialize, Serialize};
12use serde_json::json;
13use std::sync::Arc;
14
15use crate::config::CredentialConfig;
16use crate::error::AppError;
17use crate::server::AppState;
18
19/// Request body for creating a credential
20#[derive(Debug, Deserialize)]
21pub struct CreateCredentialRequest {
22    pub id: String,
23    /// Adapter name (must exist in adapters_dir, or "generic" for built-in)
24    pub adapter: String,
25    pub token: String,
26    #[serde(default = "default_true")]
27    pub active: bool,
28    #[serde(default)]
29    pub emergency: bool,
30    #[serde(default)]
31    pub config: Option<serde_json::Value>,
32    #[serde(default)]
33    pub backend: Option<String>,
34    pub route: serde_json::Value,
35}
36
37fn default_true() -> bool {
38    true
39}
40
41/// Request body for updating a credential
42#[derive(Debug, Deserialize)]
43pub struct UpdateCredentialRequest {
44    #[serde(default)]
45    pub adapter: Option<String>,
46    #[serde(default)]
47    pub token: Option<String>,
48    #[serde(default)]
49    pub active: Option<bool>,
50    #[serde(default)]
51    pub emergency: Option<bool>,
52    #[serde(default)]
53    pub config: Option<serde_json::Value>,
54    #[serde(default)]
55    pub backend: Option<String>,
56    #[serde(default)]
57    pub route: Option<serde_json::Value>,
58}
59
60/// Response for credential info
61#[derive(Debug, Serialize)]
62pub struct CredentialResponse {
63    pub id: String,
64    pub adapter: String,
65    pub active: bool,
66    pub emergency: bool,
67    pub config: Option<serde_json::Value>,
68    pub backend: Option<String>,
69    pub route: serde_json::Value,
70    pub instance_status: Option<String>,
71}
72
73/// GET /admin/credentials/:id
74pub async fn get_credential(
75    State(state): State<Arc<AppState>>,
76    Path(id): Path<String>,
77) -> Result<impl IntoResponse, AppError> {
78    let config = state.config.read().await;
79
80    let cred = config
81        .credentials
82        .get(&id)
83        .ok_or_else(|| AppError::CredentialNotFound(id.clone()))?;
84
85    let instance_status = state.manager.registry.get_status(&id).await;
86
87    Ok(Json(CredentialResponse {
88        id: id.clone(),
89        adapter: cred.adapter.clone(),
90        active: cred.active,
91        emergency: cred.emergency,
92        config: cred.config.clone(),
93        backend: cred.backend.clone(),
94        route: cred.route.clone(),
95        instance_status: instance_status.map(|s| format!("{:?}", s)),
96    }))
97}
98
99/// POST /admin/credentials
100pub async fn create_credential(
101    State(state): State<Arc<AppState>>,
102    Json(req): Json<CreateCredentialRequest>,
103) -> Result<impl IntoResponse, AppError> {
104    // Tell watcher to skip reloads
105    set_skip_reload(&state).await;
106
107    // Check if credential already exists
108    {
109        let config = state.config.read().await;
110        if config.credentials.contains_key(&req.id) {
111            return Err(AppError::Internal(format!(
112                "Credential already exists: {}",
113                req.id
114            )));
115        }
116    }
117
118    let cred_config = CredentialConfig {
119        adapter: req.adapter,
120        token: req.token,
121        active: req.active,
122        emergency: req.emergency,
123        config: req.config,
124        backend: req.backend,
125        route: req.route,
126    };
127
128    // Update config in memory
129    {
130        let mut config = state.config.write().await;
131        config
132            .credentials
133            .insert(req.id.clone(), cred_config.clone());
134    }
135
136    // Write config to file
137    write_config(&state).await?;
138
139    // Start task if active
140    if req.active {
141        state.manager.spawn_task(req.id.clone(), cred_config).await;
142    }
143
144    tracing::info!(credential_id = %req.id, "Credential created");
145
146    Ok((
147        StatusCode::CREATED,
148        Json(json!({
149            "id": req.id,
150            "status": "created"
151        })),
152    ))
153}
154
155/// PUT /admin/credentials/:id
156pub async fn update_credential(
157    State(state): State<Arc<AppState>>,
158    Path(id): Path<String>,
159    Json(req): Json<UpdateCredentialRequest>,
160) -> Result<impl IntoResponse, AppError> {
161    // Tell watcher to skip reloads
162    set_skip_reload(&state).await;
163
164    let old_config;
165    let new_cred_config;
166
167    // Update config in memory
168    {
169        let mut config = state.config.write().await;
170
171        let cred = config
172            .credentials
173            .get_mut(&id)
174            .ok_or_else(|| AppError::CredentialNotFound(id.clone()))?;
175
176        old_config = cred.clone();
177
178        // Apply updates
179        if let Some(adapter) = req.adapter {
180            cred.adapter = adapter;
181        }
182        if let Some(token) = req.token {
183            cred.token = token;
184        }
185        if let Some(active) = req.active {
186            cred.active = active;
187        }
188        if let Some(emergency) = req.emergency {
189            cred.emergency = emergency;
190        }
191        if req.config.is_some() {
192            cred.config = req.config;
193        }
194        if req.backend.is_some() {
195            cred.backend = req.backend;
196        }
197        if let Some(route) = req.route {
198            cred.route = route;
199        }
200
201        new_cred_config = cred.clone();
202    }
203
204    // Write config to file
205    write_config(&state).await?;
206
207    // Handle adapter instance lifecycle based on changes
208    let needs_restart = old_config.adapter != new_cred_config.adapter
209        || old_config.token != new_cred_config.token
210        || old_config.config != new_cred_config.config;
211
212    if needs_restart && old_config.active {
213        state.manager.stop_task(&id).await;
214    }
215
216    if new_cred_config.active && (needs_restart || !old_config.active) {
217        state.manager.spawn_task(id.clone(), new_cred_config).await;
218    } else if !new_cred_config.active && old_config.active {
219        state.manager.stop_task(&id).await;
220    }
221
222    tracing::info!(credential_id = %id, "Credential updated");
223
224    Ok(Json(json!({
225        "id": id,
226        "status": "updated"
227    })))
228}
229
230/// DELETE /admin/credentials/:id
231pub async fn delete_credential(
232    State(state): State<Arc<AppState>>,
233    Path(id): Path<String>,
234) -> Result<impl IntoResponse, AppError> {
235    // Tell watcher to skip reloads
236    set_skip_reload(&state).await;
237
238    // Stop task if running
239    state.manager.stop_task(&id).await;
240
241    // Remove from config
242    {
243        let mut config = state.config.write().await;
244        if config.credentials.remove(&id).is_none() {
245            return Err(AppError::CredentialNotFound(id));
246        }
247    }
248
249    // Write config to file
250    write_config(&state).await?;
251
252    tracing::info!(credential_id = %id, "Credential deleted");
253
254    Ok(Json(json!({
255        "id": id,
256        "status": "deleted"
257    })))
258}
259
260/// PATCH /admin/credentials/:id/activate
261pub async fn activate_credential(
262    State(state): State<Arc<AppState>>,
263    Path(id): Path<String>,
264) -> Result<impl IntoResponse, AppError> {
265    // Tell watcher to skip reloads
266    set_skip_reload(&state).await;
267
268    let cred_config;
269
270    {
271        let mut config = state.config.write().await;
272
273        let cred = config
274            .credentials
275            .get_mut(&id)
276            .ok_or_else(|| AppError::CredentialNotFound(id.clone()))?;
277
278        if cred.active {
279            return Ok(Json(json!({
280                "id": id,
281                "status": "already_active"
282            })));
283        }
284
285        cred.active = true;
286        cred_config = cred.clone();
287    }
288
289    // Write config to file
290    write_config(&state).await?;
291
292    // Start task
293    state.manager.spawn_task(id.clone(), cred_config).await;
294
295    tracing::info!(credential_id = %id, "Credential activated");
296
297    Ok(Json(json!({
298        "id": id,
299        "status": "activated"
300    })))
301}
302
303/// PATCH /admin/credentials/:id/deactivate
304pub async fn deactivate_credential(
305    State(state): State<Arc<AppState>>,
306    Path(id): Path<String>,
307) -> Result<impl IntoResponse, AppError> {
308    // Tell watcher to skip reloads
309    set_skip_reload(&state).await;
310
311    {
312        let mut config = state.config.write().await;
313
314        let cred = config
315            .credentials
316            .get_mut(&id)
317            .ok_or_else(|| AppError::CredentialNotFound(id.clone()))?;
318
319        if !cred.active {
320            return Ok(Json(json!({
321                "id": id,
322                "status": "already_inactive"
323            })));
324        }
325
326        cred.active = false;
327    }
328
329    // Write config to file
330    write_config(&state).await?;
331
332    // Stop task
333    state.manager.stop_task(&id).await;
334
335    tracing::info!(credential_id = %id, "Credential deactivated");
336
337    Ok(Json(json!({
338        "id": id,
339        "status": "deactivated"
340    })))
341}
342
343/// Set skip reload flag before any config modification
344pub async fn set_skip_reload(state: &AppState) {
345    use std::time::{Duration, Instant};
346    let mut skip_until = state.skip_reload_until.write().await;
347    *skip_until = Some(Instant::now() + Duration::from_secs(2));
348}
349
350/// Write config to file atomically (write to temp, then rename)
351async fn write_config(state: &AppState) -> Result<(), AppError> {
352    let config_path = std::env::var("GATEWAY_CONFIG").unwrap_or_else(|_| "config.json".to_string());
353
354    let config = state.config.read().await;
355
356    // Serialize config
357    let json = serde_json::to_string_pretty(&*config)
358        .map_err(|e| AppError::Internal(format!("Failed to serialize config: {}", e)))?;
359
360    drop(config); // Release lock before file I/O
361
362    // Write to temp file
363    let temp_path = format!("{}.tmp", config_path);
364    tokio::fs::write(&temp_path, &json)
365        .await
366        .map_err(|e| AppError::Internal(format!("Failed to write temp config: {}", e)))?;
367
368    // Atomic rename
369    tokio::fs::rename(&temp_path, &config_path)
370        .await
371        .map_err(|e| AppError::Internal(format!("Failed to rename config: {}", e)))?;
372
373    tracing::debug!("Config written to {}", config_path);
374
375    Ok(())
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    // ==================== Request/Response Struct Tests ====================
382
383    #[test]
384    fn test_create_credential_request_parse_minimal() {
385        let json = r#"{
386            "id": "test_cred",
387            "adapter": "telegram",
388            "token": "secret123",
389            "route": {"type": "default"}
390        }"#;
391
392        let req: CreateCredentialRequest = serde_json::from_str(json).unwrap();
393        assert_eq!(req.id, "test_cred");
394        assert_eq!(req.adapter, "telegram");
395        assert_eq!(req.token, "secret123");
396        assert!(req.active); // default is true
397        assert!(!req.emergency); // default is false
398        assert!(req.config.is_none());
399        assert!(req.backend.is_none());
400    }
401
402    #[test]
403    fn test_create_credential_request_parse_full() {
404        let json = r#"{
405            "id": "test_cred",
406            "adapter": "telegram",
407            "token": "secret123",
408            "active": false,
409            "emergency": true,
410            "config": {"chat_id": "123"},
411            "backend": "pipelit",
412            "route": {"type": "custom", "path": "/api"}
413        }"#;
414
415        let req: CreateCredentialRequest = serde_json::from_str(json).unwrap();
416        assert_eq!(req.id, "test_cred");
417        assert!(!req.active);
418        assert!(req.emergency);
419        assert!(req.config.is_some());
420        assert_eq!(req.backend, Some("pipelit".to_string()));
421    }
422
423    #[test]
424    fn test_update_credential_request_parse_empty() {
425        let json = r#"{}"#;
426
427        let req: UpdateCredentialRequest = serde_json::from_str(json).unwrap();
428        assert!(req.adapter.is_none());
429        assert!(req.token.is_none());
430        assert!(req.active.is_none());
431        assert!(req.emergency.is_none());
432        assert!(req.config.is_none());
433        assert!(req.backend.is_none());
434        assert!(req.route.is_none());
435    }
436
437    #[test]
438    fn test_update_credential_request_parse_partial() {
439        let json = r#"{
440            "active": true,
441            "token": "new_token"
442        }"#;
443
444        let req: UpdateCredentialRequest = serde_json::from_str(json).unwrap();
445        assert!(req.adapter.is_none());
446        assert_eq!(req.token, Some("new_token".to_string()));
447        assert_eq!(req.active, Some(true));
448        assert!(req.emergency.is_none());
449    }
450
451    #[test]
452    fn test_update_credential_request_parse_full() {
453        let json = r#"{
454            "adapter": "discord",
455            "token": "new_token",
456            "active": false,
457            "emergency": true,
458            "config": {"setting": "value"},
459            "backend": "opencode",
460            "route": {"new": "route"}
461        }"#;
462
463        let req: UpdateCredentialRequest = serde_json::from_str(json).unwrap();
464        assert_eq!(req.adapter, Some("discord".to_string()));
465        assert_eq!(req.token, Some("new_token".to_string()));
466        assert_eq!(req.active, Some(false));
467        assert_eq!(req.emergency, Some(true));
468        assert!(req.config.is_some());
469        assert_eq!(req.backend, Some("opencode".to_string()));
470        assert!(req.route.is_some());
471    }
472
473    #[test]
474    fn test_credential_response_serialize() {
475        let response = CredentialResponse {
476            id: "cred1".to_string(),
477            adapter: "telegram".to_string(),
478            active: true,
479            emergency: false,
480            config: Some(serde_json::json!({"key": "value"})),
481            backend: None,
482            route: serde_json::json!({"type": "default"}),
483            instance_status: Some("Running".to_string()),
484        };
485
486        let json = serde_json::to_string(&response).unwrap();
487        assert!(json.contains("\"id\":\"cred1\""));
488        assert!(json.contains("\"adapter\":\"telegram\""));
489        assert!(json.contains("\"active\":true"));
490        assert!(json.contains("\"emergency\":false"));
491        assert!(json.contains("\"instance_status\":\"Running\""));
492    }
493
494    #[test]
495    fn test_credential_response_serialize_minimal() {
496        let response = CredentialResponse {
497            id: "cred2".to_string(),
498            adapter: "generic".to_string(),
499            active: false,
500            emergency: true,
501            config: None,
502            backend: None,
503            route: serde_json::json!(null),
504            instance_status: None,
505        };
506
507        let json = serde_json::to_string(&response).unwrap();
508        assert!(json.contains("\"id\":\"cred2\""));
509        assert!(json.contains("\"config\":null"));
510        assert!(json.contains("\"instance_status\":null"));
511    }
512
513    #[test]
514    fn test_default_true() {
515        assert!(default_true());
516    }
517
518    // ==================== Debug Trait Tests ====================
519
520    #[test]
521    fn test_create_credential_request_debug() {
522        let req = CreateCredentialRequest {
523            id: "test".to_string(),
524            adapter: "telegram".to_string(),
525            token: "secret".to_string(),
526            active: true,
527            emergency: false,
528            config: None,
529            backend: None,
530            route: serde_json::json!({}),
531        };
532
533        let debug_str = format!("{:?}", req);
534        assert!(debug_str.contains("CreateCredentialRequest"));
535        assert!(debug_str.contains("test"));
536    }
537
538    #[test]
539    fn test_update_credential_request_debug() {
540        let req = UpdateCredentialRequest {
541            adapter: Some("discord".to_string()),
542            token: None,
543            active: Some(true),
544            emergency: None,
545            config: None,
546            backend: None,
547            route: None,
548        };
549
550        let debug_str = format!("{:?}", req);
551        assert!(debug_str.contains("UpdateCredentialRequest"));
552        assert!(debug_str.contains("discord"));
553    }
554
555    #[test]
556    fn test_credential_response_debug() {
557        let response = CredentialResponse {
558            id: "cred1".to_string(),
559            adapter: "telegram".to_string(),
560            active: true,
561            emergency: false,
562            config: None,
563            backend: None,
564            route: serde_json::json!({}),
565            instance_status: None,
566        };
567
568        let debug_str = format!("{:?}", response);
569        assert!(debug_str.contains("CredentialResponse"));
570        assert!(debug_str.contains("cred1"));
571    }
572
573    // ==================== Backend Name in Requests ====================
574
575    #[test]
576    fn test_create_request_with_backend_name() {
577        let json = r#"{
578            "id": "test",
579            "adapter": "telegram",
580            "token": "tok",
581            "route": {},
582            "backend": "pipelit"
583        }"#;
584
585        let req: CreateCredentialRequest = serde_json::from_str(json).unwrap();
586        assert_eq!(req.backend, Some("pipelit".to_string()));
587    }
588
589    #[test]
590    fn test_create_request_without_backend() {
591        let json = r#"{
592            "id": "test",
593            "adapter": "generic",
594            "token": "tok",
595            "route": {}
596        }"#;
597
598        let req: CreateCredentialRequest = serde_json::from_str(json).unwrap();
599        assert!(req.backend.is_none());
600    }
601
602    // ==================== Helper Function Tests ====================
603
604    #[tokio::test]
605    async fn test_set_skip_reload() {
606        use std::time::Instant;
607        use tokio::sync::RwLock;
608
609        // Create minimal AppState-like structure for testing
610        let skip_reload_until: RwLock<Option<Instant>> = RwLock::new(None);
611
612        // Initially should be None
613        assert!(skip_reload_until.read().await.is_none());
614
615        // Simulate set_skip_reload behavior
616        {
617            use std::time::Duration;
618            let mut skip_until = skip_reload_until.write().await;
619            *skip_until = Some(Instant::now() + Duration::from_secs(2));
620        }
621
622        // Should now be Some
623        let value = skip_reload_until.read().await;
624        assert!(value.is_some());
625        // Should be in the future
626        assert!(value.unwrap() > Instant::now());
627    }
628
629    // ==================== Config Validation Tests ====================
630
631    #[test]
632    fn test_credential_config_from_create_request() {
633        let req = CreateCredentialRequest {
634            id: "test_id".to_string(),
635            adapter: "telegram".to_string(),
636            token: "test_token".to_string(),
637            active: true,
638            emergency: true,
639            config: Some(serde_json::json!({"key": "value"})),
640            backend: Some("pipelit".to_string()),
641            route: serde_json::json!({"route": "data"}),
642        };
643
644        let cred_config = CredentialConfig {
645            adapter: req.adapter.clone(),
646            token: req.token.clone(),
647            active: req.active,
648            emergency: req.emergency,
649            config: req.config.clone(),
650            backend: req.backend.clone(),
651            route: req.route.clone(),
652        };
653
654        assert_eq!(cred_config.adapter, "telegram");
655        assert_eq!(cred_config.token, "test_token");
656        assert!(cred_config.active);
657        assert!(cred_config.emergency);
658        assert!(cred_config.config.is_some());
659        assert_eq!(cred_config.backend, Some("pipelit".to_string()));
660    }
661
662    #[test]
663    fn test_update_applies_partial_changes() {
664        let mut cred = CredentialConfig {
665            adapter: "telegram".to_string(),
666            token: "old_token".to_string(),
667            active: true,
668            emergency: false,
669            config: None,
670            backend: None,
671            route: serde_json::json!({"old": "route"}),
672        };
673
674        let update = UpdateCredentialRequest {
675            adapter: None,
676            token: Some("new_token".to_string()),
677            active: Some(false),
678            emergency: None,
679            config: None,
680            backend: None,
681            route: None,
682        };
683
684        // Apply updates (simulating update_credential logic)
685        if let Some(token) = update.token {
686            cred.token = token;
687        }
688        if let Some(active) = update.active {
689            cred.active = active;
690        }
691
692        assert_eq!(cred.adapter, "telegram"); // unchanged
693        assert_eq!(cred.token, "new_token"); // changed
694        assert!(!cred.active); // changed
695        assert!(!cred.emergency); // unchanged
696    }
697}