Skip to main content

slack_rs/auth/
export_import.rs

1//! Export and import commands for profile backup and migration
2
3use 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/// Options for export command
47#[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/// Options for import command
57#[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/// Import action taken for a profile
68#[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/// Result for a single profile import
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct ProfileImportResult {
89    pub profile_name: String,
90    pub action: ImportAction,
91    pub reason: String,
92}
93
94/// Overall import result
95#[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/// Summary of import operation
103#[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/// Result of export operation with skip information
112#[derive(Debug, Clone)]
113pub struct ExportResult {
114    pub exported_count: usize,
115    pub skipped_profiles: Vec<String>,
116}
117
118/// Export profiles to encrypted file
119pub fn export_profiles(
120    token_store: &dyn TokenStore,
121    options: &ExportOptions,
122) -> Result<ExportResult> {
123    // Require --yes confirmation
124    if !options.yes {
125        return Err(ExportImportError::ConfirmationRequired);
126    }
127
128    // Validate passphrase
129    if options.passphrase.is_empty() {
130        return Err(ExportImportError::EmptyPassphrase);
131    }
132
133    // Load profiles
134    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    // Select profiles to export
140    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        // Default to "default" profile
153        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    // Build export payload and track skipped profiles
164    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        // Try to get bot token and user token
172        let bot_token = token_store.get(&bot_token_key).ok();
173        let user_token = token_store.get(&user_token_key).ok();
174
175        // Check if we have at least one token
176        if bot_token.is_none() && user_token.is_none() {
177            // No tokens found
178            if options.all {
179                // For --all, skip this profile and continue
180                skipped_profiles.push(name);
181            } else {
182                // For single profile export, fail immediately
183                return Err(ExportImportError::TokenNotFound(name));
184            }
185            continue;
186        }
187
188        // Try to get OAuth credentials (non-fatal if not found)
189        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    // Check if we have any profiles to export after skipping
208    if payload.profiles.is_empty() {
209        return Err(ExportImportError::NoProfiles);
210    }
211
212    // Encrypt payload
213    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    // Encode to binary format
224    let encoded = format::encode_export(&payload, &encrypted, &kdf_params)?;
225
226    // Check existing file permissions
227    let output_path = Path::new(&options.output_path);
228    if output_path.exists() {
229        check_file_permissions(output_path)?;
230    }
231
232    // Write to file with 0600 permissions
233    write_secure_file(output_path, &encoded)?;
234
235    Ok(ExportResult {
236        exported_count: payload.profiles.len(),
237        skipped_profiles,
238    })
239}
240
241/// Import profiles from encrypted file
242pub fn import_profiles(
243    token_store: &dyn TokenStore,
244    options: &ImportOptions,
245) -> Result<ImportResult> {
246    // Validate passphrase
247    if options.passphrase.is_empty() {
248        return Err(ExportImportError::EmptyPassphrase);
249    }
250
251    // Read and check file permissions
252    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    // Decode from binary format
258    let decoded = format::decode_export(&encoded_data)?;
259
260    // Decrypt payload
261    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    // Load existing profiles
267    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    // Check for conflicts and determine actions
273    // Force requires --yes
274    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    // Track results for each profile
281    let mut profile_results = Vec::new();
282
283    // Import profiles - no early validation, handle conflicts during import
284    for (name, export_profile) in payload.profiles {
285        // Helper to find conflicting profile name (different name, same team_id)
286        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        // Determine action and reason based on current state
295        let (action, reason, should_import) = if let Some(existing) = config.get(&name) {
296            // Profile name already exists
297            if existing.team_id == export_profile.team_id {
298                // Same team_id: update or overwrite
299                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                // Different team_id: conflict
320                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            // team_id exists under different name
342            if options.force {
343                // Remove the conflicting profile before importing
344                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            // New profile
365            (
366                ImportAction::Updated,
367                "New profile imported".to_string(),
368                true,
369            )
370        };
371
372        // Only perform import actions if should_import is true
373        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, // Not exported/imported for security
381                scopes: None,       // Not exported/imported for security
382                bot_scopes: None,   // Not exported/imported for security
383                user_scopes: None,  // Not exported/imported for security
384                default_token_type: None,
385            };
386
387            config.set(name.clone(), profile);
388
389            // Store bot token
390            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            // Store user token if present
394            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            // Store OAuth client secret if present
403            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    // Save config unless dry-run
416    if !options.dry_run {
417        save_config(&config_path, &config)
418            .map_err(|e| ExportImportError::Storage(e.to_string()))?;
419    }
420
421    // Calculate summary
422    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/// Check file permissions (Unix only)
449#[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    // Check if file is 0600 (owner read/write only)
462    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/// Check file permissions (non-Unix - always succeeds)
473#[cfg(not(unix))]
474fn check_file_permissions(_path: &Path) -> Result<()> {
475    Ok(())
476}
477
478/// Write file with secure permissions (Unix: 0600)
479#[cfg(unix)]
480fn write_secure_file(path: &Path, data: &[u8]) -> Result<()> {
481    use std::os::unix::fs::PermissionsExt;
482
483    // Write file
484    fs::write(path, data)?;
485
486    // Set permissions to 0600
487    let mut permissions = fs::metadata(path)?.permissions();
488    permissions.set_mode(0o600);
489    fs::set_permissions(path, permissions)?;
490
491    Ok(())
492}
493
494/// Write file with secure permissions (non-Unix - no permission setting)
495#[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        // Set up test profile
572        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        // Set up token store
591        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        // Export (this will use default_config_path, so we need to work around that)
596        // For now, skip the full integration test as it requires mocking config path
597        // This is covered by crypto and format tests
598    }
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        // Set SLACK_RS_TOKENS_PATH for file-based token store
628        std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
629
630        // Create existing profile
631        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        // Create encrypted export file
650        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        // Mock config path by using environment variable
681        std::env::set_var("SLACK_RS_CONFIG_PATH", config_path.to_str().unwrap());
682
683        // Test dry-run import
684        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        // Verify dry-run flag is set
697        assert!(result.dry_run);
698
699        // Verify action is "updated" for new profile
700        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        // Verify no changes were made to config file
705        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        // Verify no token was stored
711        let token_key = make_token_key("T789", "U101");
712        assert!(!token_store.exists(&token_key));
713
714        // Clean up
715        std::env::remove_var("SLACK_RS_TOKENS_PATH");
716        std::env::remove_var("SLACK_RS_CONFIG_PATH");
717    }
718
719    // Note: More comprehensive integration tests for dry-run would require
720    // mocking the config path system, which is not currently supported.
721    // The test_import_dry_run_no_changes test provides basic coverage that
722    // dry-run prevents file writes. Manual testing is recommended for
723    // full validation of update/conflict scenarios.
724
725    #[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        // Set SLACK_RS_TOKENS_PATH for file-based token store
736        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        // Create multiple profiles
740        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        // Set up token store with only one token (profile1)
774        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        // Note: No token for profile2
778
779        // Export with --all
780        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        // Verify result
791        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        // Verify export file was created
796        assert!(export_path.exists());
797
798        // Clean up
799        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        // Set SLACK_RS_TOKENS_PATH for file-based token store
814        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        // Create multiple profiles
818        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        // Set up token store with NO tokens
852        let token_store = crate::profile::FileTokenStore::with_path(tokens_path.clone()).unwrap();
853
854        // Export with --all
855        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        // Verify error when all profiles are skipped
866        assert!(result.is_err());
867        assert!(matches!(result.unwrap_err(), ExportImportError::NoProfiles));
868
869        // Clean up
870        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        // Set SLACK_RS_TOKENS_PATH for file-based token store
885        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        // Create a profile
889        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        // Set up token store with NO token
908        let token_store = crate::profile::FileTokenStore::with_path(tokens_path.clone()).unwrap();
909
910        // Export single profile (not --all)
911        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        // Verify error for single profile export with missing token
922        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        // Clean up
931        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        // Set environment variables
946        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        // Create a profile
950        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        // Set up token store with both bot and user tokens
969        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        // Export
976        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        // Clear tokens to simulate fresh import
988        token_store.delete(&bot_token_key).ok();
989        token_store.delete(&user_token_key).ok();
990
991        // Import
992        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        // Verify both tokens were restored
1005        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        // Clean up
1011        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        // Set environment variables
1026        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        // Create a profile
1030        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        // Set up token store with only user token (no bot token)
1049        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        // Export should succeed
1054        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        // Clear tokens to simulate fresh import
1066        token_store.delete(&user_token_key).ok();
1067
1068        // Import
1069        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        // Verify user token was restored
1081        let user_token = token_store.get(&user_token_key).unwrap();
1082        assert_eq!(user_token, "xoxp-user-token");
1083
1084        // Clean up
1085        std::env::remove_var("SLACK_RS_TOKENS_PATH");
1086        std::env::remove_var("SLACK_RS_CONFIG_PATH");
1087    }
1088}