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
21pub trait TokenStore: Send + Sync {
23 fn set(&self, key: &str, token: &str) -> Result<()>;
25
26 fn get(&self, key: &str) -> Result<String>;
28
29 fn delete(&self, key: &str) -> Result<()>;
31
32 fn exists(&self, key: &str) -> bool;
34}
35
36#[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#[derive(Debug, Clone)]
88pub struct FileTokenStore {
89 file_path: PathBuf,
90 tokens: Arc<Mutex<HashMap<String, String>>>,
91}
92
93impl FileTokenStore {
94 pub fn new() -> Result<Self> {
96 let file_path = Self::default_path()?;
97 Self::with_path(file_path)
98 }
99
100 pub fn with_path(file_path: PathBuf) -> Result<Self> {
102 Self::with_path_and_migration(file_path, None)
103 }
104
105 fn with_path_and_migration(file_path: PathBuf, old_path: Option<PathBuf>) -> Result<Self> {
108 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 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 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 pub fn default_path() -> Result<PathBuf> {
141 if let Ok(path) = std::env::var("SLACK_RS_TOKENS_PATH") {
143 return Ok(PathBuf::from(path));
144 }
145
146 if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME") {
148 let trimmed = xdg_data_home.trim();
150 if !trimmed.is_empty() {
151 let xdg_path = PathBuf::from(trimmed);
152 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 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 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 let config_dir = home.join(".config").join("slack-rs");
183 Ok(config_dir.join("tokens.json"))
184 }
185
186 fn migrate_from_old_path_if_needed(new_path: &Path) -> Result<()> {
189 let old_path = match Self::old_config_path() {
191 Ok(path) => path,
192 Err(_) => return Ok(()), };
194
195 Self::migrate_from_path(&old_path, new_path)
196 }
197
198 fn migrate_from_path(old_path: &Path, new_path: &Path) -> Result<()> {
201 if new_path.exists() {
203 return Ok(());
204 }
205
206 if !old_path.exists() {
208 return Ok(());
209 }
210
211 fs::copy(old_path, new_path).map_err(|e| {
213 TokenStoreError::IoError(format!("Failed to migrate tokens from old path: {}", e))
214 })?;
215
216 #[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 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 fn save_tokens(&self) -> Result<()> {
243 let tokens = self.tokens.lock().unwrap();
244
245 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 fs::write(&self.file_path, content).map_err(|e| {
255 TokenStoreError::StoreFailed(format!("Failed to write tokens file: {}", e))
256 })?;
257
258 #[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); 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); 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
309pub fn make_token_key(team_id: &str, user_id: &str) -> String {
311 format!("{}:{}", team_id, user_id)
312}
313
314pub fn make_oauth_client_secret_key(profile_name: &str) -> String {
316 format!("oauth-client-secret:{}", profile_name)
317}
318
319pub 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
329pub 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
335pub 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
341pub 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 assert!(file_path.exists());
463
464 #[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 {
502 let store = FileTokenStore::with_path(file_path.clone()).unwrap();
503 store.set("T123:U456", "xoxb-test-token").unwrap();
504 }
505
506 {
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 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]
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 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 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 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 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]
608 #[serial_test::serial]
609 fn test_file_token_store_default_path() {
610 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 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 #[test]
632 #[serial_test::serial]
633 fn test_unified_credential_storage_policy() {
634 use tempfile::TempDir;
635
636 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 let memory_store = InMemoryTokenStore::new();
643
644 let file_store = FileTokenStore::with_path(tokens_path.clone()).unwrap();
646
647 let token_key = make_token_key("T123", "U456");
649 let secret_key = make_oauth_client_secret_key("default");
650
651 memory_store.set(&token_key, "token1").unwrap();
653 memory_store.set(&secret_key, "secret1").unwrap();
654
655 file_store.set(&token_key, "token2").unwrap();
657 file_store.set(&secret_key, "secret2").unwrap();
658
659 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 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 std::env::remove_var("SLACK_RS_TOKENS_PATH");
680 }
681
682 #[test]
685 fn test_in_memory_token_store_as_mock() {
686 let store = InMemoryTokenStore::new();
687
688 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 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 assert!(store.exists(&token_key));
700 assert!(store.exists(&secret_key));
701 assert!(!store.exists("nonexistent"));
702
703 store.delete(&token_key).unwrap();
705 assert!(!store.exists(&token_key));
706
707 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]
719 #[serial_test::serial]
720 fn test_migration_from_old_to_new_path() {
721 use tempfile::TempDir;
722
723 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 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 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 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 assert!(old_path.exists());
755 assert!(!new_path.exists());
756
757 let store =
759 FileTokenStore::with_path_and_migration(new_path.clone(), Some(old_path.clone()))
760 .unwrap();
761
762 assert!(new_path.exists());
764
765 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 #[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 assert!(old_path.exists());
783 }
784
785 #[test]
787 fn test_no_migration_when_new_path_exists() {
788 use tempfile::TempDir;
789
790 let temp_dir = TempDir::new().unwrap();
791
792 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 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 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 let store = FileTokenStore::with_path(new_path.clone()).unwrap();
825
826 assert_eq!(store.get("new:key").unwrap(), "new-value");
828 assert!(store.get("old:key").is_err());
829 }
830
831 #[test]
833 fn test_no_migration_when_old_path_missing() {
834 use tempfile::TempDir;
835
836 let temp_dir = TempDir::new().unwrap();
837
838 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 let store = FileTokenStore::with_path(new_path.clone()).unwrap();
849
850 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]
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 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 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 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 let store = FileTokenStore::new().unwrap();
884
885 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]
897 #[serial_test::serial]
898 fn test_deterministic_serialization_different_insertion_orders() {
899 use tempfile::TempDir;
900
901 std::env::remove_var("SLACK_RS_TOKENS_PATH");
903
904 let temp_dir1 = TempDir::new().unwrap();
906 let temp_dir2 = TempDir::new().unwrap();
907
908 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 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 let content1 = fs::read_to_string(&file_path_1).unwrap();
924 let content2 = fs::read_to_string(&file_path_2).unwrap();
925
926 assert_eq!(content1, content2,
928 "Files should have identical content regardless of insertion order.\nFile1:\n{}\nFile2:\n{}",
929 content1, content2);
930
931 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 std::env::remove_var("SLACK_RS_TOKENS_PATH");
951 }
952
953 #[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 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 store.set("key1", "value1").unwrap();
970 let content_after_second_save = fs::read_to_string(&file_path).unwrap();
971
972 store.set("key2", "value2").unwrap();
974 let content_after_third_save = fs::read_to_string(&file_path).unwrap();
975
976 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]
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 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 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 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 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]
1026 #[serial_test::serial]
1027 fn test_xdg_data_home_resolution() {
1028 use tempfile::TempDir;
1029
1030 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]
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 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 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]
1076 #[serial_test::serial]
1077 fn test_fallback_when_xdg_data_home_not_set() {
1078 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 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]
1096 #[serial_test::serial]
1097 fn test_empty_xdg_data_home_fallback() {
1098 std::env::remove_var("SLACK_RS_TOKENS_PATH");
1100
1101 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 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]
1120 #[serial_test::serial]
1121 fn test_whitespace_xdg_data_home_fallback() {
1122 std::env::remove_var("SLACK_RS_TOKENS_PATH");
1124
1125 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 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]
1144 #[serial_test::serial]
1145 fn test_relative_xdg_data_home_fallback() {
1146 std::env::remove_var("SLACK_RS_TOKENS_PATH");
1148
1149 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 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}