1use crate::auth::crypto::{self, KdfParams};
4use crate::auth::format::{self, ExportPayload, ExportProfile};
5use crate::profile::{
6 default_config_path, get_oauth_client_secret, load_config, make_token_key, save_config,
7 store_oauth_client_secret, Profile, TokenStore, TokenStoreError,
8};
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::io;
12use std::path::Path;
13use thiserror::Error;
14
15#[derive(Debug, Error)]
16pub enum ExportImportError {
17 #[error("Profile not found: {0}")]
18 ProfileNotFound(String),
19 #[error("Token not found for profile: {0}")]
20 TokenNotFound(String),
21 #[error("No profiles to export")]
22 NoProfiles,
23 #[error("Export requires --yes flag for confirmation")]
24 ConfirmationRequired,
25 #[error("Profile already exists: {0} (use --force to overwrite)")]
26 ProfileExists(String),
27 #[error("Empty passphrase not allowed")]
28 EmptyPassphrase,
29 #[cfg(unix)]
30 #[error("File permission error: {0}")]
31 PermissionError(String),
32 #[error("IO error: {0}")]
33 Io(#[from] io::Error),
34 #[error("Crypto error: {0}")]
35 Crypto(#[from] crypto::CryptoError),
36 #[error("Format error: {0}")]
37 Format(#[from] format::FormatError),
38 #[error("Storage error: {0}")]
39 Storage(String),
40 #[error("Token store error: {0}")]
41 TokenStore(#[from] TokenStoreError),
42}
43
44pub type Result<T> = std::result::Result<T, ExportImportError>;
45
46#[derive(Debug, Clone)]
48pub struct ExportOptions {
49 pub profile_name: Option<String>,
50 pub all: bool,
51 pub output_path: String,
52 pub passphrase: String,
53 pub yes: bool,
54}
55
56#[derive(Debug, Clone)]
58pub struct ImportOptions {
59 pub input_path: String,
60 pub passphrase: String,
61 pub yes: bool,
62 pub force: bool,
63 pub dry_run: bool,
64 pub json: bool,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum ImportAction {
71 Updated,
72 Skipped,
73 Overwritten,
74}
75
76impl std::fmt::Display for ImportAction {
77 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78 match self {
79 ImportAction::Updated => write!(f, "updated"),
80 ImportAction::Skipped => write!(f, "skipped"),
81 ImportAction::Overwritten => write!(f, "overwritten"),
82 }
83 }
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct ProfileImportResult {
89 pub profile_name: String,
90 pub action: ImportAction,
91 pub reason: String,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ImportResult {
97 pub profiles: Vec<ProfileImportResult>,
98 pub summary: ImportSummary,
99 pub dry_run: bool,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ImportSummary {
105 pub updated: usize,
106 pub skipped: usize,
107 pub overwritten: usize,
108 pub total: usize,
109}
110
111#[derive(Debug, Clone)]
113pub struct ExportResult {
114 pub exported_count: usize,
115 pub skipped_profiles: Vec<String>,
116}
117
118pub fn export_profiles(
120 token_store: &dyn TokenStore,
121 options: &ExportOptions,
122) -> Result<ExportResult> {
123 if !options.yes {
125 return Err(ExportImportError::ConfirmationRequired);
126 }
127
128 if options.passphrase.is_empty() {
130 return Err(ExportImportError::EmptyPassphrase);
131 }
132
133 let config_path =
135 default_config_path().map_err(|e| ExportImportError::Storage(e.to_string()))?;
136 let config =
137 load_config(&config_path).map_err(|e| ExportImportError::Storage(e.to_string()))?;
138
139 let profiles_to_export: Vec<(String, &Profile)> = if options.all {
141 config
142 .profiles
143 .iter()
144 .map(|(k, v)| (k.clone(), v))
145 .collect()
146 } else if let Some(ref profile_name) = options.profile_name {
147 let profile = config
148 .get(profile_name)
149 .ok_or_else(|| ExportImportError::ProfileNotFound(profile_name.clone()))?;
150 vec![(profile_name.clone(), profile)]
151 } else {
152 let profile = config
154 .get("default")
155 .ok_or_else(|| ExportImportError::ProfileNotFound("default".to_string()))?;
156 vec![("default".to_string(), profile)]
157 };
158
159 if profiles_to_export.is_empty() {
160 return Err(ExportImportError::NoProfiles);
161 }
162
163 let mut payload = ExportPayload::new();
165 let mut skipped_profiles = Vec::new();
166
167 for (name, profile) in profiles_to_export {
168 let bot_token_key = make_token_key(&profile.team_id, &profile.user_id);
169 let user_token_key = format!("{}:{}:user", &profile.team_id, &profile.user_id);
170
171 let bot_token = token_store.get(&bot_token_key).ok();
173 let user_token = token_store.get(&user_token_key).ok();
174
175 if bot_token.is_none() && user_token.is_none() {
177 if options.all {
179 skipped_profiles.push(name);
181 } else {
182 return Err(ExportImportError::TokenNotFound(name));
184 }
185 continue;
186 }
187
188 let client_id = profile.client_id.clone();
190 let client_secret = get_oauth_client_secret(token_store, &name).ok();
191
192 payload.profiles.insert(
193 name,
194 ExportProfile {
195 team_id: profile.team_id.clone(),
196 user_id: profile.user_id.clone(),
197 team_name: profile.team_name.clone(),
198 user_name: profile.user_name.clone(),
199 token: bot_token.unwrap_or_default(),
200 client_id,
201 client_secret,
202 user_token,
203 },
204 );
205 }
206
207 if payload.profiles.is_empty() {
209 return Err(ExportImportError::NoProfiles);
210 }
211
212 let kdf_params = KdfParams {
214 salt: crypto::generate_salt(),
215 ..Default::default()
216 };
217
218 let key = crypto::derive_key(&options.passphrase, &kdf_params)?;
219 let payload_json = serde_json::to_vec(&payload)
220 .map_err(|e| ExportImportError::Format(format::FormatError::Json(e)))?;
221 let encrypted = crypto::encrypt(&payload_json, &key)?;
222
223 let encoded = format::encode_export(&payload, &encrypted, &kdf_params)?;
225
226 let output_path = Path::new(&options.output_path);
228 if output_path.exists() {
229 check_file_permissions(output_path)?;
230 }
231
232 write_secure_file(output_path, &encoded)?;
234
235 Ok(ExportResult {
236 exported_count: payload.profiles.len(),
237 skipped_profiles,
238 })
239}
240
241pub fn import_profiles(
243 token_store: &dyn TokenStore,
244 options: &ImportOptions,
245) -> Result<ImportResult> {
246 if options.passphrase.is_empty() {
248 return Err(ExportImportError::EmptyPassphrase);
249 }
250
251 let input_path = Path::new(&options.input_path);
253 check_file_permissions(input_path)?;
254
255 let encoded_data = fs::read(input_path)?;
256
257 let decoded = format::decode_export(&encoded_data)?;
259
260 let key = crypto::derive_key(&options.passphrase, &decoded.kdf_params)?;
262 let payload_json = crypto::decrypt(&decoded.encrypted_data, &key)?;
263 let payload: ExportPayload = serde_json::from_slice(&payload_json)
264 .map_err(|e| ExportImportError::Format(format::FormatError::Json(e)))?;
265
266 let config_path =
268 default_config_path().map_err(|e| ExportImportError::Storage(e.to_string()))?;
269 let mut config =
270 load_config(&config_path).map_err(|e| ExportImportError::Storage(e.to_string()))?;
271
272 if options.force && !options.yes && !options.dry_run {
275 return Err(ExportImportError::Storage(
276 "--force requires --yes to confirm overwrite".to_string(),
277 ));
278 }
279
280 let mut profile_results = Vec::new();
282
283 for (name, export_profile) in payload.profiles {
285 let find_conflicting_name = || -> Option<String> {
287 config
288 .profiles
289 .iter()
290 .find(|(n, p)| *n != &name && p.team_id == export_profile.team_id)
291 .map(|(n, _)| n.clone())
292 };
293
294 let (action, reason, should_import) = if let Some(existing) = config.get(&name) {
296 if existing.team_id == export_profile.team_id {
298 if options.force {
300 (
301 ImportAction::Overwritten,
302 format!(
303 "Overwritten existing profile (same team_id: {})",
304 existing.team_id
305 ),
306 true,
307 )
308 } else {
309 (
310 ImportAction::Updated,
311 format!(
312 "Updated existing profile (same team_id: {})",
313 existing.team_id
314 ),
315 true,
316 )
317 }
318 } else {
319 if options.force {
321 (
322 ImportAction::Overwritten,
323 format!(
324 "Overwritten conflicting profile (team_id {} -> {})",
325 existing.team_id, export_profile.team_id
326 ),
327 true,
328 )
329 } else {
330 (
331 ImportAction::Skipped,
332 format!(
333 "Skipped due to team_id conflict ({} vs {})",
334 existing.team_id, export_profile.team_id
335 ),
336 false,
337 )
338 }
339 }
340 } else if let Some(conflicting_name) = find_conflicting_name() {
341 if options.force {
343 config.remove(&conflicting_name);
345 (
346 ImportAction::Overwritten,
347 format!(
348 "Overwritten profile '{}' with conflicting team_id {}",
349 conflicting_name, export_profile.team_id
350 ),
351 true,
352 )
353 } else {
354 (
355 ImportAction::Skipped,
356 format!(
357 "Skipped due to existing team_id {} under different name '{}'",
358 export_profile.team_id, conflicting_name
359 ),
360 false,
361 )
362 }
363 } else {
364 (
366 ImportAction::Updated,
367 "New profile imported".to_string(),
368 true,
369 )
370 };
371
372 if should_import && !options.dry_run {
374 let profile = Profile {
375 team_id: export_profile.team_id.clone(),
376 user_id: export_profile.user_id.clone(),
377 team_name: export_profile.team_name,
378 user_name: export_profile.user_name,
379 client_id: export_profile.client_id.clone(),
380 redirect_uri: None, scopes: None, bot_scopes: None, user_scopes: None, default_token_type: None,
385 };
386
387 config.set(name.clone(), profile);
388
389 let bot_token_key = make_token_key(&export_profile.team_id, &export_profile.user_id);
391 token_store.set(&bot_token_key, &export_profile.token)?;
392
393 if let Some(user_token) = &export_profile.user_token {
395 let user_token_key = format!(
396 "{}:{}:user",
397 &export_profile.team_id, &export_profile.user_id
398 );
399 token_store.set(&user_token_key, user_token)?;
400 }
401
402 if let Some(client_secret) = export_profile.client_secret {
404 store_oauth_client_secret(token_store, &name, &client_secret)?;
405 }
406 }
407
408 profile_results.push(ProfileImportResult {
409 profile_name: name,
410 action,
411 reason,
412 });
413 }
414
415 if !options.dry_run {
417 save_config(&config_path, &config)
418 .map_err(|e| ExportImportError::Storage(e.to_string()))?;
419 }
420
421 let updated = profile_results
423 .iter()
424 .filter(|r| r.action == ImportAction::Updated)
425 .count();
426 let skipped = profile_results
427 .iter()
428 .filter(|r| r.action == ImportAction::Skipped)
429 .count();
430 let overwritten = profile_results
431 .iter()
432 .filter(|r| r.action == ImportAction::Overwritten)
433 .count();
434 let total = profile_results.len();
435
436 Ok(ImportResult {
437 profiles: profile_results,
438 summary: ImportSummary {
439 updated,
440 skipped,
441 overwritten,
442 total,
443 },
444 dry_run: options.dry_run,
445 })
446}
447
448#[cfg(unix)]
450fn check_file_permissions(path: &Path) -> Result<()> {
451 use std::os::unix::fs::PermissionsExt;
452
453 if !path.exists() {
454 return Ok(());
455 }
456
457 let metadata = fs::metadata(path)?;
458 let permissions = metadata.permissions();
459 let mode = permissions.mode();
460
461 if mode & 0o777 != 0o600 {
463 return Err(ExportImportError::PermissionError(format!(
464 "File must have 0600 permissions, found: {:o}",
465 mode & 0o777
466 )));
467 }
468
469 Ok(())
470}
471
472#[cfg(not(unix))]
474fn check_file_permissions(_path: &Path) -> Result<()> {
475 Ok(())
476}
477
478#[cfg(unix)]
480fn write_secure_file(path: &Path, data: &[u8]) -> Result<()> {
481 use std::os::unix::fs::PermissionsExt;
482
483 fs::write(path, data)?;
485
486 let mut permissions = fs::metadata(path)?.permissions();
488 permissions.set_mode(0o600);
489 fs::set_permissions(path, permissions)?;
490
491 Ok(())
492}
493
494#[cfg(not(unix))]
496fn write_secure_file(path: &Path, data: &[u8]) -> Result<()> {
497 fs::write(path, data)?;
498 Ok(())
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504 use crate::profile::{InMemoryTokenStore, ProfilesConfig};
505 use tempfile::TempDir;
506
507 #[test]
508 fn test_export_requires_yes_flag() {
509 let token_store = InMemoryTokenStore::new();
510 let options = ExportOptions {
511 profile_name: None,
512 all: false,
513 output_path: "/tmp/test.export".to_string(),
514 passphrase: "password".to_string(),
515 yes: false,
516 };
517
518 let result = export_profiles(&token_store, &options);
519 assert!(result.is_err());
520 assert!(matches!(
521 result.unwrap_err(),
522 ExportImportError::ConfirmationRequired
523 ));
524 }
525
526 #[test]
527 fn test_export_empty_passphrase() {
528 let token_store = InMemoryTokenStore::new();
529 let options = ExportOptions {
530 profile_name: None,
531 all: false,
532 output_path: "/tmp/test.export".to_string(),
533 passphrase: "".to_string(),
534 yes: true,
535 };
536
537 let result = export_profiles(&token_store, &options);
538 assert!(result.is_err());
539 assert!(matches!(
540 result.unwrap_err(),
541 ExportImportError::EmptyPassphrase
542 ));
543 }
544
545 #[test]
546 fn test_import_empty_passphrase() {
547 let token_store = InMemoryTokenStore::new();
548 let options = ImportOptions {
549 input_path: "/tmp/test.export".to_string(),
550 passphrase: "".to_string(),
551 yes: false,
552 force: false,
553 dry_run: false,
554 json: false,
555 };
556
557 let result = import_profiles(&token_store, &options);
558 assert!(result.is_err());
559 assert!(matches!(
560 result.unwrap_err(),
561 ExportImportError::EmptyPassphrase
562 ));
563 }
564
565 #[test]
566 fn test_export_import_round_trip() {
567 let temp_dir = TempDir::new().unwrap();
568 let config_path = temp_dir.path().join("profiles.json");
569 let _export_path = temp_dir.path().join("export.dat");
570
571 let mut config = ProfilesConfig::new();
573 config.set(
574 "test".to_string(),
575 Profile {
576 team_id: "T123".to_string(),
577 user_id: "U456".to_string(),
578 team_name: Some("Test Team".to_string()),
579 user_name: Some("Test User".to_string()),
580 client_id: None,
581 redirect_uri: None,
582 scopes: None,
583 bot_scopes: None,
584 user_scopes: None,
585 default_token_type: None,
586 },
587 );
588 save_config(&config_path, &config).unwrap();
589
590 let token_store = InMemoryTokenStore::new();
592 let token_key = make_token_key("T123", "U456");
593 token_store.set(&token_key, "xoxb-test-token").unwrap();
594
595 }
599
600 #[cfg(unix)]
601 #[test]
602 fn test_write_secure_file_permissions() {
603 use std::os::unix::fs::PermissionsExt;
604
605 let temp_dir = TempDir::new().unwrap();
606 let file_path = temp_dir.path().join("secure.dat");
607
608 write_secure_file(&file_path, b"test data").unwrap();
609
610 let metadata = fs::metadata(&file_path).unwrap();
611 let mode = metadata.permissions().mode();
612
613 assert_eq!(mode & 0o777, 0o600, "File should have 0600 permissions");
614 }
615
616 #[test]
617 #[serial_test::serial]
618 fn test_import_dry_run_no_changes() {
619 use crate::auth::crypto::KdfParams;
620 use crate::auth::format::ExportProfile;
621
622 let temp_dir = TempDir::new().unwrap();
623 let config_path = temp_dir.path().join("profiles.json");
624 let import_path = temp_dir.path().join("import.dat");
625 let tokens_path = temp_dir.path().join("tokens.json");
626
627 std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
629
630 let mut config = ProfilesConfig::new();
632 config.set(
633 "existing".to_string(),
634 Profile {
635 team_id: "T123".to_string(),
636 user_id: "U456".to_string(),
637 team_name: Some("Existing Team".to_string()),
638 user_name: None,
639 client_id: None,
640 redirect_uri: None,
641 scopes: None,
642 bot_scopes: None,
643 user_scopes: None,
644 default_token_type: None,
645 },
646 );
647 save_config(&config_path, &config).unwrap();
648
649 let mut payload = crate::auth::format::ExportPayload::new();
651 payload.profiles.insert(
652 "new_profile".to_string(),
653 ExportProfile {
654 team_id: "T789".to_string(),
655 user_id: "U101".to_string(),
656 team_name: Some("New Team".to_string()),
657 user_name: None,
658 token: "xoxb-new-token".to_string(),
659 client_id: None,
660 client_secret: None,
661 user_token: None,
662 },
663 );
664
665 let passphrase = "test-password";
666 let kdf_params = KdfParams {
667 salt: crypto::generate_salt(),
668 ..Default::default()
669 };
670 let key = crypto::derive_key(passphrase, &kdf_params).unwrap();
671 let payload_json = serde_json::to_vec(&payload).unwrap();
672 let encrypted = crypto::encrypt(&payload_json, &key).unwrap();
673 let encoded = format::encode_export(&payload, &encrypted, &kdf_params).unwrap();
674
675 #[cfg(unix)]
676 write_secure_file(&import_path, &encoded).unwrap();
677 #[cfg(not(unix))]
678 std::fs::write(&import_path, &encoded).unwrap();
679
680 std::env::set_var("SLACK_RS_CONFIG_PATH", config_path.to_str().unwrap());
682
683 let token_store = crate::profile::FileTokenStore::with_path(tokens_path.clone()).unwrap();
685 let options = ImportOptions {
686 input_path: import_path.to_str().unwrap().to_string(),
687 passphrase: passphrase.to_string(),
688 yes: true,
689 force: false,
690 dry_run: true,
691 json: false,
692 };
693
694 let result = import_profiles(&token_store, &options).unwrap();
695
696 assert!(result.dry_run);
698
699 assert_eq!(result.profiles.len(), 1);
701 assert_eq!(result.profiles[0].profile_name, "new_profile");
702 assert_eq!(result.profiles[0].action, ImportAction::Updated);
703
704 let config_after = load_config(&config_path).unwrap();
706 assert_eq!(config_after.profiles.len(), 1);
707 assert!(config_after.get("new_profile").is_none());
708 assert!(config_after.get("existing").is_some());
709
710 let token_key = make_token_key("T789", "U101");
712 assert!(!token_store.exists(&token_key));
713
714 std::env::remove_var("SLACK_RS_TOKENS_PATH");
716 std::env::remove_var("SLACK_RS_CONFIG_PATH");
717 }
718
719 #[test]
726 #[serial_test::serial]
727 fn test_export_all_with_partial_skip() {
728 use tempfile::TempDir;
729
730 let temp_dir = TempDir::new().unwrap();
731 let config_path = temp_dir.path().join("profiles.json");
732 let export_path = temp_dir.path().join("export.dat");
733 let tokens_path = temp_dir.path().join("tokens.json");
734
735 std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
737 std::env::set_var("SLACK_RS_CONFIG_PATH", config_path.to_str().unwrap());
738
739 let mut config = ProfilesConfig::new();
741 config.set(
742 "profile1".to_string(),
743 Profile {
744 team_id: "T123".to_string(),
745 user_id: "U456".to_string(),
746 team_name: Some("Team 1".to_string()),
747 user_name: Some("User 1".to_string()),
748 client_id: None,
749 redirect_uri: None,
750 scopes: None,
751 bot_scopes: None,
752 user_scopes: None,
753 default_token_type: None,
754 },
755 );
756 config.set(
757 "profile2".to_string(),
758 Profile {
759 team_id: "T789".to_string(),
760 user_id: "U101".to_string(),
761 team_name: Some("Team 2".to_string()),
762 user_name: Some("User 2".to_string()),
763 client_id: None,
764 redirect_uri: None,
765 scopes: None,
766 bot_scopes: None,
767 user_scopes: None,
768 default_token_type: None,
769 },
770 );
771 save_config(&config_path, &config).unwrap();
772
773 let token_store = crate::profile::FileTokenStore::with_path(tokens_path.clone()).unwrap();
775 let token_key1 = make_token_key("T123", "U456");
776 token_store.set(&token_key1, "xoxb-token-1").unwrap();
777 let options = ExportOptions {
781 profile_name: None,
782 all: true,
783 output_path: export_path.to_str().unwrap().to_string(),
784 passphrase: "test-password".to_string(),
785 yes: true,
786 };
787
788 let result = export_profiles(&token_store, &options).unwrap();
789
790 assert_eq!(result.exported_count, 1);
792 assert_eq!(result.skipped_profiles.len(), 1);
793 assert!(result.skipped_profiles.contains(&"profile2".to_string()));
794
795 assert!(export_path.exists());
797
798 std::env::remove_var("SLACK_RS_TOKENS_PATH");
800 std::env::remove_var("SLACK_RS_CONFIG_PATH");
801 }
802
803 #[test]
804 #[serial_test::serial]
805 fn test_export_all_with_all_skipped() {
806 use tempfile::TempDir;
807
808 let temp_dir = TempDir::new().unwrap();
809 let config_path = temp_dir.path().join("profiles.json");
810 let export_path = temp_dir.path().join("export.dat");
811 let tokens_path = temp_dir.path().join("tokens.json");
812
813 std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
815 std::env::set_var("SLACK_RS_CONFIG_PATH", config_path.to_str().unwrap());
816
817 let mut config = ProfilesConfig::new();
819 config.set(
820 "profile1".to_string(),
821 Profile {
822 team_id: "T123".to_string(),
823 user_id: "U456".to_string(),
824 team_name: Some("Team 1".to_string()),
825 user_name: Some("User 1".to_string()),
826 client_id: None,
827 redirect_uri: None,
828 scopes: None,
829 bot_scopes: None,
830 user_scopes: None,
831 default_token_type: None,
832 },
833 );
834 config.set(
835 "profile2".to_string(),
836 Profile {
837 team_id: "T789".to_string(),
838 user_id: "U101".to_string(),
839 team_name: Some("Team 2".to_string()),
840 user_name: Some("User 2".to_string()),
841 client_id: None,
842 redirect_uri: None,
843 scopes: None,
844 bot_scopes: None,
845 user_scopes: None,
846 default_token_type: None,
847 },
848 );
849 save_config(&config_path, &config).unwrap();
850
851 let token_store = crate::profile::FileTokenStore::with_path(tokens_path.clone()).unwrap();
853
854 let options = ExportOptions {
856 profile_name: None,
857 all: true,
858 output_path: export_path.to_str().unwrap().to_string(),
859 passphrase: "test-password".to_string(),
860 yes: true,
861 };
862
863 let result = export_profiles(&token_store, &options);
864
865 assert!(result.is_err());
867 assert!(matches!(result.unwrap_err(), ExportImportError::NoProfiles));
868
869 std::env::remove_var("SLACK_RS_TOKENS_PATH");
871 std::env::remove_var("SLACK_RS_CONFIG_PATH");
872 }
873
874 #[test]
875 #[serial_test::serial]
876 fn test_export_single_profile_with_missing_token_fails() {
877 use tempfile::TempDir;
878
879 let temp_dir = TempDir::new().unwrap();
880 let config_path = temp_dir.path().join("profiles.json");
881 let export_path = temp_dir.path().join("export.dat");
882 let tokens_path = temp_dir.path().join("tokens.json");
883
884 std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
886 std::env::set_var("SLACK_RS_CONFIG_PATH", config_path.to_str().unwrap());
887
888 let mut config = ProfilesConfig::new();
890 config.set(
891 "profile1".to_string(),
892 Profile {
893 team_id: "T123".to_string(),
894 user_id: "U456".to_string(),
895 team_name: Some("Team 1".to_string()),
896 user_name: Some("User 1".to_string()),
897 client_id: None,
898 redirect_uri: None,
899 scopes: None,
900 bot_scopes: None,
901 user_scopes: None,
902 default_token_type: None,
903 },
904 );
905 save_config(&config_path, &config).unwrap();
906
907 let token_store = crate::profile::FileTokenStore::with_path(tokens_path.clone()).unwrap();
909
910 let options = ExportOptions {
912 profile_name: Some("profile1".to_string()),
913 all: false,
914 output_path: export_path.to_str().unwrap().to_string(),
915 passphrase: "test-password".to_string(),
916 yes: true,
917 };
918
919 let result = export_profiles(&token_store, &options);
920
921 assert!(result.is_err());
923 match result.unwrap_err() {
924 ExportImportError::TokenNotFound(name) => {
925 assert_eq!(name, "profile1");
926 }
927 _ => panic!("Expected TokenNotFound error"),
928 }
929
930 std::env::remove_var("SLACK_RS_TOKENS_PATH");
932 std::env::remove_var("SLACK_RS_CONFIG_PATH");
933 }
934
935 #[test]
936 #[serial_test::serial]
937 fn test_export_import_with_user_token() {
938 use tempfile::TempDir;
939
940 let temp_dir = TempDir::new().unwrap();
941 let config_path = temp_dir.path().join("profiles.json");
942 let export_path = temp_dir.path().join("export.dat");
943 let tokens_path = temp_dir.path().join("tokens.json");
944
945 std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
947 std::env::set_var("SLACK_RS_CONFIG_PATH", config_path.to_str().unwrap());
948
949 let mut config = ProfilesConfig::new();
951 config.set(
952 "test".to_string(),
953 Profile {
954 team_id: "T123".to_string(),
955 user_id: "U456".to_string(),
956 team_name: Some("Test Team".to_string()),
957 user_name: Some("Test User".to_string()),
958 client_id: None,
959 redirect_uri: None,
960 scopes: None,
961 bot_scopes: None,
962 user_scopes: None,
963 default_token_type: None,
964 },
965 );
966 save_config(&config_path, &config).unwrap();
967
968 let token_store = crate::profile::FileTokenStore::with_path(tokens_path.clone()).unwrap();
970 let bot_token_key = make_token_key("T123", "U456");
971 let user_token_key = "T123:U456:user".to_string();
972 token_store.set(&bot_token_key, "xoxb-bot-token").unwrap();
973 token_store.set(&user_token_key, "xoxp-user-token").unwrap();
974
975 let export_options = ExportOptions {
977 profile_name: Some("test".to_string()),
978 all: false,
979 output_path: export_path.to_str().unwrap().to_string(),
980 passphrase: "test-password".to_string(),
981 yes: true,
982 };
983 let export_result = export_profiles(&token_store, &export_options).unwrap();
984 assert_eq!(export_result.exported_count, 1);
985 assert_eq!(export_result.skipped_profiles.len(), 0);
986
987 token_store.delete(&bot_token_key).ok();
989 token_store.delete(&user_token_key).ok();
990
991 let import_options = ImportOptions {
993 input_path: export_path.to_str().unwrap().to_string(),
994 passphrase: "test-password".to_string(),
995 yes: true,
996 force: false,
997 dry_run: false,
998 json: false,
999 };
1000 let import_result = import_profiles(&token_store, &import_options).unwrap();
1001 assert_eq!(import_result.summary.updated, 1);
1002 assert_eq!(import_result.summary.skipped, 0);
1003
1004 let bot_token = token_store.get(&bot_token_key).unwrap();
1006 assert_eq!(bot_token, "xoxb-bot-token");
1007 let user_token = token_store.get(&user_token_key).unwrap();
1008 assert_eq!(user_token, "xoxp-user-token");
1009
1010 std::env::remove_var("SLACK_RS_TOKENS_PATH");
1012 std::env::remove_var("SLACK_RS_CONFIG_PATH");
1013 }
1014
1015 #[test]
1016 #[serial_test::serial]
1017 fn test_export_user_token_only() {
1018 use tempfile::TempDir;
1019
1020 let temp_dir = TempDir::new().unwrap();
1021 let config_path = temp_dir.path().join("profiles.json");
1022 let export_path = temp_dir.path().join("export.dat");
1023 let tokens_path = temp_dir.path().join("tokens.json");
1024
1025 std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
1027 std::env::set_var("SLACK_RS_CONFIG_PATH", config_path.to_str().unwrap());
1028
1029 let mut config = ProfilesConfig::new();
1031 config.set(
1032 "test".to_string(),
1033 Profile {
1034 team_id: "T123".to_string(),
1035 user_id: "U456".to_string(),
1036 team_name: Some("Test Team".to_string()),
1037 user_name: Some("Test User".to_string()),
1038 client_id: None,
1039 redirect_uri: None,
1040 scopes: None,
1041 bot_scopes: None,
1042 user_scopes: None,
1043 default_token_type: None,
1044 },
1045 );
1046 save_config(&config_path, &config).unwrap();
1047
1048 let token_store = crate::profile::FileTokenStore::with_path(tokens_path.clone()).unwrap();
1050 let user_token_key = "T123:U456:user".to_string();
1051 token_store.set(&user_token_key, "xoxp-user-token").unwrap();
1052
1053 let export_options = ExportOptions {
1055 profile_name: Some("test".to_string()),
1056 all: false,
1057 output_path: export_path.to_str().unwrap().to_string(),
1058 passphrase: "test-password".to_string(),
1059 yes: true,
1060 };
1061 let export_result = export_profiles(&token_store, &export_options).unwrap();
1062 assert_eq!(export_result.exported_count, 1);
1063 assert_eq!(export_result.skipped_profiles.len(), 0);
1064
1065 token_store.delete(&user_token_key).ok();
1067
1068 let import_options = ImportOptions {
1070 input_path: export_path.to_str().unwrap().to_string(),
1071 passphrase: "test-password".to_string(),
1072 yes: true,
1073 force: false,
1074 dry_run: false,
1075 json: false,
1076 };
1077 let import_result = import_profiles(&token_store, &import_options).unwrap();
1078 assert_eq!(import_result.summary.updated, 1);
1079
1080 let user_token = token_store.get(&user_token_key).unwrap();
1082 assert_eq!(user_token, "xoxp-user-token");
1083
1084 std::env::remove_var("SLACK_RS_TOKENS_PATH");
1086 std::env::remove_var("SLACK_RS_CONFIG_PATH");
1087 }
1088}