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