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}
18
19pub type Result<T> = std::result::Result<T, TokenStoreError>;
20
21/// Trait for storing and retrieving tokens securely
22pub trait TokenStore: Send + Sync {
23    /// Store a token with the given key
24    fn set(&self, key: &str, token: &str) -> Result<()>;
25
26    /// Retrieve a token by key
27    fn get(&self, key: &str) -> Result<String>;
28
29    /// Delete a token by key
30    fn delete(&self, key: &str) -> Result<()>;
31
32    /// Check if a token exists for the given key
33    fn exists(&self, key: &str) -> bool;
34}
35
36/// In-memory implementation of TokenStore for testing
37#[derive(Debug, Clone)]
38pub struct InMemoryTokenStore {
39    tokens: Arc<Mutex<HashMap<String, String>>>,
40}
41
42impl InMemoryTokenStore {
43    pub fn new() -> Self {
44        Self {
45            tokens: Arc::new(Mutex::new(HashMap::new())),
46        }
47    }
48}
49
50impl Default for InMemoryTokenStore {
51    fn default() -> Self {
52        Self::new()
53    }
54}
55
56impl TokenStore for InMemoryTokenStore {
57    fn set(&self, key: &str, token: &str) -> Result<()> {
58        let mut tokens = self.tokens.lock().unwrap();
59        tokens.insert(key.to_string(), token.to_string());
60        Ok(())
61    }
62
63    fn get(&self, key: &str) -> Result<String> {
64        let tokens = self.tokens.lock().unwrap();
65        tokens
66            .get(key)
67            .cloned()
68            .ok_or_else(|| TokenStoreError::NotFound(key.to_string()))
69    }
70
71    fn delete(&self, key: &str) -> Result<()> {
72        let mut tokens = self.tokens.lock().unwrap();
73        tokens
74            .remove(key)
75            .ok_or_else(|| TokenStoreError::NotFound(key.to_string()))?;
76        Ok(())
77    }
78
79    fn exists(&self, key: &str) -> bool {
80        let tokens = self.tokens.lock().unwrap();
81        tokens.contains_key(key)
82    }
83}
84
85/// File-based implementation of TokenStore
86/// Stores tokens in ~/.local/share/slack-rs/tokens.json with restricted permissions (0600)
87#[derive(Debug, Clone)]
88pub struct FileTokenStore {
89    file_path: PathBuf,
90    tokens: Arc<Mutex<HashMap<String, String>>>,
91}
92
93impl FileTokenStore {
94    /// Create a new FileTokenStore with the default path (~/.local/share/slack-rs/tokens.json)
95    pub fn new() -> Result<Self> {
96        let file_path = Self::default_path()?;
97        Self::with_path(file_path)
98    }
99
100    /// Create a FileTokenStore with a custom path
101    pub fn with_path(file_path: PathBuf) -> Result<Self> {
102        Self::with_path_and_migration(file_path, None)
103    }
104
105    /// Create a FileTokenStore with a custom path and optional migration source
106    /// Internal method that allows tests to specify a custom old path for migration
107    fn with_path_and_migration(file_path: PathBuf, old_path: Option<PathBuf>) -> Result<Self> {
108        // Ensure parent directory exists
109        if let Some(parent) = file_path.parent() {
110            fs::create_dir_all(parent).map_err(|e| {
111                TokenStoreError::IoError(format!("Failed to create directory: {}", e))
112            })?;
113        }
114
115        // Auto-migrate from old path if needed (only when using default path)
116        if std::env::var("SLACK_RS_TOKENS_PATH").is_err() {
117            if let Some(old) = old_path {
118                Self::migrate_from_path(&old, &file_path)?;
119            } else {
120                Self::migrate_from_old_path_if_needed(&file_path)?;
121            }
122        }
123
124        // Load existing tokens or create empty map
125        let tokens = if file_path.exists() {
126            Self::load_tokens(&file_path)?
127        } else {
128            HashMap::new()
129        };
130
131        Ok(Self {
132            file_path,
133            tokens: Arc::new(Mutex::new(tokens)),
134        })
135    }
136
137    /// Get the default path for the tokens file
138    /// Can be overridden with SLACK_RS_TOKENS_PATH environment variable (useful for testing)
139    /// Respects XDG_DATA_HOME when set
140    pub fn default_path() -> Result<PathBuf> {
141        // Priority 1: Check for environment variable override (useful for testing)
142        if let Ok(path) = std::env::var("SLACK_RS_TOKENS_PATH") {
143            return Ok(PathBuf::from(path));
144        }
145
146        // Priority 2: Check for XDG_DATA_HOME
147        if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME") {
148            // Guard against empty or whitespace-only values
149            let trimmed = xdg_data_home.trim();
150            if !trimmed.is_empty() {
151                let xdg_path = PathBuf::from(trimmed);
152                // Ensure the path is absolute to avoid confusion
153                if xdg_path.is_absolute() {
154                    let data_dir = xdg_path.join("slack-rs");
155                    return Ok(data_dir.join("tokens.json"));
156                }
157            }
158        }
159
160        // Priority 3: Fallback to ~/.local/share/slack-rs/tokens.json
161        let home = directories::BaseDirs::new()
162            .ok_or_else(|| {
163                TokenStoreError::IoError("Failed to determine home directory".to_string())
164            })?
165            .home_dir()
166            .to_path_buf();
167
168        let data_dir = home.join(".local").join("share").join("slack-rs");
169        Ok(data_dir.join("tokens.json"))
170    }
171
172    /// Get the old config path for migration purposes
173    fn old_config_path() -> Result<PathBuf> {
174        let home = directories::BaseDirs::new()
175            .ok_or_else(|| {
176                TokenStoreError::IoError("Failed to determine home directory".to_string())
177            })?
178            .home_dir()
179            .to_path_buf();
180
181        // Old path: ~/.config/slack-rs/tokens.json
182        let config_dir = home.join(".config").join("slack-rs");
183        Ok(config_dir.join("tokens.json"))
184    }
185
186    /// Migrate from old path to new path if needed
187    /// Only runs when new path doesn't exist and old path does exist
188    fn migrate_from_old_path_if_needed(new_path: &Path) -> Result<()> {
189        // Get the old path, but don't fail if we can't determine it
190        let old_path = match Self::old_config_path() {
191            Ok(path) => path,
192            Err(_) => return Ok(()), // Can't determine old path, skip migration
193        };
194
195        Self::migrate_from_path(&old_path, new_path)
196    }
197
198    /// Migrate tokens from a specific old path to a new path
199    /// Only runs when new path doesn't exist and old path does exist
200    fn migrate_from_path(old_path: &Path, new_path: &Path) -> Result<()> {
201        // Skip migration if new path already exists
202        if new_path.exists() {
203            return Ok(());
204        }
205
206        // Skip migration if old path doesn't exist
207        if !old_path.exists() {
208            return Ok(());
209        }
210
211        // Copy old file to new location
212        fs::copy(old_path, new_path).map_err(|e| {
213            TokenStoreError::IoError(format!("Failed to migrate tokens from old path: {}", e))
214        })?;
215
216        // Set file permissions to 0600 on Unix systems
217        #[cfg(unix)]
218        {
219            use std::os::unix::fs::PermissionsExt;
220            let permissions = fs::Permissions::from_mode(0o600);
221            fs::set_permissions(new_path, permissions).map_err(|e| {
222                TokenStoreError::IoError(format!(
223                    "Failed to set file permissions during migration: {}",
224                    e
225                ))
226            })?;
227        }
228
229        Ok(())
230    }
231
232    /// Load tokens from file
233    fn load_tokens(path: &Path) -> Result<HashMap<String, String>> {
234        let content = fs::read_to_string(path)
235            .map_err(|e| TokenStoreError::IoError(format!("Failed to read tokens file: {}", e)))?;
236
237        serde_json::from_str(&content)
238            .map_err(|e| TokenStoreError::IoError(format!("Failed to parse tokens file: {}", e)))
239    }
240
241    /// Save tokens to file with restricted permissions
242    fn save_tokens(&self) -> Result<()> {
243        let tokens = self.tokens.lock().unwrap();
244
245        // Convert HashMap to BTreeMap for deterministic key ordering
246        use std::collections::BTreeMap;
247        let sorted_tokens: BTreeMap<_, _> = tokens.iter().collect();
248
249        let content = serde_json::to_string_pretty(&sorted_tokens).map_err(|e| {
250            TokenStoreError::StoreFailed(format!("Failed to serialize tokens: {}", e))
251        })?;
252
253        // Write to file
254        fs::write(&self.file_path, content).map_err(|e| {
255            TokenStoreError::StoreFailed(format!("Failed to write tokens file: {}", e))
256        })?;
257
258        // Set file permissions to 0600 (owner read/write only) on Unix systems
259        #[cfg(unix)]
260        {
261            use std::os::unix::fs::PermissionsExt;
262            let permissions = fs::Permissions::from_mode(0o600);
263            fs::set_permissions(&self.file_path, permissions).map_err(|e| {
264                TokenStoreError::StoreFailed(format!("Failed to set file permissions: {}", e))
265            })?;
266        }
267
268        Ok(())
269    }
270}
271
272impl Default for FileTokenStore {
273    fn default() -> Self {
274        Self::new().expect("Failed to create FileTokenStore")
275    }
276}
277
278impl TokenStore for FileTokenStore {
279    fn set(&self, key: &str, token: &str) -> Result<()> {
280        let mut tokens = self.tokens.lock().unwrap();
281        tokens.insert(key.to_string(), token.to_string());
282        drop(tokens); // Release lock before saving
283        self.save_tokens()
284    }
285
286    fn get(&self, key: &str) -> Result<String> {
287        let tokens = self.tokens.lock().unwrap();
288        tokens
289            .get(key)
290            .cloned()
291            .ok_or_else(|| TokenStoreError::NotFound(key.to_string()))
292    }
293
294    fn delete(&self, key: &str) -> Result<()> {
295        let mut tokens = self.tokens.lock().unwrap();
296        tokens
297            .remove(key)
298            .ok_or_else(|| TokenStoreError::NotFound(key.to_string()))?;
299        drop(tokens); // Release lock before saving
300        self.save_tokens()
301    }
302
303    fn exists(&self, key: &str) -> bool {
304        let tokens = self.tokens.lock().unwrap();
305        tokens.contains_key(key)
306    }
307}
308
309/// Helper function to create a token key from team_id and user_id
310pub fn make_token_key(team_id: &str, user_id: &str) -> String {
311    format!("{}:{}", team_id, user_id)
312}
313
314/// Helper function to create an OAuth client secret key for a profile
315pub fn make_oauth_client_secret_key(profile_name: &str) -> String {
316    format!("oauth-client-secret:{}", profile_name)
317}
318
319/// Store OAuth client secret in the token store
320pub fn store_oauth_client_secret(
321    token_store: &dyn TokenStore,
322    profile_name: &str,
323    client_secret: &str,
324) -> Result<()> {
325    let key = make_oauth_client_secret_key(profile_name);
326    token_store.set(&key, client_secret)
327}
328
329/// Retrieve OAuth client secret from the token store
330pub fn get_oauth_client_secret(token_store: &dyn TokenStore, profile_name: &str) -> Result<String> {
331    let key = make_oauth_client_secret_key(profile_name);
332    token_store.get(&key)
333}
334
335/// Delete OAuth client secret from the token store
336pub fn delete_oauth_client_secret(token_store: &dyn TokenStore, profile_name: &str) -> Result<()> {
337    let key = make_oauth_client_secret_key(profile_name);
338    token_store.delete(&key)
339}
340
341/// Create a token store using FileTokenStore
342///
343/// This function creates a FileTokenStore with the default path.
344///
345/// Returns Box<dyn TokenStore> for runtime polymorphism
346pub fn create_token_store() -> Result<Box<dyn TokenStore>> {
347    let store = FileTokenStore::new()?;
348    Ok(Box::new(store))
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    #[test]
356    fn test_in_memory_token_store_set_get() {
357        let store = InMemoryTokenStore::new();
358        let key = "T123:U456";
359        let token = "xoxb-test-token";
360
361        store.set(key, token).unwrap();
362        assert_eq!(store.get(key).unwrap(), token);
363    }
364
365    #[test]
366    fn test_in_memory_token_store_delete() {
367        let store = InMemoryTokenStore::new();
368        let key = "T123:U456";
369        let token = "xoxb-test-token";
370
371        store.set(key, token).unwrap();
372        assert!(store.exists(key));
373
374        store.delete(key).unwrap();
375        assert!(!store.exists(key));
376        assert!(store.get(key).is_err());
377    }
378
379    #[test]
380    fn test_in_memory_token_store_not_found() {
381        let store = InMemoryTokenStore::new();
382        let result = store.get("nonexistent");
383        assert!(result.is_err());
384        match result {
385            Err(TokenStoreError::NotFound(_)) => {}
386            _ => panic!("Expected NotFound error"),
387        }
388    }
389
390    #[test]
391    fn test_in_memory_token_store_exists() {
392        let store = InMemoryTokenStore::new();
393        let key = "T123:U456";
394
395        assert!(!store.exists(key));
396        store.set(key, "token").unwrap();
397        assert!(store.exists(key));
398    }
399
400    #[test]
401    fn test_make_token_key() {
402        let key = make_token_key("T123", "U456");
403        assert_eq!(key, "T123:U456");
404    }
405
406    #[test]
407    fn test_in_memory_token_store_multiple_keys() {
408        let store = InMemoryTokenStore::new();
409
410        store.set("T1:U1", "token1").unwrap();
411        store.set("T2:U2", "token2").unwrap();
412
413        assert_eq!(store.get("T1:U1").unwrap(), "token1");
414        assert_eq!(store.get("T2:U2").unwrap(), "token2");
415    }
416
417    #[test]
418    fn test_make_oauth_client_secret_key() {
419        let key = make_oauth_client_secret_key("default");
420        assert_eq!(key, "oauth-client-secret:default");
421    }
422
423    #[test]
424    fn test_store_and_get_oauth_client_secret() {
425        let store = InMemoryTokenStore::new();
426        let profile_name = "test-profile";
427        let client_secret = "test-secret-123";
428
429        store_oauth_client_secret(&store, profile_name, client_secret).unwrap();
430        let retrieved = get_oauth_client_secret(&store, profile_name).unwrap();
431        assert_eq!(retrieved, client_secret);
432    }
433
434    #[test]
435    fn test_delete_oauth_client_secret() {
436        let store = InMemoryTokenStore::new();
437        let profile_name = "test-profile";
438        let client_secret = "test-secret-123";
439
440        store_oauth_client_secret(&store, profile_name, client_secret).unwrap();
441        assert!(get_oauth_client_secret(&store, profile_name).is_ok());
442
443        delete_oauth_client_secret(&store, profile_name).unwrap();
444        assert!(get_oauth_client_secret(&store, profile_name).is_err());
445    }
446
447    #[test]
448    fn test_file_token_store_set_get() {
449        use tempfile::TempDir;
450
451        let temp_dir = TempDir::new().unwrap();
452        let file_path = temp_dir.path().join("tokens.json");
453        let store = FileTokenStore::with_path(file_path.clone()).unwrap();
454
455        let key = "T123:U456";
456        let token = "xoxb-test-token";
457
458        store.set(key, token).unwrap();
459        assert_eq!(store.get(key).unwrap(), token);
460
461        // Verify file exists
462        assert!(file_path.exists());
463
464        // Verify file permissions on Unix
465        #[cfg(unix)]
466        {
467            use std::os::unix::fs::PermissionsExt;
468            let metadata = fs::metadata(&file_path).unwrap();
469            let permissions = metadata.permissions();
470            assert_eq!(permissions.mode() & 0o777, 0o600);
471        }
472    }
473
474    #[test]
475    fn test_file_token_store_delete() {
476        use tempfile::TempDir;
477
478        let temp_dir = TempDir::new().unwrap();
479        let file_path = temp_dir.path().join("tokens.json");
480        let store = FileTokenStore::with_path(file_path).unwrap();
481
482        let key = "T123:U456";
483        let token = "xoxb-test-token";
484
485        store.set(key, token).unwrap();
486        assert!(store.exists(key));
487
488        store.delete(key).unwrap();
489        assert!(!store.exists(key));
490        assert!(store.get(key).is_err());
491    }
492
493    #[test]
494    fn test_file_token_store_persistence() {
495        use tempfile::TempDir;
496
497        let temp_dir = TempDir::new().unwrap();
498        let file_path = temp_dir.path().join("tokens.json");
499
500        // Create store and save token
501        {
502            let store = FileTokenStore::with_path(file_path.clone()).unwrap();
503            store.set("T123:U456", "xoxb-test-token").unwrap();
504        }
505
506        // Create new store instance and verify token persists
507        {
508            let store = FileTokenStore::with_path(file_path).unwrap();
509            assert_eq!(store.get("T123:U456").unwrap(), "xoxb-test-token");
510        }
511    }
512
513    #[test]
514    fn test_file_token_store_multiple_keys() {
515        use tempfile::TempDir;
516
517        let temp_dir = TempDir::new().unwrap();
518        let file_path = temp_dir.path().join("tokens.json");
519        let store = FileTokenStore::with_path(file_path).unwrap();
520
521        store.set("T1:U1", "token1").unwrap();
522        store.set("T2:U2", "token2").unwrap();
523        store
524            .set("oauth-client-secret:default", "secret123")
525            .unwrap();
526
527        assert_eq!(store.get("T1:U1").unwrap(), "token1");
528        assert_eq!(store.get("T2:U2").unwrap(), "token2");
529        assert_eq!(
530            store.get("oauth-client-secret:default").unwrap(),
531            "secret123"
532        );
533    }
534
535    #[test]
536    fn test_file_token_store_not_found() {
537        use tempfile::TempDir;
538
539        let temp_dir = TempDir::new().unwrap();
540        let file_path = temp_dir.path().join("tokens.json");
541        let store = FileTokenStore::with_path(file_path).unwrap();
542
543        let result = store.get("nonexistent");
544        assert!(result.is_err());
545        match result {
546            Err(TokenStoreError::NotFound(_)) => {}
547            _ => panic!("Expected NotFound error"),
548        }
549    }
550
551    #[test]
552    #[serial_test::serial]
553    fn test_create_token_store_file_backend() {
554        use tempfile::TempDir;
555
556        let temp_dir = TempDir::new().unwrap();
557        let tokens_path = temp_dir.path().join("tokens.json");
558        std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
559
560        let store = create_token_store().unwrap();
561
562        // Test that the store works
563        store.set("test_key", "test_value").unwrap();
564        assert_eq!(store.get("test_key").unwrap(), "test_value");
565
566        std::env::remove_var("SLACK_RS_TOKENS_PATH");
567    }
568
569    /// Test that file mode uses existing tokens.json path and key format
570    /// This verifies backward compatibility with existing token storage
571    #[test]
572    #[serial_test::serial]
573    fn test_file_mode_uses_existing_path_and_key_format() {
574        use tempfile::TempDir;
575
576        let temp_dir = TempDir::new().unwrap();
577        let tokens_path = temp_dir.path().join("tokens.json");
578
579        // Set environment to use file backend with custom path
580        std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
581
582        let store = create_token_store().expect("File backend should work");
583
584        // Test token key format: {team_id}:{user_id}
585        let token_key = make_token_key("T123", "U456");
586        assert_eq!(token_key, "T123:U456");
587        store.set(&token_key, "xoxb-test-token").unwrap();
588        assert_eq!(store.get(&token_key).unwrap(), "xoxb-test-token");
589
590        // Test OAuth client secret key format: oauth-client-secret:{profile_name}
591        let secret_key = make_oauth_client_secret_key("default");
592        assert_eq!(secret_key, "oauth-client-secret:default");
593        store.set(&secret_key, "test-secret").unwrap();
594        assert_eq!(store.get(&secret_key).unwrap(), "test-secret");
595
596        // Verify the file exists and contains both keys
597        assert!(tokens_path.exists());
598        let content = std::fs::read_to_string(&tokens_path).unwrap();
599        assert!(content.contains("T123:U456"));
600        assert!(content.contains("oauth-client-secret:default"));
601
602        std::env::remove_var("SLACK_RS_TOKENS_PATH");
603    }
604
605    /// Test default path for FileTokenStore
606    /// Verifies that FileTokenStore uses ~/.local/share/slack-rs/tokens.json by default
607    #[test]
608    #[serial_test::serial]
609    fn test_file_token_store_default_path() {
610        // Clear environment override to test actual default
611        std::env::remove_var("SLACK_RS_TOKENS_PATH");
612
613        let default_path = FileTokenStore::default_path().unwrap();
614        let path_str = default_path.to_string_lossy();
615
616        // Should contain .local/share/slack-rs/tokens.json
617        assert!(
618            path_str.contains(".local/share/slack-rs/tokens.json")
619                || path_str.contains(".local\\share\\slack-rs\\tokens.json"),
620            "Default path should be ~/.local/share/slack-rs/tokens.json, got: {}",
621            path_str
622        );
623    }
624
625    /// Comprehensive test for unified credential storage policy
626    ///
627    /// This test verifies the complete specification:
628    /// 1. FileTokenStore is the default and only backend
629    /// 2. Both InMemoryTokenStore and FileTokenStore use the same key format (team_id:user_id for tokens, oauth-client-secret:profile for secrets)
630    /// 3. InMemoryTokenStore can be used for testing with same key format
631    #[test]
632    #[serial_test::serial]
633    fn test_unified_credential_storage_policy() {
634        use tempfile::TempDir;
635
636        // Test 1: Same key format across all backends
637        let temp_dir = TempDir::new().unwrap();
638        let tokens_path = temp_dir.path().join("tokens.json");
639        std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
640
641        // Test with InMemoryTokenStore (for testing)
642        let memory_store = InMemoryTokenStore::new();
643
644        // Test with FileTokenStore
645        let file_store = FileTokenStore::with_path(tokens_path.clone()).unwrap();
646
647        // Both should use the same key format
648        let token_key = make_token_key("T123", "U456");
649        let secret_key = make_oauth_client_secret_key("default");
650
651        // Store in memory store
652        memory_store.set(&token_key, "token1").unwrap();
653        memory_store.set(&secret_key, "secret1").unwrap();
654
655        // Store in file store
656        file_store.set(&token_key, "token2").unwrap();
657        file_store.set(&secret_key, "secret2").unwrap();
658
659        // Verify retrieval works with same keys
660        assert_eq!(memory_store.get(&token_key).unwrap(), "token1");
661        assert_eq!(memory_store.get(&secret_key).unwrap(), "secret1");
662        assert_eq!(file_store.get(&token_key).unwrap(), "token2");
663        assert_eq!(file_store.get(&secret_key).unwrap(), "secret2");
664
665        // Test 2: Verify helper functions work across all backends
666        store_oauth_client_secret(&memory_store, "test", "secret123").unwrap();
667        assert_eq!(
668            get_oauth_client_secret(&memory_store, "test").unwrap(),
669            "secret123"
670        );
671
672        store_oauth_client_secret(&file_store, "test", "secret456").unwrap();
673        assert_eq!(
674            get_oauth_client_secret(&file_store, "test").unwrap(),
675            "secret456"
676        );
677
678        // Clean up
679        std::env::remove_var("SLACK_RS_TOKENS_PATH");
680    }
681
682    /// Test that InMemoryTokenStore works as a test/mock backend
683    /// with the same interface as production backends
684    #[test]
685    fn test_in_memory_token_store_as_mock() {
686        let store = InMemoryTokenStore::new();
687
688        // Test token storage and retrieval
689        let token_key = make_token_key("T999", "U888");
690        store.set(&token_key, "xoxb-mock-token").unwrap();
691        assert_eq!(store.get(&token_key).unwrap(), "xoxb-mock-token");
692
693        // Test OAuth secret storage and retrieval
694        let secret_key = make_oauth_client_secret_key("mock-profile");
695        store.set(&secret_key, "mock-secret").unwrap();
696        assert_eq!(store.get(&secret_key).unwrap(), "mock-secret");
697
698        // Test existence checks
699        assert!(store.exists(&token_key));
700        assert!(store.exists(&secret_key));
701        assert!(!store.exists("nonexistent"));
702
703        // Test deletion
704        store.delete(&token_key).unwrap();
705        assert!(!store.exists(&token_key));
706
707        // Test helper functions
708        store_oauth_client_secret(&store, "test", "test-secret").unwrap();
709        assert_eq!(
710            get_oauth_client_secret(&store, "test").unwrap(),
711            "test-secret"
712        );
713        delete_oauth_client_secret(&store, "test").unwrap();
714        assert!(!store.exists(&make_oauth_client_secret_key("test")));
715    }
716
717    /// Test migration from old path to new path
718    #[test]
719    #[serial_test::serial]
720    fn test_migration_from_old_to_new_path() {
721        use tempfile::TempDir;
722
723        // Clear environment variables to ensure migration logic runs
724        std::env::remove_var("SLACK_RS_TOKENS_PATH");
725        std::env::remove_var("XDG_DATA_HOME");
726
727        let temp_dir = TempDir::new().unwrap();
728
729        // Create old-style directory structure
730        let old_config_dir = temp_dir.path().join(".config").join("slack-rs");
731        fs::create_dir_all(&old_config_dir).unwrap();
732        let old_path = old_config_dir.join("tokens.json");
733
734        // Create new-style directory structure
735        let new_data_dir = temp_dir
736            .path()
737            .join(".local")
738            .join("share")
739            .join("slack-rs");
740        fs::create_dir_all(&new_data_dir).unwrap();
741        let new_path = new_data_dir.join("tokens.json");
742
743        // Write test data to old path
744        let mut old_tokens = HashMap::new();
745        old_tokens.insert("T123:U456".to_string(), "xoxb-old-token".to_string());
746        old_tokens.insert(
747            "oauth-client-secret:default".to_string(),
748            "old-secret".to_string(),
749        );
750        let old_content = serde_json::to_string_pretty(&old_tokens).unwrap();
751        fs::write(&old_path, old_content).unwrap();
752
753        // Verify old file exists and new file doesn't
754        assert!(old_path.exists());
755        assert!(!new_path.exists());
756
757        // Create FileTokenStore with new path (should trigger migration)
758        let store =
759            FileTokenStore::with_path_and_migration(new_path.clone(), Some(old_path.clone()))
760                .unwrap();
761
762        // Verify new file exists after migration
763        assert!(new_path.exists());
764
765        // Verify content was migrated correctly
766        assert_eq!(store.get("T123:U456").unwrap(), "xoxb-old-token");
767        assert_eq!(
768            store.get("oauth-client-secret:default").unwrap(),
769            "old-secret"
770        );
771
772        // Verify file permissions on Unix
773        #[cfg(unix)]
774        {
775            use std::os::unix::fs::PermissionsExt;
776            let metadata = fs::metadata(&new_path).unwrap();
777            let permissions = metadata.permissions();
778            assert_eq!(permissions.mode() & 0o777, 0o600);
779        }
780
781        // Verify old file still exists (not deleted)
782        assert!(old_path.exists());
783    }
784
785    /// Test that migration doesn't happen when new path already exists
786    #[test]
787    fn test_no_migration_when_new_path_exists() {
788        use tempfile::TempDir;
789
790        let temp_dir = TempDir::new().unwrap();
791
792        // Create old-style directory structure
793        let old_config_dir = temp_dir.path().join(".config").join("slack-rs");
794        fs::create_dir_all(&old_config_dir).unwrap();
795        let old_path = old_config_dir.join("tokens.json");
796
797        // Create new-style directory structure
798        let new_data_dir = temp_dir
799            .path()
800            .join(".local")
801            .join("share")
802            .join("slack-rs");
803        fs::create_dir_all(&new_data_dir).unwrap();
804        let new_path = new_data_dir.join("tokens.json");
805
806        // Write different data to both paths
807        let mut old_tokens = HashMap::new();
808        old_tokens.insert("old:key".to_string(), "old-value".to_string());
809        fs::write(
810            &old_path,
811            serde_json::to_string_pretty(&old_tokens).unwrap(),
812        )
813        .unwrap();
814
815        let mut new_tokens = HashMap::new();
816        new_tokens.insert("new:key".to_string(), "new-value".to_string());
817        fs::write(
818            &new_path,
819            serde_json::to_string_pretty(&new_tokens).unwrap(),
820        )
821        .unwrap();
822
823        // Create FileTokenStore with new path
824        let store = FileTokenStore::with_path(new_path.clone()).unwrap();
825
826        // Verify new path content is preserved (no migration happened)
827        assert_eq!(store.get("new:key").unwrap(), "new-value");
828        assert!(store.get("old:key").is_err());
829    }
830
831    /// Test that migration doesn't happen when old path doesn't exist
832    #[test]
833    fn test_no_migration_when_old_path_missing() {
834        use tempfile::TempDir;
835
836        let temp_dir = TempDir::new().unwrap();
837
838        // Create new-style directory structure only
839        let new_data_dir = temp_dir
840            .path()
841            .join(".local")
842            .join("share")
843            .join("slack-rs");
844        fs::create_dir_all(&new_data_dir).unwrap();
845        let new_path = new_data_dir.join("tokens.json");
846
847        // Create FileTokenStore with new path (should not fail)
848        let store = FileTokenStore::with_path(new_path.clone()).unwrap();
849
850        // Should work normally
851        store.set("test:key", "test-value").unwrap();
852        assert_eq!(store.get("test:key").unwrap(), "test-value");
853        assert!(new_path.exists());
854    }
855
856    /// Test that migration doesn't happen when SLACK_RS_TOKENS_PATH is set
857    #[test]
858    #[serial_test::serial]
859    fn test_no_migration_with_env_override() {
860        use tempfile::TempDir;
861
862        let temp_dir = TempDir::new().unwrap();
863
864        // Create old-style directory structure
865        let old_config_dir = temp_dir.path().join(".config").join("slack-rs");
866        fs::create_dir_all(&old_config_dir).unwrap();
867        let old_path = old_config_dir.join("tokens.json");
868
869        // Write test data to old path
870        let mut old_tokens = HashMap::new();
871        old_tokens.insert("old:key".to_string(), "old-value".to_string());
872        fs::write(
873            &old_path,
874            serde_json::to_string_pretty(&old_tokens).unwrap(),
875        )
876        .unwrap();
877
878        // Set custom path via environment variable
879        let custom_path = temp_dir.path().join("custom-tokens.json");
880        std::env::set_var("SLACK_RS_TOKENS_PATH", custom_path.to_str().unwrap());
881
882        // Create FileTokenStore (should use custom path, no migration)
883        let store = FileTokenStore::new().unwrap();
884
885        // Verify custom path is used and old data is not migrated
886        store.set("new:key", "new-value").unwrap();
887        assert_eq!(store.get("new:key").unwrap(), "new-value");
888        assert!(store.get("old:key").is_err());
889        assert!(custom_path.exists());
890
891        std::env::remove_var("SLACK_RS_TOKENS_PATH");
892    }
893
894    /// Test deterministic serialization with different insertion orders
895    /// Regression test for Issue #24
896    #[test]
897    #[serial_test::serial]
898    fn test_deterministic_serialization_different_insertion_orders() {
899        use tempfile::TempDir;
900
901        // Ensure test isolation from global path overrides
902        std::env::remove_var("SLACK_RS_TOKENS_PATH");
903
904        // Create separate temp directories for each store to avoid state sharing
905        let temp_dir1 = TempDir::new().unwrap();
906        let temp_dir2 = TempDir::new().unwrap();
907
908        // Create first store and insert keys in order: A, B, C
909        let file_path_1 = temp_dir1.path().join("tokens.json");
910        let store1 = FileTokenStore::with_path(file_path_1.clone()).unwrap();
911        store1.set("key_a", "value_a").unwrap();
912        store1.set("key_b", "value_b").unwrap();
913        store1.set("key_c", "value_c").unwrap();
914
915        // Create second store and insert keys in order: C, A, B
916        let file_path_2 = temp_dir2.path().join("tokens.json");
917        let store2 = FileTokenStore::with_path(file_path_2.clone()).unwrap();
918        store2.set("key_c", "value_c").unwrap();
919        store2.set("key_a", "value_a").unwrap();
920        store2.set("key_b", "value_b").unwrap();
921
922        // Read both files and compare content
923        let content1 = fs::read_to_string(&file_path_1).unwrap();
924        let content2 = fs::read_to_string(&file_path_2).unwrap();
925
926        // Content should be identical despite different insertion orders
927        assert_eq!(content1, content2,
928            "Files should have identical content regardless of insertion order.\nFile1:\n{}\nFile2:\n{}",
929            content1, content2);
930
931        // Verify keys are sorted alphabetically in the output
932        let content_lines: Vec<&str> = content1.lines().collect();
933        let key_a_idx = content_lines
934            .iter()
935            .position(|l| l.contains("key_a"))
936            .unwrap();
937        let key_b_idx = content_lines
938            .iter()
939            .position(|l| l.contains("key_b"))
940            .unwrap();
941        let key_c_idx = content_lines
942            .iter()
943            .position(|l| l.contains("key_c"))
944            .unwrap();
945
946        assert!(key_a_idx < key_b_idx, "key_a should appear before key_b");
947        assert!(key_b_idx < key_c_idx, "key_b should appear before key_c");
948
949        // Leave environment in a clean state for other tests
950        std::env::remove_var("SLACK_RS_TOKENS_PATH");
951    }
952
953    /// Test no diff on consecutive saves with unchanged content
954    /// Regression test for Issue #24
955    #[test]
956    fn test_no_diff_on_consecutive_saves() {
957        use tempfile::TempDir;
958
959        let temp_dir = TempDir::new().unwrap();
960        let file_path = temp_dir.path().join("tokens.json");
961        let store = FileTokenStore::with_path(file_path.clone()).unwrap();
962
963        // First save
964        store.set("key1", "value1").unwrap();
965        store.set("key2", "value2").unwrap();
966        let content_after_first_save = fs::read_to_string(&file_path).unwrap();
967
968        // Second save with no changes (re-save existing data)
969        store.set("key1", "value1").unwrap();
970        let content_after_second_save = fs::read_to_string(&file_path).unwrap();
971
972        // Third save with no changes
973        store.set("key2", "value2").unwrap();
974        let content_after_third_save = fs::read_to_string(&file_path).unwrap();
975
976        // All saves should produce identical content
977        assert_eq!(
978            content_after_first_save, content_after_second_save,
979            "Second save should not change file content"
980        );
981        assert_eq!(
982            content_after_second_save, content_after_third_save,
983            "Third save should not change file content"
984        );
985    }
986
987    /// Test existing key format compatibility (regression test)
988    #[test]
989    fn test_existing_key_format_compatibility() {
990        use tempfile::TempDir;
991
992        let temp_dir = TempDir::new().unwrap();
993        let file_path = temp_dir.path().join("tokens.json");
994        let store = FileTokenStore::with_path(file_path.clone()).unwrap();
995
996        // Test team_id:user_id format
997        let token_key = make_token_key("T123", "U456");
998        assert_eq!(token_key, "T123:U456");
999        store.set(&token_key, "xoxb-test-token").unwrap();
1000        assert_eq!(store.get(&token_key).unwrap(), "xoxb-test-token");
1001
1002        // Test oauth-client-secret:profile_name format
1003        let secret_key = make_oauth_client_secret_key("default");
1004        assert_eq!(secret_key, "oauth-client-secret:default");
1005        store.set(&secret_key, "test-secret").unwrap();
1006        assert_eq!(store.get(&secret_key).unwrap(), "test-secret");
1007
1008        // Verify helper functions work correctly
1009        store_oauth_client_secret(&store, "profile1", "secret1").unwrap();
1010        assert_eq!(
1011            get_oauth_client_secret(&store, "profile1").unwrap(),
1012            "secret1"
1013        );
1014
1015        delete_oauth_client_secret(&store, "profile1").unwrap();
1016        assert!(get_oauth_client_secret(&store, "profile1").is_err());
1017
1018        // Verify file content has correct keys
1019        let content = fs::read_to_string(&file_path).unwrap();
1020        assert!(content.contains("T123:U456"));
1021        assert!(content.contains("oauth-client-secret:default"));
1022    }
1023
1024    /// Test XDG_DATA_HOME resolution (when set to valid absolute path)
1025    #[test]
1026    #[serial_test::serial]
1027    fn test_xdg_data_home_resolution() {
1028        use tempfile::TempDir;
1029
1030        // Clear SLACK_RS_TOKENS_PATH to ensure XDG_DATA_HOME is tested
1031        std::env::remove_var("SLACK_RS_TOKENS_PATH");
1032
1033        let temp_dir = TempDir::new().unwrap();
1034        let xdg_data_home = temp_dir.path().to_str().unwrap();
1035        std::env::set_var("XDG_DATA_HOME", xdg_data_home);
1036
1037        let path = FileTokenStore::default_path().unwrap();
1038        let expected = temp_dir.path().join("slack-rs").join("tokens.json");
1039
1040        assert_eq!(
1041            path, expected,
1042            "XDG_DATA_HOME should resolve to $XDG_DATA_HOME/slack-rs/tokens.json"
1043        );
1044
1045        std::env::remove_var("XDG_DATA_HOME");
1046    }
1047
1048    /// Test that SLACK_RS_TOKENS_PATH takes priority over XDG_DATA_HOME
1049    #[test]
1050    #[serial_test::serial]
1051    fn test_slack_rs_tokens_path_priority_over_xdg() {
1052        use tempfile::TempDir;
1053
1054        let temp_dir = TempDir::new().unwrap();
1055        let custom_path = temp_dir.path().join("custom-tokens.json");
1056        let xdg_data_home = temp_dir.path().join("xdg-data");
1057
1058        // Set both environment variables
1059        std::env::set_var("SLACK_RS_TOKENS_PATH", custom_path.to_str().unwrap());
1060        std::env::set_var("XDG_DATA_HOME", xdg_data_home.to_str().unwrap());
1061
1062        let path = FileTokenStore::default_path().unwrap();
1063
1064        // SLACK_RS_TOKENS_PATH should win
1065        assert_eq!(
1066            path, custom_path,
1067            "SLACK_RS_TOKENS_PATH should take priority over XDG_DATA_HOME"
1068        );
1069
1070        std::env::remove_var("SLACK_RS_TOKENS_PATH");
1071        std::env::remove_var("XDG_DATA_HOME");
1072    }
1073
1074    /// Test fallback to ~/.local/share when XDG_DATA_HOME is not set
1075    #[test]
1076    #[serial_test::serial]
1077    fn test_fallback_when_xdg_data_home_not_set() {
1078        // Clear both environment variables
1079        std::env::remove_var("SLACK_RS_TOKENS_PATH");
1080        std::env::remove_var("XDG_DATA_HOME");
1081
1082        let path = FileTokenStore::default_path().unwrap();
1083        let path_str = path.to_string_lossy();
1084
1085        // Should fallback to ~/.local/share/slack-rs/tokens.json
1086        assert!(
1087            path_str.contains(".local/share/slack-rs/tokens.json")
1088                || path_str.contains(".local\\share\\slack-rs\\tokens.json"),
1089            "Should fallback to ~/.local/share/slack-rs/tokens.json when XDG_DATA_HOME is not set, got: {}",
1090            path_str
1091        );
1092    }
1093
1094    /// Test that empty XDG_DATA_HOME falls back to default
1095    #[test]
1096    #[serial_test::serial]
1097    fn test_empty_xdg_data_home_fallback() {
1098        // Clear SLACK_RS_TOKENS_PATH
1099        std::env::remove_var("SLACK_RS_TOKENS_PATH");
1100
1101        // Set XDG_DATA_HOME to empty string
1102        std::env::set_var("XDG_DATA_HOME", "");
1103
1104        let path = FileTokenStore::default_path().unwrap();
1105        let path_str = path.to_string_lossy();
1106
1107        // Should fallback to ~/.local/share/slack-rs/tokens.json
1108        assert!(
1109            path_str.contains(".local/share/slack-rs/tokens.json")
1110                || path_str.contains(".local\\share\\slack-rs\\tokens.json"),
1111            "Empty XDG_DATA_HOME should fallback to ~/.local/share/slack-rs/tokens.json, got: {}",
1112            path_str
1113        );
1114
1115        std::env::remove_var("XDG_DATA_HOME");
1116    }
1117
1118    /// Test that whitespace-only XDG_DATA_HOME falls back to default
1119    #[test]
1120    #[serial_test::serial]
1121    fn test_whitespace_xdg_data_home_fallback() {
1122        // Clear SLACK_RS_TOKENS_PATH
1123        std::env::remove_var("SLACK_RS_TOKENS_PATH");
1124
1125        // Set XDG_DATA_HOME to whitespace
1126        std::env::set_var("XDG_DATA_HOME", "   ");
1127
1128        let path = FileTokenStore::default_path().unwrap();
1129        let path_str = path.to_string_lossy();
1130
1131        // Should fallback to ~/.local/share/slack-rs/tokens.json
1132        assert!(
1133            path_str.contains(".local/share/slack-rs/tokens.json")
1134                || path_str.contains(".local\\share\\slack-rs\\tokens.json"),
1135            "Whitespace XDG_DATA_HOME should fallback to ~/.local/share/slack-rs/tokens.json, got: {}",
1136            path_str
1137        );
1138
1139        std::env::remove_var("XDG_DATA_HOME");
1140    }
1141
1142    /// Test that relative XDG_DATA_HOME path falls back to default
1143    #[test]
1144    #[serial_test::serial]
1145    fn test_relative_xdg_data_home_fallback() {
1146        // Clear SLACK_RS_TOKENS_PATH
1147        std::env::remove_var("SLACK_RS_TOKENS_PATH");
1148
1149        // Set XDG_DATA_HOME to relative path
1150        std::env::set_var("XDG_DATA_HOME", "relative/path");
1151
1152        let path = FileTokenStore::default_path().unwrap();
1153        let path_str = path.to_string_lossy();
1154
1155        // Should fallback to ~/.local/share/slack-rs/tokens.json
1156        assert!(
1157            path_str.contains(".local/share/slack-rs/tokens.json")
1158                || path_str.contains(".local\\share\\slack-rs\\tokens.json"),
1159            "Relative XDG_DATA_HOME should fallback to ~/.local/share/slack-rs/tokens.json, got: {}",
1160            path_str
1161        );
1162
1163        std::env::remove_var("XDG_DATA_HOME");
1164    }
1165}