Skip to main content

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