1use crate::auth::cloudflared::{CloudflaredError, CloudflaredTunnel};
4use crate::debug;
5use crate::oauth::{
6 build_authorization_url, exchange_code, generate_pkce, generate_state, resolve_callback_port,
7 run_callback_server, OAuthConfig, OAuthError,
8};
9use crate::profile::{
10 create_token_store, default_config_path, load_config, make_token_key, save_config, Profile,
11 ProfilesConfig,
12};
13use std::io::{self, Write};
14use std::path::PathBuf;
15use std::process::Command;
16
17struct LoginConfig {
19 client_id: String,
20 client_secret: String,
21 redirect_uri: String,
22 bot_scopes: Vec<String>,
23 user_scopes: Vec<String>,
24}
25
26fn resolve_client_id(
28 cli_arg: Option<String>,
29 existing_profile: Option<&Profile>,
30 non_interactive: bool,
31) -> Result<String, OAuthError> {
32 if let Some(id) = cli_arg {
33 return Ok(id);
34 }
35
36 if let Some(profile) = existing_profile {
37 if let Some(saved_id) = &profile.client_id {
38 return Ok(saved_id.clone());
39 }
40 }
41
42 prompt_for_client_id_with_mode(non_interactive)
43}
44
45fn resolve_redirect_uri(
47 existing_profile: Option<&Profile>,
48 default_uri: &str,
49 non_interactive: bool,
50) -> Result<String, OAuthError> {
51 if let Some(profile) = existing_profile {
52 if let Some(saved_uri) = &profile.redirect_uri {
53 return Ok(saved_uri.clone());
54 }
55 }
56
57 if non_interactive {
58 Ok(default_uri.to_string())
59 } else {
60 prompt_for_redirect_uri(default_uri)
61 }
62}
63
64fn resolve_bot_scopes(
66 cli_arg: Option<Vec<String>>,
67 existing_profile: Option<&Profile>,
68) -> Result<Vec<String>, OAuthError> {
69 if let Some(scopes) = cli_arg {
70 return Ok(scopes);
71 }
72
73 if let Some(profile) = existing_profile {
74 if let Some(saved_scopes) = profile.get_bot_scopes() {
75 return Ok(saved_scopes);
76 }
77 }
78
79 prompt_for_bot_scopes()
80}
81
82fn resolve_user_scopes(
84 cli_arg: Option<Vec<String>>,
85 existing_profile: Option<&Profile>,
86) -> Result<Vec<String>, OAuthError> {
87 if let Some(scopes) = cli_arg {
88 return Ok(scopes);
89 }
90
91 if let Some(profile) = existing_profile {
92 if let Some(saved_scopes) = profile.get_user_scopes() {
93 return Ok(saved_scopes);
94 }
95 }
96
97 prompt_for_user_scopes()
98}
99
100fn resolve_client_secret(
102 token_store: &dyn crate::profile::TokenStore,
103 profile_name: &str,
104 non_interactive: bool,
105) -> Result<String, OAuthError> {
106 match crate::profile::get_oauth_client_secret(token_store, profile_name) {
107 Ok(secret) => {
108 println!("Using saved client secret from token store.");
109 Ok(secret)
110 }
111 Err(_) => {
112 if non_interactive {
113 Err(OAuthError::ConfigError(
114 "Client secret is required. In non-interactive mode, save it first with 'config oauth set'".to_string()
115 ))
116 } else {
117 prompt_for_client_secret()
118 }
119 }
120 }
121}
122
123fn check_non_interactive_params(
125 client_id: &Option<String>,
126 bot_scopes: &Option<Vec<String>>,
127 user_scopes: &Option<Vec<String>>,
128 existing_profile: Option<&Profile>,
129 _profile_name: &str,
130) -> Result<(), OAuthError> {
131 let mut missing_params = Vec::new();
132
133 let has_client_id = client_id.is_some()
135 || existing_profile
136 .and_then(|p| p.client_id.as_ref())
137 .is_some();
138 if !has_client_id {
139 missing_params.push("--client-id <id>");
140 }
141
142 let has_bot_scopes =
144 bot_scopes.is_some() || existing_profile.and_then(|p| p.get_bot_scopes()).is_some();
145 if !has_bot_scopes {
146 missing_params.push("--bot-scopes <scopes>");
147 }
148
149 let has_user_scopes =
151 user_scopes.is_some() || existing_profile.and_then(|p| p.get_user_scopes()).is_some();
152 if !has_user_scopes {
153 missing_params.push("--user-scopes <scopes>");
154 }
155
156 if !missing_params.is_empty() {
158 let missing_list = missing_params.join(", ");
159 return Err(OAuthError::ConfigError(format!(
160 "Missing required OAuth parameters in non-interactive mode: {}\n\
161 Provide them via CLI flags or save with 'config oauth set':\n\
162 Example: slack-rs auth login --client-id <id> --bot-scopes <scopes> --user-scopes <scopes>",
163 missing_list
164 )));
165 }
166
167 Ok(())
168}
169
170fn resolve_login_config(
172 client_id: Option<String>,
173 redirect_uri: &str,
174 bot_scopes: Option<Vec<String>>,
175 user_scopes: Option<Vec<String>>,
176 existing_profile: Option<&Profile>,
177 profile_name: &str,
178 non_interactive: bool,
179) -> Result<LoginConfig, OAuthError> {
180 let token_store = create_token_store()
181 .map_err(|e| OAuthError::ConfigError(format!("Failed to create token store: {}", e)))?;
182
183 let resolved_client_id = resolve_client_id(client_id, existing_profile, non_interactive)?;
184 let resolved_redirect_uri =
185 resolve_redirect_uri(existing_profile, redirect_uri, non_interactive)?;
186 let resolved_bot_scopes = resolve_bot_scopes(bot_scopes, existing_profile)?;
187 let resolved_user_scopes = resolve_user_scopes(user_scopes, existing_profile)?;
188 let resolved_client_secret =
189 resolve_client_secret(&*token_store, profile_name, non_interactive)?;
190
191 Ok(LoginConfig {
192 client_id: resolved_client_id,
193 client_secret: resolved_client_secret,
194 redirect_uri: resolved_redirect_uri,
195 bot_scopes: resolved_bot_scopes,
196 user_scopes: resolved_user_scopes,
197 })
198}
199
200#[allow(dead_code)]
212#[allow(clippy::too_many_arguments)]
213pub async fn login_with_credentials(
214 client_id: Option<String>,
215 profile_name: Option<String>,
216 redirect_uri: String,
217 _scopes: Vec<String>,
218 bot_scopes: Option<Vec<String>>,
219 user_scopes: Option<Vec<String>>,
220 base_url: Option<String>,
221 non_interactive: bool,
222) -> Result<(), OAuthError> {
223 let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
224
225 let config_path = default_config_path()
227 .map_err(|e| OAuthError::ConfigError(format!("Failed to get config path: {}", e)))?;
228 let existing_config = load_config(&config_path).ok();
229 let existing_profile = existing_config.as_ref().and_then(|c| c.get(&profile_name));
230
231 if non_interactive {
233 check_non_interactive_params(
234 &client_id,
235 &bot_scopes,
236 &user_scopes,
237 existing_profile,
238 &profile_name,
239 )?;
240 }
241
242 let login_config = resolve_login_config(
244 client_id,
245 &redirect_uri,
246 bot_scopes,
247 user_scopes,
248 existing_profile,
249 &profile_name,
250 non_interactive,
251 )?;
252
253 let oauth_config = OAuthConfig {
255 client_id: login_config.client_id.clone(),
256 client_secret: login_config.client_secret.clone(),
257 redirect_uri: login_config.redirect_uri.clone(),
258 scopes: login_config.bot_scopes.clone(),
259 user_scopes: login_config.user_scopes.clone(),
260 };
261
262 let (team_id, team_name, user_id, bot_token, user_token) =
264 perform_oauth_flow(&oauth_config, base_url.as_deref()).await?;
265
266 save_profile_and_credentials(SaveCredentials {
268 config_path: &config_path,
269 profile_name: &profile_name,
270 team_id: &team_id,
271 team_name: &team_name,
272 user_id: &user_id,
273 bot_token: bot_token.as_deref(),
274 user_token: user_token.as_deref(),
275 client_id: &login_config.client_id,
276 client_secret: &login_config.client_secret,
277 redirect_uri: &login_config.redirect_uri,
278 scopes: &login_config.bot_scopes, bot_scopes: &login_config.bot_scopes,
280 user_scopes: &login_config.user_scopes,
281 })?;
282
283 println!("ā Authentication successful!");
284 println!("Profile '{}' saved.", profile_name);
285
286 Ok(())
287}
288
289#[allow(dead_code)]
291fn prompt_for_client_id() -> Result<String, OAuthError> {
292 prompt_for_client_id_with_mode(false)
293}
294
295fn prompt_for_client_id_with_mode(non_interactive: bool) -> Result<String, OAuthError> {
297 if non_interactive {
298 return Err(OAuthError::ConfigError(
299 "Client ID is required. In non-interactive mode, provide it via --client-id flag or save it in config with 'config oauth set'".to_string()
300 ));
301 }
302
303 loop {
304 print!("Enter OAuth client ID: ");
305 io::stdout()
306 .flush()
307 .map_err(|e| OAuthError::ConfigError(format!("Failed to flush stdout: {}", e)))?;
308
309 let mut input = String::new();
310 io::stdin()
311 .read_line(&mut input)
312 .map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
313
314 let trimmed = input.trim();
315 if !trimmed.is_empty() {
316 return Ok(trimmed.to_string());
317 }
318 eprintln!("Client ID cannot be empty. Please try again.");
319 }
320}
321
322pub fn prompt_for_client_secret() -> Result<String, OAuthError> {
324 loop {
325 let input = rpassword::prompt_password("Enter OAuth client secret: ")
326 .map_err(|e| OAuthError::ConfigError(format!("Failed to read password: {}", e)))?;
327
328 let trimmed = input.trim();
329 if !trimmed.is_empty() {
330 println!();
332 return Ok(trimmed.to_string());
333 }
334 eprintln!("Client secret cannot be empty. Please try again.");
335 }
336}
337
338fn prompt_for_redirect_uri(default: &str) -> Result<String, OAuthError> {
340 print!("Enter OAuth redirect URI [{}]: ", default);
341 io::stdout()
342 .flush()
343 .map_err(|e| OAuthError::ConfigError(format!("Failed to flush stdout: {}", e)))?;
344
345 let mut input = String::new();
346 io::stdin()
347 .read_line(&mut input)
348 .map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
349
350 let trimmed = input.trim();
351 if trimmed.is_empty() {
352 Ok(default.to_string())
353 } else {
354 Ok(trimmed.to_string())
355 }
356}
357
358fn prompt_for_bot_scopes() -> Result<Vec<String>, OAuthError> {
360 print!("Enter bot scopes (comma-separated, or 'all'/'bot:all' for preset) [all]: ");
361 io::stdout()
362 .flush()
363 .map_err(|e| OAuthError::ConfigError(format!("Failed to flush stdout: {}", e)))?;
364
365 let mut input = String::new();
366 io::stdin()
367 .read_line(&mut input)
368 .map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
369
370 let trimmed = input.trim();
371 let scopes_input = if trimmed.is_empty() {
372 vec!["all".to_string()]
373 } else {
374 trimmed.split(',').map(|s| s.trim().to_string()).collect()
375 };
376
377 Ok(crate::oauth::expand_scopes_with_context(
378 &scopes_input,
379 true,
380 ))
381}
382
383fn prompt_for_user_scopes() -> Result<Vec<String>, OAuthError> {
385 print!("Enter user scopes (comma-separated, or 'all'/'user:all' for preset) [all]: ");
386 io::stdout()
387 .flush()
388 .map_err(|e| OAuthError::ConfigError(format!("Failed to flush stdout: {}", e)))?;
389
390 let mut input = String::new();
391 io::stdin()
392 .read_line(&mut input)
393 .map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
394
395 let trimmed = input.trim();
396 let scopes_input = if trimmed.is_empty() {
397 vec!["all".to_string()]
398 } else {
399 trimmed.split(',').map(|s| s.trim().to_string()).collect()
400 };
401
402 Ok(crate::oauth::expand_scopes_with_context(
403 &scopes_input,
404 false,
405 ))
406}
407
408async fn perform_oauth_flow(
410 config: &OAuthConfig,
411 base_url: Option<&str>,
412) -> Result<
413 (
414 String,
415 Option<String>,
416 String,
417 Option<String>,
418 Option<String>,
419 ),
420 OAuthError,
421> {
422 config.validate()?;
424
425 let (code_verifier, code_challenge) = generate_pkce();
427 let state = generate_state();
428
429 let auth_url = build_authorization_url(config, &code_challenge, &state)?;
431
432 println!("Opening browser for authentication...");
433 println!("If the browser doesn't open, visit this URL:");
434 println!("{}", auth_url);
435 println!();
436
437 if let Err(e) = open_browser(&auth_url) {
439 println!("Failed to open browser: {}", e);
440 println!("Please open the URL manually in your browser.");
441 }
442
443 let port = resolve_callback_port()?;
445 println!("Waiting for authentication callback...");
446 let callback_result = run_callback_server(port, state.clone(), 300).await?;
447
448 println!("Received authorization code, exchanging for token...");
449
450 let oauth_response =
452 exchange_code(config, &callback_result.code, &code_verifier, base_url).await?;
453
454 let team_id = oauth_response
456 .team
457 .as_ref()
458 .map(|t| t.id.clone())
459 .ok_or_else(|| OAuthError::SlackError("Missing team information".to_string()))?;
460
461 let team_name = oauth_response.team.as_ref().map(|t| t.name.clone());
462
463 let user_id = oauth_response
464 .authed_user
465 .as_ref()
466 .map(|u| u.id.clone())
467 .ok_or_else(|| OAuthError::SlackError("Missing user information".to_string()))?;
468
469 let bot_token = oauth_response.access_token.clone();
471
472 let user_token = oauth_response
474 .authed_user
475 .as_ref()
476 .and_then(|u| u.access_token.clone());
477
478 if debug::enabled() {
479 debug::log(format!(
480 "OAuth tokens received: bot_token_present={}, user_token_present={}",
481 bot_token.is_some(),
482 user_token.is_some()
483 ));
484 if let Some(ref token) = bot_token {
485 debug::log(format!("bot_token={}", debug::token_hint(token)));
486 }
487 if let Some(ref token) = user_token {
488 debug::log(format!("user_token={}", debug::token_hint(token)));
489 }
490 }
491
492 if bot_token.is_none() && user_token.is_none() {
494 return Err(OAuthError::SlackError(
495 "No access tokens received".to_string(),
496 ));
497 }
498
499 Ok((team_id, team_name, user_id, bot_token, user_token))
500}
501
502struct SaveCredentials<'a> {
504 config_path: &'a std::path::Path,
505 profile_name: &'a str,
506 team_id: &'a str,
507 team_name: &'a Option<String>,
508 user_id: &'a str,
509 bot_token: Option<&'a str>, user_token: Option<&'a str>, client_id: &'a str,
512 client_secret: &'a str,
513 redirect_uri: &'a str,
514 scopes: &'a [String], bot_scopes: &'a [String], user_scopes: &'a [String], }
518
519fn save_profile_and_credentials(creds: SaveCredentials) -> Result<(), OAuthError> {
521 let mut profiles_config =
523 load_config(creds.config_path).unwrap_or_else(|_| ProfilesConfig::new());
524
525 let existing_default_token_type = profiles_config
527 .get(creds.profile_name)
528 .and_then(|p| p.default_token_type);
529
530 let has_user_token = creds.user_token.is_some();
532 let default_token_type =
533 compute_initial_default_token_type(existing_default_token_type, has_user_token);
534
535 let profile = Profile {
537 team_id: creds.team_id.to_string(),
538 user_id: creds.user_id.to_string(),
539 team_name: creds.team_name.clone(),
540 user_name: None,
541 client_id: Some(creds.client_id.to_string()),
542 redirect_uri: Some(creds.redirect_uri.to_string()),
543 scopes: Some(creds.scopes.to_vec()), bot_scopes: Some(creds.bot_scopes.to_vec()),
545 user_scopes: Some(creds.user_scopes.to_vec()),
546 default_token_type: Some(default_token_type),
547 };
548
549 profiles_config
550 .set_or_update(creds.profile_name.to_string(), profile)
551 .map_err(|e| OAuthError::ConfigError(format!("Failed to save profile: {}", e)))?;
552
553 save_config(creds.config_path, &profiles_config)
554 .map_err(|e| OAuthError::ConfigError(format!("Failed to save config: {}", e)))?;
555
556 let token_store = create_token_store()
558 .map_err(|e| OAuthError::ConfigError(format!("Failed to create token store: {}", e)))?;
559
560 if let Some(bot_token) = creds.bot_token {
562 let bot_token_key = make_token_key(creds.team_id, creds.user_id);
563 token_store
564 .set(&bot_token_key, bot_token)
565 .map_err(|e| OAuthError::ConfigError(format!("Failed to save bot token: {}", e)))?;
566 }
567
568 if let Some(user_token) = creds.user_token {
570 let user_token_key = format!("{}:{}:user", creds.team_id, creds.user_id);
571 debug::log(format!("Saving user token with key: {}", user_token_key));
572 token_store
573 .set(&user_token_key, user_token)
574 .map_err(|e| OAuthError::ConfigError(format!("Failed to save user token: {}", e)))?;
575 debug::log("User token saved successfully");
576 } else {
577 debug::log("No user token to save (user_token is None)");
578 }
579
580 let client_secret_key = format!("oauth-client-secret:{}", creds.profile_name);
582 token_store
583 .set(&client_secret_key, creds.client_secret)
584 .map_err(|e| OAuthError::ConfigError(format!("Failed to save client secret: {}", e)))?;
585
586 Ok(())
587}
588
589#[allow(dead_code)]
596pub async fn login(
597 config: OAuthConfig,
598 profile_name: Option<String>,
599 base_url: Option<String>,
600) -> Result<(), OAuthError> {
601 config.validate()?;
603
604 let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
605
606 let (code_verifier, code_challenge) = generate_pkce();
608 let state = generate_state();
609
610 let auth_url = build_authorization_url(&config, &code_challenge, &state)?;
612
613 println!("Opening browser for authentication...");
614 println!("If the browser doesn't open, visit this URL:");
615 println!("{}", auth_url);
616 println!();
617
618 if let Err(e) = open_browser(&auth_url) {
620 println!("Failed to open browser: {}", e);
621 println!("Please open the URL manually in your browser.");
622 }
623
624 let port = resolve_callback_port()?;
626 println!("Waiting for authentication callback...");
627 let callback_result = run_callback_server(port, state.clone(), 300).await?;
628
629 println!("Received authorization code, exchanging for token...");
630
631 let oauth_response = exchange_code(
633 &config,
634 &callback_result.code,
635 &code_verifier,
636 base_url.as_deref(),
637 )
638 .await?;
639
640 let team_id = oauth_response
642 .team
643 .as_ref()
644 .map(|t| t.id.clone())
645 .ok_or_else(|| OAuthError::SlackError("Missing team information".to_string()))?;
646
647 let team_name = oauth_response.team.as_ref().map(|t| t.name.clone());
648
649 let user_id = oauth_response
650 .authed_user
651 .as_ref()
652 .map(|u| u.id.clone())
653 .ok_or_else(|| OAuthError::SlackError("Missing user information".to_string()))?;
654
655 let token = oauth_response
656 .authed_user
657 .as_ref()
658 .and_then(|u| u.access_token.clone())
659 .or(oauth_response.access_token.clone())
660 .ok_or_else(|| OAuthError::SlackError("Missing access token".to_string()))?;
661
662 let config_path = default_config_path()
664 .map_err(|e| OAuthError::ConfigError(format!("Failed to get config path: {}", e)))?;
665
666 let mut config = load_config(&config_path).unwrap_or_else(|_| ProfilesConfig::new());
667
668 let profile = Profile {
669 team_id: team_id.clone(),
670 user_id: user_id.clone(),
671 team_name,
672 user_name: None, client_id: None, redirect_uri: None,
675 scopes: None,
676 bot_scopes: None,
677 user_scopes: None,
678 default_token_type: None,
679 };
680
681 config
682 .set_or_update(profile_name.clone(), profile)
683 .map_err(|e| OAuthError::ConfigError(format!("Failed to save profile: {}", e)))?;
684
685 save_config(&config_path, &config)
686 .map_err(|e| OAuthError::ConfigError(format!("Failed to save config: {}", e)))?;
687
688 let token_store = create_token_store()
690 .map_err(|e| OAuthError::ConfigError(format!("Failed to create token store: {}", e)))?;
691 let token_key = make_token_key(&team_id, &user_id);
692 token_store
693 .set(&token_key, &token)
694 .map_err(|e| OAuthError::ConfigError(format!("Failed to save token: {}", e)))?;
695
696 println!("ā Authentication successful!");
697 println!("Profile '{}' saved.", profile_name);
698
699 Ok(())
700}
701
702pub fn status(profile_name: Option<String>) -> Result<(), String> {
707 let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
708
709 let config_path = default_config_path().map_err(|e| e.to_string())?;
710 let config = load_config(&config_path).map_err(|e| e.to_string())?;
711
712 let profile = config
713 .get(&profile_name)
714 .ok_or_else(|| format!("Profile '{}' not found", profile_name))?;
715
716 println!("Profile: {}", profile_name);
717 println!("Team ID: {}", profile.team_id);
718 println!("User ID: {}", profile.user_id);
719 if let Some(team_name) = &profile.team_name {
720 println!("Team Name: {}", team_name);
721 }
722 if let Some(user_name) = &profile.user_name {
723 println!("User Name: {}", user_name);
724 }
725 if let Some(client_id) = &profile.client_id {
726 println!("Client ID: {}", client_id);
727 }
728
729 if std::env::var("SLACK_TOKEN").is_ok() {
731 println!("SLACK_TOKEN: set");
732 }
733
734 use crate::profile::FileTokenStore;
736 let file_path = FileTokenStore::default_path().map_err(|e| e.to_string())?;
737 println!("Token Store: file ({})", file_path.display());
738
739 let token_store = create_token_store().map_err(|e| e.to_string())?;
741 let bot_token_key = make_token_key(&profile.team_id, &profile.user_id);
742 let user_token_key = format!("{}:{}:user", &profile.team_id, &profile.user_id);
743
744 let has_bot_token = token_store.exists(&bot_token_key);
745 let has_user_token = token_store.exists(&user_token_key);
746
747 let mut available_tokens = Vec::new();
749 if has_bot_token {
750 available_tokens.push("Bot");
751 }
752 if has_user_token {
753 available_tokens.push("User");
754 }
755
756 if available_tokens.is_empty() {
757 println!("Tokens Available: None");
758 } else {
759 println!("Tokens Available: {}", available_tokens.join(", "));
760 }
761
762 if has_bot_token {
764 if let Ok(bot_token) = token_store.get(&bot_token_key) {
766 if let Some(bot_id) = extract_bot_id(&bot_token) {
767 println!("Bot ID: {}", bot_id);
768 }
769 }
770 }
771
772 if let Some(bot_scopes) = profile.get_bot_scopes() {
774 if !bot_scopes.is_empty() {
775 println!("Bot Scopes: {}", bot_scopes.join(", "));
776 }
777 }
778 if let Some(user_scopes) = profile.get_user_scopes() {
779 if !user_scopes.is_empty() {
780 println!("User Scopes: {}", user_scopes.join(", "));
781 }
782 }
783
784 let default_token_type =
786 compute_default_token_type_display(profile.default_token_type, has_user_token);
787 println!("Default Token Type: {}", default_token_type);
788
789 Ok(())
790}
791
792fn compute_default_token_type_display(
804 profile_default_token_type: Option<crate::profile::TokenType>,
805 has_user_token: bool,
806) -> &'static str {
807 if let Some(token_type) = profile_default_token_type {
808 match token_type {
809 crate::profile::TokenType::Bot => "Bot",
810 crate::profile::TokenType::User => "User",
811 }
812 } else if has_user_token {
813 "User"
814 } else {
815 "Bot"
816 }
817}
818
819pub fn compute_initial_default_token_type(
865 existing_default_token_type: Option<crate::profile::TokenType>,
866 has_user_token: bool,
867) -> crate::profile::TokenType {
868 if let Some(token_type) = existing_default_token_type {
870 return token_type;
871 }
872
873 if has_user_token {
875 crate::profile::TokenType::User
876 } else {
877 crate::profile::TokenType::Bot
878 }
879}
880
881fn extract_bot_id(token: &str) -> Option<String> {
884 if token.starts_with("xoxb-") {
885 let parts: Vec<&str> = token.split('-').collect();
886 if parts.len() >= 3 {
889 return Some(parts[2].to_string());
890 }
891 }
892 None
893}
894
895pub fn list() -> Result<(), String> {
897 let config_path = default_config_path().map_err(|e| e.to_string())?;
898 let config = load_config(&config_path).map_err(|e| e.to_string())?;
899
900 if config.profiles.is_empty() {
901 println!("No profiles found.");
902 return Ok(());
903 }
904
905 println!("Profiles:");
906 for name in config.list_names() {
907 if let Some(profile) = config.get(&name) {
908 let team_name = profile.team_name.as_deref().unwrap_or(&profile.team_id);
909 println!(
910 " {}: {} ({}:{})",
911 name, team_name, profile.team_id, profile.user_id
912 );
913 }
914 }
915
916 Ok(())
917}
918
919pub fn rename(old_name: String, new_name: String) -> Result<(), String> {
925 let config_path = default_config_path().map_err(|e| e.to_string())?;
926 let mut config = load_config(&config_path).map_err(|e| e.to_string())?;
927
928 let profile = config
930 .get(&old_name)
931 .ok_or_else(|| format!("Profile '{}' not found", old_name))?
932 .clone();
933
934 if config.get(&new_name).is_some() {
936 return Err(format!("Profile '{}' already exists", new_name));
937 }
938
939 config.remove(&old_name);
941 config.set(new_name.clone(), profile);
942
943 save_config(&config_path, &config).map_err(|e| e.to_string())?;
944
945 println!("Profile '{}' renamed to '{}'", old_name, new_name);
946
947 Ok(())
948}
949
950pub fn logout(profile_name: Option<String>) -> Result<(), String> {
955 let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
956
957 let config_path = default_config_path().map_err(|e| e.to_string())?;
958 let mut config = load_config(&config_path).map_err(|e| e.to_string())?;
959
960 let profile = config
961 .get(&profile_name)
962 .ok_or_else(|| format!("Profile '{}' not found", profile_name))?
963 .clone();
964
965 let token_store = create_token_store().map_err(|e| e.to_string())?;
967 let token_key = make_token_key(&profile.team_id, &profile.user_id);
968 let _ = token_store.delete(&token_key); config.remove(&profile_name);
972 save_config(&config_path, &config).map_err(|e| e.to_string())?;
973
974 println!("Profile '{}' removed", profile_name);
975
976 Ok(())
977}
978
979fn open_browser(url: &str) -> Result<(), String> {
981 #[cfg(target_os = "macos")]
982 let result = Command::new("open").arg(url).spawn();
983
984 #[cfg(target_os = "linux")]
985 let result = Command::new("xdg-open").arg(url).spawn();
986
987 #[cfg(target_os = "windows")]
988 let result = Command::new("cmd").args(["/C", "start", url]).spawn();
989
990 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
991 let result: Result<std::process::Child, std::io::Error> = Err(std::io::Error::new(
992 std::io::ErrorKind::Unsupported,
993 "Unsupported platform",
994 ));
995
996 result.map(|_| ()).map_err(|e| e.to_string())
997}
998
999fn find_cloudflared() -> Option<String> {
1001 if Command::new("cloudflared")
1003 .arg("--version")
1004 .output()
1005 .is_ok()
1006 {
1007 return Some("cloudflared".to_string());
1008 }
1009
1010 let common_paths = [
1012 "/usr/local/bin/cloudflared",
1013 "/opt/homebrew/bin/cloudflared",
1014 "/usr/bin/cloudflared",
1015 ];
1016
1017 for path in &common_paths {
1018 if std::path::Path::new(path).exists() {
1019 return Some(path.to_string());
1020 }
1021 }
1022
1023 None
1024}
1025
1026fn generate_and_save_manifest(
1028 client_id: &str,
1029 redirect_uri: &str,
1030 bot_scopes: &[String],
1031 user_scopes: &[String],
1032 profile_name: &str,
1033) -> Result<PathBuf, OAuthError> {
1034 use crate::auth::manifest::generate_manifest;
1035 use std::fs;
1036
1037 let manifest_yaml = generate_manifest(
1039 client_id,
1040 bot_scopes,
1041 user_scopes,
1042 redirect_uri,
1043 false, false, profile_name,
1046 )
1047 .map_err(|e| OAuthError::ConfigError(format!("Failed to generate manifest: {}", e)))?;
1048
1049 let home = directories::BaseDirs::new()
1052 .ok_or_else(|| OAuthError::ConfigError("Failed to determine home directory".to_string()))?
1053 .home_dir()
1054 .to_path_buf();
1055
1056 let config_dir = home.join(".config").join("slack-rs");
1058
1059 fs::create_dir_all(&config_dir).map_err(|e| {
1061 OAuthError::ConfigError(format!("Failed to create config directory: {}", e))
1062 })?;
1063
1064 let manifest_path = config_dir.join(format!("{}_manifest.yml", profile_name));
1065
1066 fs::write(&manifest_path, &manifest_yaml)
1068 .map_err(|e| OAuthError::ConfigError(format!("Failed to write manifest file: {}", e)))?;
1069
1070 use crate::auth::clipboard::{copy_to_clipboard, ClipboardResult};
1072
1073 match copy_to_clipboard(&manifest_yaml) {
1074 ClipboardResult::Success(method) => {
1075 println!("ā Manifest copied to clipboard ({})!", method);
1076 }
1077 ClipboardResult::Failed => {
1078 eprintln!("ā ļø Warning: Could not copy to clipboard.");
1079 eprintln!(" Please manually copy from: {}", manifest_path.display());
1080 }
1081 }
1082
1083 Ok(manifest_path)
1084}
1085
1086#[allow(dead_code)]
1088pub struct ExtendedLoginOptions {
1089 pub client_id: Option<String>,
1090 pub profile_name: Option<String>,
1091 pub redirect_uri: String,
1092 pub bot_scopes: Option<Vec<String>>,
1093 pub user_scopes: Option<Vec<String>>,
1094 pub cloudflared_path: Option<String>,
1095 pub ngrok_path: Option<String>,
1096 pub base_url: Option<String>,
1097}
1098
1099pub async fn login_with_credentials_extended(
1103 client_id: String,
1104 client_secret: String,
1105 bot_scopes: Vec<String>,
1106 user_scopes: Vec<String>,
1107 profile_name: Option<String>,
1108 use_cloudflared: bool,
1109) -> Result<(), OAuthError> {
1110 let profile_name = profile_name.unwrap_or_else(|| "default".to_string());
1111
1112 if debug::enabled() {
1113 debug::log(format!(
1114 "login_with_credentials_extended: profile={}, bot_scopes_count={}, user_scopes_count={}",
1115 profile_name,
1116 bot_scopes.len(),
1117 user_scopes.len()
1118 ));
1119 }
1120
1121 let port = resolve_callback_port()?;
1123
1124 let final_redirect_uri: String;
1125 let mut cloudflared_tunnel: Option<CloudflaredTunnel> = None;
1126
1127 if use_cloudflared {
1128 let path = match find_cloudflared() {
1130 Some(p) => p,
1131 None => {
1132 return Err(OAuthError::ConfigError(
1133 "cloudflared not found. Please install it first:\n \
1134 macOS: brew install cloudflare/cloudflare/cloudflared\n \
1135 Linux: See https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/"
1136 .to_string(),
1137 ));
1138 }
1139 };
1140
1141 println!("Starting cloudflared tunnel...");
1142 let local_url = format!("http://localhost:{}", port);
1143 match CloudflaredTunnel::start(&path, &local_url, 30) {
1144 Ok(mut t) => {
1145 let public_url = t.public_url().to_string();
1146 println!("ā Tunnel started: {}", public_url);
1147 println!(" Tunneling {} -> {}", public_url, local_url);
1148
1149 if !t.is_running() {
1150 return Err(OAuthError::ConfigError(
1151 "Cloudflared tunnel started but process is not running".to_string(),
1152 ));
1153 }
1154
1155 final_redirect_uri = format!("{}/callback", public_url);
1156 println!("Using redirect URI: {}", final_redirect_uri);
1157 cloudflared_tunnel = Some(t);
1158 }
1159 Err(CloudflaredError::StartError(msg)) => {
1160 return Err(OAuthError::ConfigError(format!(
1161 "Failed to start cloudflared: {}",
1162 msg
1163 )));
1164 }
1165 Err(CloudflaredError::UrlExtractionError(msg)) => {
1166 return Err(OAuthError::ConfigError(format!(
1167 "Failed to extract cloudflared URL: {}",
1168 msg
1169 )));
1170 }
1171 Err(e) => {
1172 return Err(OAuthError::ConfigError(format!(
1173 "Cloudflared error: {:?}",
1174 e
1175 )));
1176 }
1177 }
1178 } else {
1179 final_redirect_uri = format!("http://localhost:{}/callback", port);
1180 }
1181
1182 let manifest_path = generate_and_save_manifest(
1184 &client_id,
1185 &final_redirect_uri,
1186 &bot_scopes,
1187 &user_scopes,
1188 &profile_name,
1189 )?;
1190
1191 println!("\nš Slack App Manifest saved to:");
1192 println!(" {}", manifest_path.display());
1193 println!("\nš§ Setup Instructions:");
1194 println!(" 1. Go to https://api.slack.com/apps");
1195 println!(" 2. Click 'Create New App' ā 'From an app manifest'");
1196 println!(" 3. Select your workspace");
1197 println!(" 4. Copy and paste the manifest from the file above");
1198 println!(" 5. Click 'Create'");
1199 println!(" 6. ā ļø IMPORTANT: Do NOT click 'Install to Workspace' yet!");
1200 println!(" The OAuth flow will start automatically after you press Enter.");
1201 println!("\nāøļø Press Enter when you've created the app (but NOT installed it yet)...");
1202
1203 let mut input = String::new();
1204 std::io::stdin()
1205 .read_line(&mut input)
1206 .map_err(|e| OAuthError::ConfigError(format!("Failed to read input: {}", e)))?;
1207
1208 if let Some(ref mut tunnel) = cloudflared_tunnel {
1210 if !tunnel.is_running() {
1211 return Err(OAuthError::ConfigError(
1212 "Cloudflared tunnel stopped unexpectedly".to_string(),
1213 ));
1214 }
1215 println!("ā Tunnel is running");
1216 }
1217
1218 let config = OAuthConfig {
1220 client_id: client_id.clone(),
1221 client_secret: client_secret.clone(),
1222 redirect_uri: final_redirect_uri.clone(),
1223 scopes: bot_scopes.clone(),
1224 user_scopes: user_scopes.clone(),
1225 };
1226
1227 println!("š Starting OAuth flow...");
1229 let (team_id, team_name, user_id, bot_token, user_token) =
1230 perform_oauth_flow(&config, None).await?;
1231
1232 if debug::enabled() {
1233 debug::log(format!(
1234 "OAuth flow completed: team_id={}, user_id={}, team_name={:?}",
1235 team_id, user_id, team_name
1236 ));
1237 debug::log(format!(
1238 "tokens: bot_token_present={}, user_token_present={}",
1239 bot_token.is_some(),
1240 user_token.is_some()
1241 ));
1242 if let Some(ref token) = bot_token {
1243 debug::log(format!("bot_token={}", debug::token_hint(token)));
1244 }
1245 if let Some(ref token) = user_token {
1246 debug::log(format!("user_token={}", debug::token_hint(token)));
1247 }
1248 }
1249
1250 println!("š¾ Saving profile and credentials...");
1252 save_profile_and_credentials(SaveCredentials {
1253 config_path: &default_config_path()
1254 .map_err(|e| OAuthError::ConfigError(format!("Failed to get config path: {}", e)))?,
1255 profile_name: &profile_name,
1256 team_id: &team_id,
1257 team_name: &team_name,
1258 user_id: &user_id,
1259 bot_token: bot_token.as_deref(),
1260 user_token: user_token.as_deref(),
1261 client_id: &client_id,
1262 client_secret: &client_secret,
1263 redirect_uri: &final_redirect_uri,
1264 scopes: &bot_scopes,
1265 bot_scopes: &bot_scopes,
1266 user_scopes: &user_scopes,
1267 })?;
1268
1269 println!("\nā
Login successful!");
1270 println!("Profile '{}' has been saved.", profile_name);
1271
1272 drop(cloudflared_tunnel);
1274
1275 Ok(())
1276}
1277
1278#[cfg(test)]
1279mod tests {
1280 use super::*;
1281 use crate::profile::TokenStore;
1282
1283 #[test]
1284 fn test_status_profile_not_found() {
1285 let result = status(Some("nonexistent".to_string()));
1286 assert!(result.is_err());
1287 assert!(result.unwrap_err().contains("not found"));
1288 }
1289
1290 #[test]
1291 fn test_extract_bot_id_valid() {
1292 let token = "xoxb-T123-B456-secret123";
1294 assert_eq!(extract_bot_id(token), Some("B456".to_string()));
1295 }
1296
1297 #[test]
1298 fn test_extract_bot_id_invalid() {
1299 assert_eq!(extract_bot_id("xoxp-user-token"), None);
1301 assert_eq!(extract_bot_id("xoxb-only"), None);
1302 assert_eq!(extract_bot_id("xoxb-T123"), None);
1303 assert_eq!(extract_bot_id("not-a-token"), None);
1304 assert_eq!(extract_bot_id(""), None);
1305 }
1306
1307 #[test]
1308 fn test_extract_bot_id_edge_cases() {
1309 assert_eq!(
1311 extract_bot_id("xoxb-123456-789012-abcdef"),
1312 Some("789012".to_string())
1313 );
1314 assert_eq!(
1315 extract_bot_id("xoxb-T123-B456-secret123"),
1316 Some("B456".to_string())
1317 );
1318
1319 assert_eq!(
1321 extract_bot_id("xoxb-T123-B456-secret-with-dashes"),
1322 Some("B456".to_string())
1323 );
1324 }
1325
1326 #[test]
1327 fn test_list_empty() {
1328 let result = list();
1331 assert!(result.is_ok());
1332 }
1333
1334 #[test]
1335 fn test_rename_nonexistent_profile() {
1336 let result = rename("nonexistent".to_string(), "new_name".to_string());
1337 assert!(result.is_err());
1338 assert!(result.unwrap_err().contains("not found"));
1339 }
1340
1341 #[test]
1342 fn test_logout_nonexistent_profile() {
1343 let result = logout(Some("nonexistent".to_string()));
1344 assert!(result.is_err());
1345 assert!(result.unwrap_err().contains("not found"));
1346 }
1347
1348 #[test]
1349 #[serial_test::serial]
1350 fn test_save_profile_and_credentials_with_client_id() {
1351 use tempfile::TempDir;
1352
1353 let temp_dir = TempDir::new().unwrap();
1354 let config_path = temp_dir.path().join("profiles.json");
1355
1356 let team_id = "T123";
1357 let user_id = "U456";
1358 let profile_name = "test";
1359
1360 let tokens_path = temp_dir.path().join("tokens.json");
1362 std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
1363
1364 let scopes = vec!["chat:write".to_string(), "users:read".to_string()];
1366 let bot_scopes = vec!["chat:write".to_string()];
1367 let user_scopes = vec!["users:read".to_string()];
1368 save_profile_and_credentials(SaveCredentials {
1369 config_path: &config_path,
1370 profile_name,
1371 team_id,
1372 team_name: &Some("Test Team".to_string()),
1373 user_id,
1374 bot_token: Some("xoxb-test-bot-token"),
1375 user_token: Some("xoxp-test-user-token"),
1376 client_id: "test-client-id",
1377 client_secret: "test-client-secret",
1378 redirect_uri: "http://127.0.0.1:8765/callback",
1379 scopes: &scopes,
1380 bot_scopes: &bot_scopes,
1381 user_scopes: &user_scopes,
1382 })
1383 .unwrap();
1384
1385 let config = load_config(&config_path).unwrap();
1387 let profile = config.get(profile_name).unwrap();
1388 assert_eq!(profile.client_id, Some("test-client-id".to_string()));
1389 assert_eq!(profile.team_id, team_id);
1390 assert_eq!(profile.user_id, user_id);
1391
1392 use crate::profile::FileTokenStore;
1394 let token_store = FileTokenStore::with_path(tokens_path.clone()).unwrap();
1395 let bot_token_key = make_token_key(team_id, user_id);
1396 let user_token_key = format!("{}:{}:user", team_id, user_id);
1397 let client_secret_key = format!("oauth-client-secret:{}", profile_name);
1398
1399 assert!(token_store.exists(&bot_token_key));
1400 assert!(token_store.exists(&user_token_key));
1401 assert!(token_store.exists(&client_secret_key));
1402
1403 std::env::remove_var("SLACK_RS_TOKENS_PATH");
1405 }
1406
1407 #[test]
1408 #[serial_test::serial]
1409 fn test_save_profile_and_credentials_sets_default_token_type_user() {
1410 use tempfile::TempDir;
1411
1412 let temp_dir = TempDir::new().unwrap();
1413 let config_path = temp_dir.path().join("profiles.json");
1414 let tokens_path = temp_dir.path().join("tokens.json");
1415 std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
1416
1417 let team_id = "T123";
1418 let user_id = "U456";
1419 let profile_name = "test";
1420
1421 let scopes = vec!["chat:write".to_string()];
1423 let bot_scopes = vec!["chat:write".to_string()];
1424 let user_scopes = vec!["users:read".to_string()];
1425 save_profile_and_credentials(SaveCredentials {
1426 config_path: &config_path,
1427 profile_name,
1428 team_id,
1429 team_name: &Some("Test Team".to_string()),
1430 user_id,
1431 bot_token: Some("xoxb-test-bot-token"),
1432 user_token: Some("xoxp-test-user-token"), client_id: "test-client-id",
1434 client_secret: "test-client-secret",
1435 redirect_uri: "http://127.0.0.1:8765/callback",
1436 scopes: &scopes,
1437 bot_scopes: &bot_scopes,
1438 user_scopes: &user_scopes,
1439 })
1440 .unwrap();
1441
1442 let config = load_config(&config_path).unwrap();
1444 let profile = config.get(profile_name).unwrap();
1445 assert_eq!(
1446 profile.default_token_type,
1447 Some(crate::profile::TokenType::User)
1448 );
1449
1450 std::env::remove_var("SLACK_RS_TOKENS_PATH");
1451 }
1452
1453 #[test]
1454 #[serial_test::serial]
1455 fn test_save_profile_and_credentials_sets_default_token_type_bot() {
1456 use tempfile::TempDir;
1457
1458 let temp_dir = TempDir::new().unwrap();
1459 let config_path = temp_dir.path().join("profiles.json");
1460 let tokens_path = temp_dir.path().join("tokens.json");
1461 std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
1462
1463 let team_id = "T123";
1464 let user_id = "U456";
1465 let profile_name = "test";
1466
1467 let scopes = vec!["chat:write".to_string()];
1469 let bot_scopes = vec!["chat:write".to_string()];
1470 let user_scopes = vec!["users:read".to_string()];
1471 save_profile_and_credentials(SaveCredentials {
1472 config_path: &config_path,
1473 profile_name,
1474 team_id,
1475 team_name: &Some("Test Team".to_string()),
1476 user_id,
1477 bot_token: Some("xoxb-test-bot-token"),
1478 user_token: None, client_id: "test-client-id",
1480 client_secret: "test-client-secret",
1481 redirect_uri: "http://127.0.0.1:8765/callback",
1482 scopes: &scopes,
1483 bot_scopes: &bot_scopes,
1484 user_scopes: &user_scopes,
1485 })
1486 .unwrap();
1487
1488 let config = load_config(&config_path).unwrap();
1490 let profile = config.get(profile_name).unwrap();
1491 assert_eq!(
1492 profile.default_token_type,
1493 Some(crate::profile::TokenType::Bot)
1494 );
1495
1496 std::env::remove_var("SLACK_RS_TOKENS_PATH");
1497 }
1498
1499 #[test]
1500 #[serial_test::serial]
1501 fn test_save_profile_and_credentials_preserves_existing_default_token_type() {
1502 use tempfile::TempDir;
1503
1504 let temp_dir = TempDir::new().unwrap();
1505 let config_path = temp_dir.path().join("profiles.json");
1506 let tokens_path = temp_dir.path().join("tokens.json");
1507 std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
1508
1509 let team_id = "T123";
1510 let user_id = "U456";
1511 let profile_name = "test";
1512
1513 let mut config = ProfilesConfig::new();
1515 config.set(
1516 profile_name.to_string(),
1517 Profile {
1518 team_id: team_id.to_string(),
1519 user_id: user_id.to_string(),
1520 team_name: Some("Test Team".to_string()),
1521 user_name: None,
1522 client_id: Some("test-client-id".to_string()),
1523 redirect_uri: Some("http://127.0.0.1:8765/callback".to_string()),
1524 scopes: Some(vec!["chat:write".to_string()]),
1525 bot_scopes: Some(vec!["chat:write".to_string()]),
1526 user_scopes: Some(vec!["users:read".to_string()]),
1527 default_token_type: Some(crate::profile::TokenType::Bot),
1528 },
1529 );
1530 save_config(&config_path, &config).unwrap();
1531
1532 let scopes = vec!["chat:write".to_string()];
1534 let bot_scopes = vec!["chat:write".to_string()];
1535 let user_scopes = vec!["users:read".to_string()];
1536 save_profile_and_credentials(SaveCredentials {
1537 config_path: &config_path,
1538 profile_name,
1539 team_id,
1540 team_name: &Some("Test Team".to_string()),
1541 user_id,
1542 bot_token: Some("xoxb-test-bot-token"),
1543 user_token: Some("xoxp-test-user-token"), client_id: "test-client-id",
1545 client_secret: "test-client-secret",
1546 redirect_uri: "http://127.0.0.1:8765/callback",
1547 scopes: &scopes,
1548 bot_scopes: &bot_scopes,
1549 user_scopes: &user_scopes,
1550 })
1551 .unwrap();
1552
1553 let config = load_config(&config_path).unwrap();
1555 let profile = config.get(profile_name).unwrap();
1556 assert_eq!(
1557 profile.default_token_type,
1558 Some(crate::profile::TokenType::Bot),
1559 "Existing default_token_type should be preserved"
1560 );
1561
1562 std::env::remove_var("SLACK_RS_TOKENS_PATH");
1563 }
1564
1565 #[test]
1566 fn test_backward_compatibility_load_profile_without_client_id() {
1567 use tempfile::TempDir;
1568
1569 let temp_dir = TempDir::new().unwrap();
1570 let config_path = temp_dir.path().join("profiles.json");
1571
1572 let mut config = ProfilesConfig::new();
1574 config.set(
1575 "legacy".to_string(),
1576 Profile {
1577 team_id: "T999".to_string(),
1578 user_id: "U888".to_string(),
1579 team_name: Some("Legacy Team".to_string()),
1580 user_name: Some("Legacy User".to_string()),
1581 client_id: None,
1582 redirect_uri: None,
1583 scopes: None,
1584 bot_scopes: None,
1585 user_scopes: None,
1586 default_token_type: None,
1587 },
1588 );
1589 save_config(&config_path, &config).unwrap();
1590
1591 let loaded_config = load_config(&config_path).unwrap();
1593 let profile = loaded_config.get("legacy").unwrap();
1594 assert_eq!(profile.client_id, None);
1595 assert_eq!(profile.team_id, "T999");
1596 }
1597
1598 #[test]
1599 fn test_bot_and_user_token_storage_keys() {
1600 use crate::profile::InMemoryTokenStore;
1601
1602 let token_store = InMemoryTokenStore::new();
1604
1605 let team_id = "T123";
1607 let user_id = "U456";
1608 let bot_token = "xoxb-test-bot-token";
1609 let user_token = "xoxp-test-user-token";
1610
1611 let bot_token_key = make_token_key(team_id, user_id); let user_token_key = format!("{}:{}:user", team_id, user_id); token_store.set(&bot_token_key, bot_token).unwrap();
1616 token_store.set(&user_token_key, user_token).unwrap();
1617
1618 assert_eq!(token_store.get(&bot_token_key).unwrap(), bot_token);
1620 assert_eq!(bot_token_key, "T123:U456");
1621
1622 assert_eq!(token_store.get(&user_token_key).unwrap(), user_token);
1624 assert_eq!(user_token_key, "T123:U456:user");
1625
1626 assert_ne!(bot_token_key, user_token_key);
1628 }
1629
1630 #[test]
1631 #[serial_test::serial]
1632 fn test_status_shows_token_store_backend_file() {
1633 use tempfile::TempDir;
1634
1635 let temp_dir = TempDir::new().unwrap();
1636 let config_path = temp_dir.path().join("profiles.json");
1637 let tokens_path = temp_dir.path().join("tokens.json");
1638
1639 std::env::set_var("SLACK_RS_TOKENS_PATH", tokens_path.to_str().unwrap());
1641
1642 let mut config = ProfilesConfig::new();
1644 config.set(
1645 "test".to_string(),
1646 Profile {
1647 team_id: "T123".to_string(),
1648 user_id: "U456".to_string(),
1649 team_name: Some("Test Team".to_string()),
1650 user_name: None,
1651 client_id: None,
1652 redirect_uri: None,
1653 scopes: None,
1654 bot_scopes: None,
1655 user_scopes: None,
1656 default_token_type: None,
1657 },
1658 );
1659 save_config(&config_path, &config).unwrap();
1660
1661 std::env::set_var("SLACK_RS_CONFIG_PATH", config_path.to_str().unwrap());
1664
1665 std::env::remove_var("SLACK_RS_TOKENS_PATH");
1669 std::env::remove_var("SLACK_RS_CONFIG_PATH");
1670 }
1671
1672 #[test]
1673 #[serial_test::serial]
1674 fn test_status_shows_slack_token_env_when_set() {
1675 use tempfile::TempDir;
1676
1677 let temp_dir = TempDir::new().unwrap();
1678 let config_path = temp_dir.path().join("profiles.json");
1679
1680 let mut config = ProfilesConfig::new();
1682 config.set(
1683 "test".to_string(),
1684 Profile {
1685 team_id: "T123".to_string(),
1686 user_id: "U456".to_string(),
1687 team_name: Some("Test Team".to_string()),
1688 user_name: None,
1689 client_id: None,
1690 redirect_uri: None,
1691 scopes: None,
1692 bot_scopes: None,
1693 user_scopes: None,
1694 default_token_type: None,
1695 },
1696 );
1697 save_config(&config_path, &config).unwrap();
1698
1699 std::env::set_var("SLACK_TOKEN", "xoxb-secret-token");
1701
1702 std::env::remove_var("SLACK_TOKEN");
1706 }
1707
1708 #[test]
1710 fn test_status_default_token_type_user_set() {
1711 let result = compute_default_token_type_display(
1713 Some(crate::profile::TokenType::User),
1714 false, );
1716 assert_eq!(result, "User");
1717 }
1718
1719 #[test]
1720 fn test_status_default_token_type_bot_set() {
1721 let result = compute_default_token_type_display(
1723 Some(crate::profile::TokenType::Bot),
1724 true, );
1726 assert_eq!(result, "Bot");
1727 }
1728
1729 #[test]
1730 fn test_status_default_token_type_fallback_with_user_token() {
1731 let result = compute_default_token_type_display(None, true);
1733 assert_eq!(result, "User");
1734 }
1735
1736 #[test]
1737 fn test_status_default_token_type_fallback_without_user_token() {
1738 let result = compute_default_token_type_display(None, false);
1740 assert_eq!(result, "Bot");
1741 }
1742
1743 #[test]
1744 fn test_status_default_token_type_user_overrides_inference() {
1745 let result = compute_default_token_type_display(
1748 Some(crate::profile::TokenType::User),
1749 false, );
1751 assert_eq!(result, "User");
1752 }
1753
1754 #[test]
1755 fn test_status_default_token_type_bot_overrides_inference() {
1756 let result = compute_default_token_type_display(
1759 Some(crate::profile::TokenType::Bot),
1760 true, );
1762 assert_eq!(result, "Bot");
1763 }
1764
1765 #[test]
1766 fn test_compute_initial_default_token_type_new_profile_with_user_token() {
1767 let result = compute_initial_default_token_type(None, true);
1769 assert_eq!(result, crate::profile::TokenType::User);
1770 }
1771
1772 #[test]
1773 fn test_compute_initial_default_token_type_new_profile_without_user_token() {
1774 let result = compute_initial_default_token_type(None, false);
1776 assert_eq!(result, crate::profile::TokenType::Bot);
1777 }
1778
1779 #[test]
1780 fn test_compute_initial_default_token_type_preserves_existing_bot() {
1781 let result = compute_initial_default_token_type(
1783 Some(crate::profile::TokenType::Bot),
1784 true, );
1786 assert_eq!(result, crate::profile::TokenType::Bot);
1787 }
1788
1789 #[test]
1790 fn test_compute_initial_default_token_type_preserves_existing_user() {
1791 let result = compute_initial_default_token_type(
1793 Some(crate::profile::TokenType::User),
1794 false, );
1796 assert_eq!(result, crate::profile::TokenType::User);
1797 }
1798
1799 #[test]
1802 #[serial_test::serial]
1803 fn test_file_token_store_respects_xdg_data_home() {
1804 use crate::profile::FileTokenStore;
1805 use tempfile::TempDir;
1806
1807 std::env::remove_var("SLACK_RS_TOKENS_PATH");
1809
1810 let temp_dir = TempDir::new().unwrap();
1811 let xdg_data_home = temp_dir.path().to_str().unwrap();
1812 std::env::set_var("XDG_DATA_HOME", xdg_data_home);
1813
1814 let path = FileTokenStore::default_path().unwrap();
1815 let expected = temp_dir.path().join("slack-rs").join("tokens.json");
1816
1817 assert_eq!(
1818 path, expected,
1819 "auth status should display XDG_DATA_HOME-based path when XDG_DATA_HOME is set"
1820 );
1821
1822 std::env::remove_var("XDG_DATA_HOME");
1823 }
1824
1825 #[test]
1827 #[serial_test::serial]
1828 fn test_file_token_store_slack_rs_tokens_path_priority() {
1829 use crate::profile::FileTokenStore;
1830 use tempfile::TempDir;
1831
1832 let temp_dir = TempDir::new().unwrap();
1833 let custom_path = temp_dir.path().join("custom-tokens.json");
1834 let xdg_data_home = temp_dir.path().join("xdg-data");
1835
1836 std::env::set_var("SLACK_RS_TOKENS_PATH", custom_path.to_str().unwrap());
1838 std::env::set_var("XDG_DATA_HOME", xdg_data_home.to_str().unwrap());
1839
1840 let path = FileTokenStore::default_path().unwrap();
1841
1842 assert_eq!(
1843 path, custom_path,
1844 "auth status should display SLACK_RS_TOKENS_PATH when both env vars are set"
1845 );
1846
1847 std::env::remove_var("SLACK_RS_TOKENS_PATH");
1848 std::env::remove_var("XDG_DATA_HOME");
1849 }
1850}