Skip to main content

slack_rs/profile/
token_store.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use thiserror::Error;
6
7#[derive(Debug, Error)]
8pub enum TokenStoreError {
9    #[error("Token not found for key: {0}")]
10    NotFound(String),
11    #[error("Failed to store token: {0}")]
12    StoreFailed(String),
13    #[error("Failed to delete token: {0}")]
14    DeleteFailed(String),
15    #[error("IO error: {0}")]
16    IoError(String),
17    #[error("Keyring backend unavailable: {0}\n\nTo resolve this:\n  1. Unlock your OS keyring/keychain (e.g., login to your desktop environment)\n  2. OR use file-based storage: export SLACKRS_TOKEN_STORE=file")]
18    KeyringUnavailable(String),
19    #[error("Invalid token store backend '{0}'. Valid options: 'keyring', 'file'")]
20    InvalidBackend(String),
21}
22
23pub type Result<T> = std::result::Result<T, TokenStoreError>;
24
25/// Trait for storing and retrieving tokens securely
26pub trait TokenStore: Send + Sync {
27    /// Store a token with the given key
28    fn set(&self, key: &str, token: &str) -> Result<()>;
29
30    /// Retrieve a token by key
31    fn get(&self, key: &str) -> Result<String>;
32
33    /// Delete a token by key
34    fn delete(&self, key: &str) -> Result<()>;
35
36    /// Check if a token exists for the given key
37    fn exists(&self, key: &str) -> bool;
38}
39
40/// In-memory implementation of TokenStore for testing
41#[derive(Debug, Clone)]
42pub struct InMemoryTokenStore {
43    tokens: Arc<Mutex<HashMap<String, String>>>,
44}
45
46impl InMemoryTokenStore {
47    pub fn new() -> Self {
48        Self {
49            tokens: Arc::new(Mutex::new(HashMap::new())),
50        }
51    }
52}
53
54impl Default for InMemoryTokenStore {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl TokenStore for InMemoryTokenStore {
61    fn set(&self, key: &str, token: &str) -> Result<()> {
62        let mut tokens = self.tokens.lock().unwrap();
63        tokens.insert(key.to_string(), token.to_string());
64        Ok(())
65    }
66
67    fn get(&self, key: &str) -> Result<String> {
68        let tokens = self.tokens.lock().unwrap();
69        tokens
70            .get(key)
71            .cloned()
72            .ok_or_else(|| TokenStoreError::NotFound(key.to_string()))
73    }
74
75    fn delete(&self, key: &str) -> Result<()> {
76        let mut tokens = self.tokens.lock().unwrap();
77        tokens
78            .remove(key)
79            .ok_or_else(|| TokenStoreError::NotFound(key.to_string()))?;
80        Ok(())
81    }
82
83    fn exists(&self, key: &str) -> bool {
84        let tokens = self.tokens.lock().unwrap();
85        tokens.contains_key(key)
86    }
87}
88
89/// File-based implementation of TokenStore
90/// Stores tokens in ~/.config/slack-rs/tokens.json with restricted permissions (0600)
91#[derive(Debug, Clone)]
92pub struct FileTokenStore {
93    file_path: PathBuf,
94    tokens: Arc<Mutex<HashMap<String, String>>>,
95}
96
97impl FileTokenStore {
98    /// Create a new FileTokenStore with the default path (~/.config/slack-rs/tokens.json)
99    pub fn new() -> Result<Self> {
100        let file_path = Self::default_path()?;
101        Self::with_path(file_path)
102    }
103
104    /// Create a FileTokenStore with a custom path
105    pub fn with_path(file_path: PathBuf) -> Result<Self> {
106        // Ensure parent directory exists
107        if let Some(parent) = file_path.parent() {
108            fs::create_dir_all(parent).map_err(|e| {
109                TokenStoreError::IoError(format!("Failed to create directory: {}", e))
110            })?;
111        }
112
113        // Load existing tokens or create empty map
114        let tokens = if file_path.exists() {
115            Self::load_tokens(&file_path)?
116        } else {
117            HashMap::new()
118        };
119
120        Ok(Self {
121            file_path,
122            tokens: Arc::new(Mutex::new(tokens)),
123        })
124    }
125
126    /// Get the default path for the tokens file
127    /// Can be overridden with SLACK_RS_TOKENS_PATH environment variable (useful for testing)
128    pub fn default_path() -> Result<PathBuf> {
129        // Check for environment variable override (useful for testing)
130        if let Ok(path) = std::env::var("SLACK_RS_TOKENS_PATH") {
131            return Ok(PathBuf::from(path));
132        }
133
134        // Use cross-platform home directory detection
135        let home = directories::BaseDirs::new()
136            .ok_or_else(|| {
137                TokenStoreError::IoError("Failed to determine home directory".to_string())
138            })?
139            .home_dir()
140            .to_path_buf();
141
142        // Use separate join calls to ensure consistent path separators on Windows
143        let config_dir = home.join(".config").join("slack-rs");
144        Ok(config_dir.join("tokens.json"))
145    }
146
147    /// Load tokens from file
148    fn load_tokens(path: &Path) -> Result<HashMap<String, String>> {
149        let content = fs::read_to_string(path)
150            .map_err(|e| TokenStoreError::IoError(format!("Failed to read tokens file: {}", e)))?;
151
152        serde_json::from_str(&content)
153            .map_err(|e| TokenStoreError::IoError(format!("Failed to parse tokens file: {}", e)))
154    }
155
156    /// Save tokens to file with restricted permissions
157    fn save_tokens(&self) -> Result<()> {
158        let tokens = self.tokens.lock().unwrap();
159        let content = serde_json::to_string_pretty(&*tokens).map_err(|e| {
160            TokenStoreError::StoreFailed(format!("Failed to serialize tokens: {}", e))
161        })?;
162
163        // Write to file
164        fs::write(&self.file_path, content).map_err(|e| {
165            TokenStoreError::StoreFailed(format!("Failed to write tokens file: {}", e))
166        })?;
167
168        // Set file permissions to 0600 (owner read/write only) on Unix systems
169        #[cfg(unix)]
170        {
171            use std::os::unix::fs::PermissionsExt;
172            let permissions = fs::Permissions::from_mode(0o600);
173            fs::set_permissions(&self.file_path, permissions).map_err(|e| {
174                TokenStoreError::StoreFailed(format!("Failed to set file permissions: {}", e))
175            })?;
176        }
177
178        Ok(())
179    }
180}
181
182impl Default for FileTokenStore {
183    fn default() -> Self {
184        Self::new().expect("Failed to create FileTokenStore")
185    }
186}
187
188impl TokenStore for FileTokenStore {
189    fn set(&self, key: &str, token: &str) -> Result<()> {
190        let mut tokens = self.tokens.lock().unwrap();
191        tokens.insert(key.to_string(), token.to_string());
192        drop(tokens); // Release lock before saving
193        self.save_tokens()
194    }
195
196    fn get(&self, key: &str) -> Result<String> {
197        let tokens = self.tokens.lock().unwrap();
198        tokens
199            .get(key)
200            .cloned()
201            .ok_or_else(|| TokenStoreError::NotFound(key.to_string()))
202    }
203
204    fn delete(&self, key: &str) -> Result<()> {
205        let mut tokens = self.tokens.lock().unwrap();
206        tokens
207            .remove(key)
208            .ok_or_else(|| TokenStoreError::NotFound(key.to_string()))?;
209        drop(tokens); // Release lock before saving
210        self.save_tokens()
211    }
212
213    fn exists(&self, key: &str) -> bool {
214        let tokens = self.tokens.lock().unwrap();
215        tokens.contains_key(key)
216    }
217}
218
219/// Keyring-based implementation of TokenStore
220pub struct KeyringTokenStore {
221    service: String,
222}
223
224impl KeyringTokenStore {
225    /// Create a new KeyringTokenStore with a custom service name
226    /// For production use, prefer `default()` which uses the standard service name
227    pub fn new(service: impl Into<String>) -> Self {
228        Self {
229            service: service.into(),
230        }
231    }
232
233    /// Create a KeyringTokenStore with the default service name "slack-rs"
234    /// This is the recommended way to create a KeyringTokenStore for production use
235    pub fn default_service() -> Self {
236        Self {
237            service: "slack-rs".to_string(),
238        }
239    }
240}
241
242impl TokenStore for KeyringTokenStore {
243    fn set(&self, key: &str, token: &str) -> Result<()> {
244        let entry = keyring::Entry::new(&self.service, key)
245            .map_err(|e| TokenStoreError::StoreFailed(e.to_string()))?;
246        entry
247            .set_password(token)
248            .map_err(|e| TokenStoreError::StoreFailed(e.to_string()))?;
249        Ok(())
250    }
251
252    fn get(&self, key: &str) -> Result<String> {
253        let entry = keyring::Entry::new(&self.service, key)
254            .map_err(|e| TokenStoreError::NotFound(e.to_string()))?;
255        entry
256            .get_password()
257            .map_err(|_| TokenStoreError::NotFound(key.to_string()))
258    }
259
260    fn delete(&self, key: &str) -> Result<()> {
261        let entry = keyring::Entry::new(&self.service, key)
262            .map_err(|e| TokenStoreError::DeleteFailed(e.to_string()))?;
263        entry
264            .delete_credential()
265            .map_err(|e| TokenStoreError::DeleteFailed(e.to_string()))?;
266        Ok(())
267    }
268
269    fn exists(&self, key: &str) -> bool {
270        if let Ok(entry) = keyring::Entry::new(&self.service, key) {
271            entry.get_password().is_ok()
272        } else {
273            false
274        }
275    }
276}
277
278/// Helper function to create a token key from team_id and user_id
279pub fn make_token_key(team_id: &str, user_id: &str) -> String {
280    format!("{}:{}", team_id, user_id)
281}
282
283/// Helper function to create an OAuth client secret key for a profile
284pub fn make_oauth_client_secret_key(profile_name: &str) -> String {
285    format!("oauth-client-secret:{}", profile_name)
286}
287
288/// Store OAuth client secret in the token store
289pub fn store_oauth_client_secret(
290    token_store: &dyn TokenStore,
291    profile_name: &str,
292    client_secret: &str,
293) -> Result<()> {
294    let key = make_oauth_client_secret_key(profile_name);
295    token_store.set(&key, client_secret)
296}
297
298/// Retrieve OAuth client secret from the token store
299pub fn get_oauth_client_secret(token_store: &dyn TokenStore, profile_name: &str) -> Result<String> {
300    let key = make_oauth_client_secret_key(profile_name);
301    token_store.get(&key)
302}
303
304/// Delete OAuth client secret from the token store
305pub fn delete_oauth_client_secret(token_store: &dyn TokenStore, profile_name: &str) -> Result<()> {
306    let key = make_oauth_client_secret_key(profile_name);
307    token_store.delete(&key)
308}
309
310/// Token store backend types
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum TokenStoreBackend {
313    Keyring,
314    File,
315}
316
317impl std::str::FromStr for TokenStoreBackend {
318    type Err = TokenStoreError;
319
320    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
321        match s.to_lowercase().as_str() {
322            "keyring" => Ok(TokenStoreBackend::Keyring),
323            "file" => Ok(TokenStoreBackend::File),
324            _ => Err(TokenStoreError::InvalidBackend(s.to_string())),
325        }
326    }
327}
328
329/// Resolve token store backend from environment variable
330///
331/// Checks SLACKRS_TOKEN_STORE environment variable:
332/// - "keyring" => Keyring backend (default)
333/// - "file" => File backend
334/// - unset => Keyring backend (default)
335/// - invalid value => Error with guidance
336pub fn resolve_token_store_backend() -> Result<TokenStoreBackend> {
337    match std::env::var("SLACKRS_TOKEN_STORE") {
338        Ok(value) => value.parse(),
339        Err(_) => Ok(TokenStoreBackend::Keyring), // Default to Keyring
340    }
341}
342
343/// Create a token store based on the resolved backend
344///
345/// This function:
346/// 1. Resolves the backend via SLACKRS_TOKEN_STORE (defaulting to Keyring)
347/// 2. For Keyring: attempts initialization and returns KeyringUnavailable error if it fails
348/// 3. For File: creates FileTokenStore with default path
349///
350/// Returns Box<dyn TokenStore> for runtime polymorphism
351pub fn create_token_store() -> Result<Box<dyn TokenStore>> {
352    let backend = resolve_token_store_backend()?;
353
354    match backend {
355        TokenStoreBackend::Keyring => {
356            // Try to create keyring store
357            let store = KeyringTokenStore::default_service();
358
359            // Test keyring availability by attempting a test operation
360            // We use a unique test key to avoid conflicts
361            let test_key = "__slackrs_keyring_test__";
362
363            // Try to set and immediately delete a test value
364            match store.set(test_key, "test") {
365                Ok(_) => {
366                    let _ = store.delete(test_key); // Clean up test key
367                    Ok(Box::new(store))
368                }
369                Err(e) => {
370                    // Keyring is unavailable - return error with guidance
371                    Err(TokenStoreError::KeyringUnavailable(e.to_string()))
372                }
373            }
374        }
375        TokenStoreBackend::File => {
376            let store = FileTokenStore::new()?;
377            Ok(Box::new(store))
378        }
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn test_in_memory_token_store_set_get() {
388        let store = InMemoryTokenStore::new();
389        let key = "T123:U456";
390        let token = "xoxb-test-token";
391
392        store.set(key, token).unwrap();
393        assert_eq!(store.get(key).unwrap(), token);
394    }
395
396    #[test]
397    fn test_in_memory_token_store_delete() {
398        let store = InMemoryTokenStore::new();
399        let key = "T123:U456";
400        let token = "xoxb-test-token";
401
402        store.set(key, token).unwrap();
403        assert!(store.exists(key));
404
405        store.delete(key).unwrap();
406        assert!(!store.exists(key));
407        assert!(store.get(key).is_err());
408    }
409
410    #[test]
411    fn test_in_memory_token_store_not_found() {
412        let store = InMemoryTokenStore::new();
413        let result = store.get("nonexistent");
414        assert!(result.is_err());
415        match result {
416            Err(TokenStoreError::NotFound(_)) => {}
417            _ => panic!("Expected NotFound error"),
418        }
419    }
420
421    #[test]
422    fn test_in_memory_token_store_exists() {
423        let store = InMemoryTokenStore::new();
424        let key = "T123:U456";
425
426        assert!(!store.exists(key));
427        store.set(key, "token").unwrap();
428        assert!(store.exists(key));
429    }
430
431    #[test]
432    fn test_make_token_key() {
433        let key = make_token_key("T123", "U456");
434        assert_eq!(key, "T123:U456");
435    }
436
437    #[test]
438    fn test_in_memory_token_store_multiple_keys() {
439        let store = InMemoryTokenStore::new();
440
441        store.set("T1:U1", "token1").unwrap();
442        store.set("T2:U2", "token2").unwrap();
443
444        assert_eq!(store.get("T1:U1").unwrap(), "token1");
445        assert_eq!(store.get("T2:U2").unwrap(), "token2");
446    }
447
448    #[test]
449    fn test_keyring_token_store_default_service() {
450        let store = KeyringTokenStore::default_service();
451        assert_eq!(store.service, "slack-rs");
452    }
453
454    #[test]
455    fn test_make_oauth_client_secret_key() {
456        let key = make_oauth_client_secret_key("default");
457        assert_eq!(key, "oauth-client-secret:default");
458    }
459
460    #[test]
461    fn test_store_and_get_oauth_client_secret() {
462        let store = InMemoryTokenStore::new();
463        let profile_name = "test-profile";
464        let client_secret = "test-secret-123";
465
466        store_oauth_client_secret(&store, profile_name, client_secret).unwrap();
467        let retrieved = get_oauth_client_secret(&store, profile_name).unwrap();
468        assert_eq!(retrieved, client_secret);
469    }
470
471    #[test]
472    fn test_delete_oauth_client_secret() {
473        let store = InMemoryTokenStore::new();
474        let profile_name = "test-profile";
475        let client_secret = "test-secret-123";
476
477        store_oauth_client_secret(&store, profile_name, client_secret).unwrap();
478        assert!(get_oauth_client_secret(&store, profile_name).is_ok());
479
480        delete_oauth_client_secret(&store, profile_name).unwrap();
481        assert!(get_oauth_client_secret(&store, profile_name).is_err());
482    }
483
484    #[test]
485    fn test_file_token_store_set_get() {
486        use tempfile::TempDir;
487
488        let temp_dir = TempDir::new().unwrap();
489        let file_path = temp_dir.path().join("tokens.json");
490        let store = FileTokenStore::with_path(file_path.clone()).unwrap();
491
492        let key = "T123:U456";
493        let token = "xoxb-test-token";
494
495        store.set(key, token).unwrap();
496        assert_eq!(store.get(key).unwrap(), token);
497
498        // Verify file exists
499        assert!(file_path.exists());
500
501        // Verify file permissions on Unix
502        #[cfg(unix)]
503        {
504            use std::os::unix::fs::PermissionsExt;
505            let metadata = fs::metadata(&file_path).unwrap();
506            let permissions = metadata.permissions();
507            assert_eq!(permissions.mode() & 0o777, 0o600);
508        }
509    }
510
511    #[test]
512    fn test_file_token_store_delete() {
513        use tempfile::TempDir;
514
515        let temp_dir = TempDir::new().unwrap();
516        let file_path = temp_dir.path().join("tokens.json");
517        let store = FileTokenStore::with_path(file_path).unwrap();
518
519        let key = "T123:U456";
520        let token = "xoxb-test-token";
521
522        store.set(key, token).unwrap();
523        assert!(store.exists(key));
524
525        store.delete(key).unwrap();
526        assert!(!store.exists(key));
527        assert!(store.get(key).is_err());
528    }
529
530    #[test]
531    fn test_file_token_store_persistence() {
532        use tempfile::TempDir;
533
534        let temp_dir = TempDir::new().unwrap();
535        let file_path = temp_dir.path().join("tokens.json");
536
537        // Create store and save token
538        {
539            let store = FileTokenStore::with_path(file_path.clone()).unwrap();
540            store.set("T123:U456", "xoxb-test-token").unwrap();
541        }
542
543        // Create new store instance and verify token persists
544        {
545            let store = FileTokenStore::with_path(file_path).unwrap();
546            assert_eq!(store.get("T123:U456").unwrap(), "xoxb-test-token");
547        }
548    }
549
550    #[test]
551    fn test_file_token_store_multiple_keys() {
552        use tempfile::TempDir;
553
554        let temp_dir = TempDir::new().unwrap();
555        let file_path = temp_dir.path().join("tokens.json");
556        let store = FileTokenStore::with_path(file_path).unwrap();
557
558        store.set("T1:U1", "token1").unwrap();
559        store.set("T2:U2", "token2").unwrap();
560        store
561            .set("oauth-client-secret:default", "secret123")
562            .unwrap();
563
564        assert_eq!(store.get("T1:U1").unwrap(), "token1");
565        assert_eq!(store.get("T2:U2").unwrap(), "token2");
566        assert_eq!(
567            store.get("oauth-client-secret:default").unwrap(),
568            "secret123"
569        );
570    }
571
572    #[test]
573    fn test_file_token_store_not_found() {
574        use tempfile::TempDir;
575
576        let temp_dir = TempDir::new().unwrap();
577        let file_path = temp_dir.path().join("tokens.json");
578        let store = FileTokenStore::with_path(file_path).unwrap();
579
580        let result = store.get("nonexistent");
581        assert!(result.is_err());
582        match result {
583            Err(TokenStoreError::NotFound(_)) => {}
584            _ => panic!("Expected NotFound error"),
585        }
586    }
587
588    #[test]
589    #[serial_test::serial]
590    fn test_resolve_token_store_backend_default() {
591        // Clear environment variable
592        std::env::remove_var("SLACKRS_TOKEN_STORE");
593
594        let backend = resolve_token_store_backend().unwrap();
595        assert_eq!(backend, TokenStoreBackend::Keyring);
596    }
597
598    #[test]
599    #[serial_test::serial]
600    fn test_resolve_token_store_backend_keyring() {
601        std::env::set_var("SLACKRS_TOKEN_STORE", "keyring");
602
603        let backend = resolve_token_store_backend().unwrap();
604        assert_eq!(backend, TokenStoreBackend::Keyring);
605
606        std::env::remove_var("SLACKRS_TOKEN_STORE");
607    }
608
609    #[test]
610    #[serial_test::serial]
611    fn test_resolve_token_store_backend_file() {
612        std::env::set_var("SLACKRS_TOKEN_STORE", "file");
613
614        let backend = resolve_token_store_backend().unwrap();
615        assert_eq!(backend, TokenStoreBackend::File);
616
617        std::env::remove_var("SLACKRS_TOKEN_STORE");
618    }
619
620    #[test]
621    #[serial_test::serial]
622    fn test_resolve_token_store_backend_case_insensitive() {
623        std::env::set_var("SLACKRS_TOKEN_STORE", "KEYRING");
624        assert_eq!(
625            resolve_token_store_backend().unwrap(),
626            TokenStoreBackend::Keyring
627        );
628
629        std::env::set_var("SLACKRS_TOKEN_STORE", "File");
630        assert_eq!(
631            resolve_token_store_backend().unwrap(),
632            TokenStoreBackend::File
633        );
634
635        std::env::remove_var("SLACKRS_TOKEN_STORE");
636    }
637
638    #[test]
639    #[serial_test::serial]
640    fn test_resolve_token_store_backend_invalid() {
641        std::env::set_var("SLACKRS_TOKEN_STORE", "invalid");
642
643        let result = resolve_token_store_backend();
644        assert!(result.is_err());
645        match result {
646            Err(TokenStoreError::InvalidBackend(backend)) => {
647                assert_eq!(backend, "invalid");
648            }
649            _ => panic!("Expected InvalidBackend error"),
650        }
651
652        std::env::remove_var("SLACKRS_TOKEN_STORE");
653    }
654
655    #[test]
656    #[serial_test::serial]
657    fn test_create_token_store_file_backend() {
658        use tempfile::TempDir;
659
660        let temp_dir = TempDir::new().unwrap();
661        let tokens_path = temp_dir.path().join("tokens.json");
662        std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
663        std::env::set_var("SLACKRS_TOKEN_STORE", "file");
664
665        let store = create_token_store().unwrap();
666
667        // Test that the store works
668        store.set("test_key", "test_value").unwrap();
669        assert_eq!(store.get("test_key").unwrap(), "test_value");
670
671        std::env::remove_var("SLACKRS_TOKEN_STORE");
672        std::env::remove_var("SLACK_RS_TOKENS_PATH");
673    }
674
675    #[test]
676    fn test_token_store_backend_parse() {
677        use std::str::FromStr;
678
679        assert_eq!(
680            TokenStoreBackend::from_str("keyring").unwrap(),
681            TokenStoreBackend::Keyring
682        );
683        assert_eq!(
684            TokenStoreBackend::from_str("file").unwrap(),
685            TokenStoreBackend::File
686        );
687        assert_eq!(
688            TokenStoreBackend::from_str("KEYRING").unwrap(),
689            TokenStoreBackend::Keyring
690        );
691        assert!(TokenStoreBackend::from_str("invalid").is_err());
692    }
693
694    #[test]
695    #[serial_test::serial]
696    fn test_keyring_unavailable_error_message() {
697        // Test that KeyringUnavailable error contains guidance
698        let err = TokenStoreError::KeyringUnavailable("test error".to_string());
699        let err_msg = err.to_string();
700
701        // Verify error message contains guidance
702        assert!(err_msg.contains("Keyring backend unavailable"));
703        assert!(err_msg.contains("SLACKRS_TOKEN_STORE=file"));
704        assert!(err_msg.contains("Unlock your OS keyring"));
705    }
706
707    #[test]
708    fn test_invalid_backend_error_message() {
709        let err = TokenStoreError::InvalidBackend("badvalue".to_string());
710        let err_msg = err.to_string();
711
712        // Verify error message lists valid options
713        assert!(err_msg.contains("Invalid token store backend 'badvalue'"));
714        assert!(err_msg.contains("keyring"));
715        assert!(err_msg.contains("file"));
716    }
717
718    /// Test that demonstrates Keyring locked/interaction-required behavior
719    ///
720    /// This test verifies that when Keyring requires user interaction (e.g., locked):
721    /// 1. create_token_store() fails with KeyringUnavailable error
722    /// 2. The error message contains actionable guidance
723    /// 3. No retry or prompt loop occurs (fail fast)
724    ///
725    /// Note: This is a mock/stub test since we can't reliably simulate a locked keyring
726    /// in CI. The actual behavior is tested by create_token_store()'s test operation.
727    #[test]
728    #[serial_test::serial]
729    fn test_keyring_locked_interaction_required() {
730        // Clear any file backend override to ensure we test keyring path
731        std::env::remove_var("SLACKRS_TOKEN_STORE");
732        std::env::remove_var("SLACK_RS_TOKENS_PATH");
733
734        // Try to create a keyring token store
735        // This will fail if keyring is unavailable (locked, not configured, etc.)
736        let result = create_token_store();
737
738        // If keyring is available on this system, test passes
739        // If keyring is NOT available, verify error handling is correct
740        match result {
741            Ok(_) => {
742                // Keyring is available - test passes
743                // (This is the expected case on developer machines with unlocked keychains)
744            }
745            Err(TokenStoreError::KeyringUnavailable(msg)) => {
746                // Keyring is unavailable - verify error message has guidance
747                let err_str = TokenStoreError::KeyringUnavailable(msg.clone()).to_string();
748                assert!(
749                    err_str.contains("SLACKRS_TOKEN_STORE=file"),
750                    "Error should suggest file fallback: {}",
751                    err_str
752                );
753                assert!(
754                    err_str.contains("Unlock your OS keyring") || err_str.contains("keyring"),
755                    "Error should mention keyring: {}",
756                    err_str
757                );
758            }
759            Err(e) => {
760                panic!("Unexpected error type: {:?}", e);
761            }
762        }
763    }
764
765    /// Test that file mode works correctly when explicitly specified
766    /// This ensures users have a fallback when Keyring is unavailable
767    #[test]
768    #[serial_test::serial]
769    fn test_file_mode_fallback_when_keyring_unavailable() {
770        use tempfile::TempDir;
771
772        let temp_dir = TempDir::new().unwrap();
773        let tokens_path = temp_dir.path().join("tokens.json");
774
775        // Set environment to use file backend
776        std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
777        std::env::set_var("SLACKRS_TOKEN_STORE", "file");
778
779        // This should succeed even if keyring is unavailable
780        let store = create_token_store().expect("File backend should work");
781
782        // Verify it works
783        store.set("test", "value").unwrap();
784        assert_eq!(store.get("test").unwrap(), "value");
785
786        std::env::remove_var("SLACKRS_TOKEN_STORE");
787        std::env::remove_var("SLACK_RS_TOKENS_PATH");
788    }
789
790    /// Test that file mode uses existing tokens.json path and key format
791    /// This verifies backward compatibility with existing token storage
792    #[test]
793    #[serial_test::serial]
794    fn test_file_mode_uses_existing_path_and_key_format() {
795        use tempfile::TempDir;
796
797        let temp_dir = TempDir::new().unwrap();
798        let tokens_path = temp_dir.path().join("tokens.json");
799
800        // Set environment to use file backend with custom path
801        std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
802        std::env::set_var("SLACKRS_TOKEN_STORE", "file");
803
804        let store = create_token_store().expect("File backend should work");
805
806        // Test token key format: {team_id}:{user_id}
807        let token_key = make_token_key("T123", "U456");
808        assert_eq!(token_key, "T123:U456");
809        store.set(&token_key, "xoxb-test-token").unwrap();
810        assert_eq!(store.get(&token_key).unwrap(), "xoxb-test-token");
811
812        // Test OAuth client secret key format: oauth-client-secret:{profile_name}
813        let secret_key = make_oauth_client_secret_key("default");
814        assert_eq!(secret_key, "oauth-client-secret:default");
815        store.set(&secret_key, "test-secret").unwrap();
816        assert_eq!(store.get(&secret_key).unwrap(), "test-secret");
817
818        // Verify the file exists and contains both keys
819        assert!(tokens_path.exists());
820        let content = std::fs::read_to_string(&tokens_path).unwrap();
821        assert!(content.contains("T123:U456"));
822        assert!(content.contains("oauth-client-secret:default"));
823
824        std::env::remove_var("SLACKRS_TOKEN_STORE");
825        std::env::remove_var("SLACK_RS_TOKENS_PATH");
826    }
827
828    /// Test default path for FileTokenStore
829    /// Verifies that FileTokenStore uses ~/.config/slack-rs/tokens.json by default
830    #[test]
831    #[serial_test::serial]
832    fn test_file_token_store_default_path() {
833        // Clear environment override to test actual default
834        std::env::remove_var("SLACK_RS_TOKENS_PATH");
835
836        let default_path = FileTokenStore::default_path().unwrap();
837        let path_str = default_path.to_string_lossy();
838
839        // Should contain .config/slack-rs/tokens.json
840        assert!(
841            path_str.contains(".config/slack-rs/tokens.json")
842                || path_str.contains(".config\\slack-rs\\tokens.json"),
843            "Default path should be ~/.config/slack-rs/tokens.json, got: {}",
844            path_str
845        );
846    }
847
848    /// Comprehensive test for unified credential storage policy
849    ///
850    /// This test verifies the complete specification:
851    /// 1. Default backend is Keyring
852    /// 2. File backend can be explicitly selected via SLACKRS_TOKEN_STORE=file
853    /// 3. Both backends use the same key format (team_id:user_id for tokens, oauth-client-secret:profile for secrets)
854    /// 4. InMemoryTokenStore can be used for testing with same key format
855    #[test]
856    #[serial_test::serial]
857    fn test_unified_credential_storage_policy() {
858        use tempfile::TempDir;
859
860        // Test 1: Default is Keyring
861        std::env::remove_var("SLACKRS_TOKEN_STORE");
862        let backend = resolve_token_store_backend().unwrap();
863        assert_eq!(
864            backend,
865            TokenStoreBackend::Keyring,
866            "Default backend should be Keyring"
867        );
868
869        // Test 2: Can explicitly select File backend
870        std::env::set_var("SLACKRS_TOKEN_STORE", "file");
871        let backend = resolve_token_store_backend().unwrap();
872        assert_eq!(
873            backend,
874            TokenStoreBackend::File,
875            "Should be able to select File backend"
876        );
877
878        // Test 3: Same key format across all backends
879        let temp_dir = TempDir::new().unwrap();
880        let tokens_path = temp_dir.path().join("tokens.json");
881        std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
882
883        // Test with InMemoryTokenStore (for testing)
884        let memory_store = InMemoryTokenStore::new();
885
886        // Test with FileTokenStore
887        let file_store = FileTokenStore::with_path(tokens_path.clone()).unwrap();
888
889        // Both should use the same key format
890        let token_key = make_token_key("T123", "U456");
891        let secret_key = make_oauth_client_secret_key("default");
892
893        // Store in memory store
894        memory_store.set(&token_key, "token1").unwrap();
895        memory_store.set(&secret_key, "secret1").unwrap();
896
897        // Store in file store
898        file_store.set(&token_key, "token2").unwrap();
899        file_store.set(&secret_key, "secret2").unwrap();
900
901        // Verify retrieval works with same keys
902        assert_eq!(memory_store.get(&token_key).unwrap(), "token1");
903        assert_eq!(memory_store.get(&secret_key).unwrap(), "secret1");
904        assert_eq!(file_store.get(&token_key).unwrap(), "token2");
905        assert_eq!(file_store.get(&secret_key).unwrap(), "secret2");
906
907        // Test 4: Verify helper functions work across all backends
908        store_oauth_client_secret(&memory_store, "test", "secret123").unwrap();
909        assert_eq!(
910            get_oauth_client_secret(&memory_store, "test").unwrap(),
911            "secret123"
912        );
913
914        store_oauth_client_secret(&file_store, "test", "secret456").unwrap();
915        assert_eq!(
916            get_oauth_client_secret(&file_store, "test").unwrap(),
917            "secret456"
918        );
919
920        // Clean up
921        std::env::remove_var("SLACKRS_TOKEN_STORE");
922        std::env::remove_var("SLACK_RS_TOKENS_PATH");
923    }
924
925    /// Test that InMemoryTokenStore works as a test/mock backend
926    /// with the same interface as production backends
927    #[test]
928    fn test_in_memory_token_store_as_mock() {
929        let store = InMemoryTokenStore::new();
930
931        // Test token storage and retrieval
932        let token_key = make_token_key("T999", "U888");
933        store.set(&token_key, "xoxb-mock-token").unwrap();
934        assert_eq!(store.get(&token_key).unwrap(), "xoxb-mock-token");
935
936        // Test OAuth secret storage and retrieval
937        let secret_key = make_oauth_client_secret_key("mock-profile");
938        store.set(&secret_key, "mock-secret").unwrap();
939        assert_eq!(store.get(&secret_key).unwrap(), "mock-secret");
940
941        // Test existence checks
942        assert!(store.exists(&token_key));
943        assert!(store.exists(&secret_key));
944        assert!(!store.exists("nonexistent"));
945
946        // Test deletion
947        store.delete(&token_key).unwrap();
948        assert!(!store.exists(&token_key));
949
950        // Test helper functions
951        store_oauth_client_secret(&store, "test", "test-secret").unwrap();
952        assert_eq!(
953            get_oauth_client_secret(&store, "test").unwrap(),
954            "test-secret"
955        );
956        delete_oauth_client_secret(&store, "test").unwrap();
957        assert!(!store.exists(&make_oauth_client_secret_key("test")));
958    }
959}