mecha10_cli/services/
credentials.rs

1//! Credentials service for managing user authentication credentials
2//!
3//! Handles loading, saving, and deleting credentials stored at ~/.mecha10/credentials.json
4
5use crate::paths;
6use crate::types::credentials::{AuthError, Credentials};
7use anyhow::{Context, Result};
8use std::path::PathBuf;
9
10/// Default auth URL for production
11pub const DEFAULT_AUTH_URL: &str = "https://mecha.industries/api/auth";
12
13/// Environment variable name for custom auth URL
14pub const AUTH_URL_ENV_VAR: &str = "MECHA10_AUTH_URL";
15
16/// Get the auth URL, checking environment variable first
17///
18/// Priority:
19/// 1. MECHA10_AUTH_URL environment variable
20/// 2. DEFAULT_AUTH_URL constant
21pub fn get_auth_url() -> String {
22    std::env::var(AUTH_URL_ENV_VAR).unwrap_or_else(|_| DEFAULT_AUTH_URL.to_string())
23}
24
25/// Service for managing user credentials
26pub struct CredentialsService {
27    /// Path to credentials file
28    credentials_path: PathBuf,
29}
30
31impl CredentialsService {
32    /// Create a new CredentialsService with default path (~/.mecha10/credentials.json)
33    pub fn new() -> Self {
34        Self {
35            credentials_path: Self::default_credentials_path(),
36        }
37    }
38
39    /// Create a CredentialsService with a custom credentials path (for testing)
40    #[allow(dead_code)]
41    pub fn with_path(path: PathBuf) -> Self {
42        Self { credentials_path: path }
43    }
44
45    /// Get the default credentials path (~/.mecha10/credentials.json)
46    pub fn default_credentials_path() -> PathBuf {
47        paths::user::credentials_file()
48    }
49
50    /// Get the path to the credentials file
51    pub fn credentials_path(&self) -> &PathBuf {
52        &self.credentials_path
53    }
54
55    /// Load credentials from disk
56    ///
57    /// Returns None if credentials file doesn't exist
58    pub fn load(&self) -> Result<Option<Credentials>> {
59        if !self.credentials_path.exists() {
60            return Ok(None);
61        }
62
63        let content = std::fs::read_to_string(&self.credentials_path)
64            .with_context(|| format!("Failed to read credentials from {}", self.credentials_path.display()))?;
65
66        let credentials: Credentials = serde_json::from_str(&content).map_err(|e| AuthError::InvalidCredentials {
67            message: format!("Failed to parse credentials: {}", e),
68        })?;
69
70        Ok(Some(credentials))
71    }
72
73    /// Save credentials to disk
74    ///
75    /// Creates the ~/.mecha10 directory if it doesn't exist
76    pub fn save(&self, credentials: &Credentials) -> Result<()> {
77        // Create parent directory if needed
78        if let Some(parent) = self.credentials_path.parent() {
79            std::fs::create_dir_all(parent)
80                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
81        }
82
83        let content = serde_json::to_string_pretty(credentials).with_context(|| "Failed to serialize credentials")?;
84
85        std::fs::write(&self.credentials_path, content)
86            .with_context(|| format!("Failed to write credentials to {}", self.credentials_path.display()))?;
87
88        // Set file permissions to user-only on Unix
89        #[cfg(unix)]
90        {
91            use std::os::unix::fs::PermissionsExt;
92            let permissions = std::fs::Permissions::from_mode(0o600);
93            std::fs::set_permissions(&self.credentials_path, permissions)
94                .with_context(|| "Failed to set credentials file permissions")?;
95        }
96
97        Ok(())
98    }
99
100    /// Delete credentials from disk
101    pub fn delete(&self) -> Result<()> {
102        if self.credentials_path.exists() {
103            std::fs::remove_file(&self.credentials_path)
104                .with_context(|| format!("Failed to delete credentials from {}", self.credentials_path.display()))?;
105        }
106        Ok(())
107    }
108
109    /// Get API key from stored credentials
110    ///
111    /// Returns None if not logged in or credentials are invalid
112    pub fn get_api_key(&self) -> Result<Option<String>> {
113        match self.load()? {
114            Some(creds) if creds.is_valid() => Ok(Some(creds.api_key)),
115            _ => Ok(None),
116        }
117    }
118
119    /// Check if user is logged in (has valid credentials)
120    pub fn is_logged_in(&self) -> bool {
121        self.load()
122            .map(|creds| creds.map(|c| c.is_valid()).unwrap_or(false))
123            .unwrap_or(false)
124    }
125
126    /// Get user info if logged in
127    pub fn get_user_info(&self) -> Result<Option<(String, String, Option<String>)>> {
128        match self.load()? {
129            Some(creds) if creds.is_valid() => Ok(Some((creds.user_id, creds.email, creds.name))),
130            _ => Ok(None),
131        }
132    }
133}
134
135impl Default for CredentialsService {
136    fn default() -> Self {
137        Self::new()
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use chrono::Utc;
145    use tempfile::TempDir;
146
147    fn create_test_credentials() -> Credentials {
148        Credentials {
149            api_key: "mecha_test123abc456def".to_string(),
150            user_id: "usr_test123".to_string(),
151            email: "test@example.com".to_string(),
152            name: Some("Test User".to_string()),
153            authenticated_at: Utc::now(),
154            auth_url: DEFAULT_AUTH_URL.to_string(),
155        }
156    }
157
158    #[test]
159    fn test_save_and_load_credentials() {
160        let temp_dir = TempDir::new().unwrap();
161        let creds_path = temp_dir.path().join("credentials.json");
162
163        let service = CredentialsService::with_path(creds_path);
164        let creds = create_test_credentials();
165
166        // Save
167        service.save(&creds).unwrap();
168
169        // Load
170        let loaded = service.load().unwrap().unwrap();
171        assert_eq!(loaded.api_key, creds.api_key);
172        assert_eq!(loaded.user_id, creds.user_id);
173        assert_eq!(loaded.email, creds.email);
174    }
175
176    #[test]
177    fn test_delete_credentials() {
178        let temp_dir = TempDir::new().unwrap();
179        let creds_path = temp_dir.path().join("credentials.json");
180
181        let service = CredentialsService::with_path(creds_path.clone());
182        let creds = create_test_credentials();
183
184        // Save then delete
185        service.save(&creds).unwrap();
186        assert!(creds_path.exists());
187
188        service.delete().unwrap();
189        assert!(!creds_path.exists());
190
191        // Load should return None
192        let loaded = service.load().unwrap();
193        assert!(loaded.is_none());
194    }
195
196    #[test]
197    fn test_get_api_key() {
198        let temp_dir = TempDir::new().unwrap();
199        let creds_path = temp_dir.path().join("credentials.json");
200
201        let service = CredentialsService::with_path(creds_path);
202        let creds = create_test_credentials();
203
204        // No credentials
205        assert!(service.get_api_key().unwrap().is_none());
206
207        // With credentials
208        service.save(&creds).unwrap();
209        assert_eq!(service.get_api_key().unwrap().unwrap(), creds.api_key);
210    }
211
212    #[test]
213    fn test_is_logged_in() {
214        let temp_dir = TempDir::new().unwrap();
215        let creds_path = temp_dir.path().join("credentials.json");
216
217        let service = CredentialsService::with_path(creds_path);
218        let creds = create_test_credentials();
219
220        // Not logged in
221        assert!(!service.is_logged_in());
222
223        // Logged in
224        service.save(&creds).unwrap();
225        assert!(service.is_logged_in());
226    }
227
228    #[test]
229    fn test_credentials_validity() {
230        let valid = Credentials {
231            api_key: "mecha_valid123".to_string(),
232            user_id: "usr_test".to_string(),
233            email: "test@example.com".to_string(),
234            name: None,
235            authenticated_at: Utc::now(),
236            auth_url: DEFAULT_AUTH_URL.to_string(),
237        };
238        assert!(valid.is_valid());
239
240        let invalid_prefix = Credentials {
241            api_key: "invalid_key".to_string(),
242            ..valid.clone()
243        };
244        assert!(!invalid_prefix.is_valid());
245
246        let empty_key = Credentials {
247            api_key: "".to_string(),
248            ..valid.clone()
249        };
250        assert!(!empty_key.is_valid());
251    }
252}