use crate::api::{execute_api_call, ApiCallArgs, ApiCallContext, ApiCallResponse, ApiClient};
use crate::auth;
use crate::debug;
use crate::oauth;
use crate::profile::{
create_token_store, default_config_path, make_token_key, resolve_profile_full, TokenType,
};
#[derive(Debug, Clone, PartialEq)]
pub struct LoginArgs {
pub profile_name: Option<String>,
pub client_id: Option<String>,
pub bot_scopes: Option<Vec<String>>,
pub user_scopes: Option<Vec<String>>,
pub tunnel_mode: TunnelMode,
}
#[derive(Debug, Clone, PartialEq)]
pub enum TunnelMode {
None,
Cloudflared(Option<String>),
Ngrok(Option<String>),
}
impl TunnelMode {
pub fn is_enabled(&self) -> bool {
!matches!(self, TunnelMode::None)
}
pub fn is_cloudflared(&self) -> bool {
matches!(self, TunnelMode::Cloudflared(_))
}
#[allow(dead_code)]
pub fn is_ngrok(&self) -> bool {
matches!(self, TunnelMode::Ngrok(_))
}
}
pub fn parse_login_args(args: &[String]) -> Result<LoginArgs, String> {
let mut profile_name: Option<String> = None;
let mut client_id: Option<String> = None;
let mut cloudflared_path: Option<String> = None;
let mut ngrok_path: Option<String> = None;
let mut bot_scopes: Option<Vec<String>> = None;
let mut user_scopes: Option<Vec<String>> = None;
let mut i = 0;
while i < args.len() {
if args[i].starts_with("--") {
match args[i].as_str() {
"--client-id" => {
i += 1;
if i < args.len() {
client_id = Some(args[i].clone());
} else {
return Err("--client-id requires a value".to_string());
}
}
"--cloudflared" => {
if i + 1 < args.len() && !args[i + 1].starts_with("--") {
i += 1;
cloudflared_path = Some(args[i].clone());
} else {
cloudflared_path = Some("cloudflared".to_string());
}
}
"--ngrok" => {
if i + 1 < args.len() && !args[i + 1].starts_with("--") {
i += 1;
ngrok_path = Some(args[i].clone());
} else {
ngrok_path = Some("ngrok".to_string());
}
}
"--bot-scopes" => {
i += 1;
if i < args.len() {
let scopes_input: Vec<String> =
args[i].split(',').map(|s| s.trim().to_string()).collect();
bot_scopes = Some(oauth::expand_scopes_with_context(&scopes_input, true));
} else {
return Err("--bot-scopes requires a value".to_string());
}
}
"--user-scopes" => {
i += 1;
if i < args.len() {
let scopes_input: Vec<String> =
args[i].split(',').map(|s| s.trim().to_string()).collect();
user_scopes = Some(oauth::expand_scopes_with_context(&scopes_input, false));
} else {
return Err("--user-scopes requires a value".to_string());
}
}
_ => {
return Err(format!("Unknown option: {}", args[i]));
}
}
} else if profile_name.is_none() {
profile_name = Some(args[i].clone());
} else {
return Err(format!("Unexpected argument: {}", args[i]));
}
i += 1;
}
if cloudflared_path.is_some() && ngrok_path.is_some() {
return Err("Cannot specify both --cloudflared and --ngrok at the same time".to_string());
}
let tunnel_mode = if let Some(path) = cloudflared_path {
TunnelMode::Cloudflared(Some(path))
} else if let Some(path) = ngrok_path {
TunnelMode::Ngrok(Some(path))
} else {
TunnelMode::None
};
Ok(LoginArgs {
profile_name,
client_id,
bot_scopes,
user_scopes,
tunnel_mode,
})
}
pub async fn run_auth_login(args: &[String], non_interactive: bool) -> Result<(), String> {
let parsed_args = parse_login_args(args)?;
let redirect_uri = "http://127.0.0.1:8765/callback".to_string();
let base_url = std::env::var("SLACK_OAUTH_BASE_URL").ok();
if parsed_args.tunnel_mode.is_enabled() {
if non_interactive {
return Err(
"Tunnel login (--cloudflared/--ngrok) requires interactive mode.\n\
The manifest-first flow needs user interaction to create the Slack App\n\
and then enter credentials. Use the standard login flow for non-interactive mode."
.to_string(),
);
}
let bot_scopes = parsed_args.bot_scopes.unwrap_or_else(oauth::bot_all_scopes);
let user_scopes = parsed_args
.user_scopes
.unwrap_or_else(oauth::user_all_scopes);
if debug::enabled() {
debug::log("Preparing to call login_with_credentials_extended");
debug::log(format!("bot_scopes_count={}", bot_scopes.len()));
debug::log(format!("user_scopes_count={}", user_scopes.len()));
}
auth::login_with_credentials_extended(
bot_scopes,
user_scopes,
parsed_args.profile_name,
parsed_args.tunnel_mode.is_cloudflared(),
)
.await
.map_err(|e| e.to_string())
} else {
auth::login_with_credentials(
parsed_args.client_id,
parsed_args.profile_name,
redirect_uri,
vec![], parsed_args.bot_scopes,
parsed_args.user_scopes,
base_url,
non_interactive,
)
.await
.map_err(|e| e.to_string())
}
}
fn should_show_private_channel_guidance(
api_args: &ApiCallArgs,
token_type: &str,
response: &ApiCallResponse,
) -> bool {
if api_args.method != "conversations.list" || token_type != "bot" {
return false;
}
if let Some(types) = api_args.params.get("types") {
if !types.contains("private_channel") {
return false;
}
} else {
return false;
}
if let Some(channels) = response.response.get("channels") {
if let Some(channels_array) = channels.as_array() {
return channels_array.is_empty();
}
}
false
}
fn infer_default_token_type(
token_store: &dyn crate::profile::TokenStore,
team_id: &str,
user_id: &str,
) -> TokenType {
let user_token_key = format!("{}:{}:user", team_id, user_id);
if token_store.exists(&user_token_key) {
TokenType::User
} else {
TokenType::Bot
}
}
#[derive(Debug)]
struct ResolvedToken {
token: String,
token_type: TokenType,
}
fn resolve_token(
token_store: &dyn crate::profile::TokenStore,
team_id: &str,
user_id: &str,
cli_token_type: Option<TokenType>,
profile_default_token_type: Option<TokenType>,
profile_name: &str,
) -> Result<ResolvedToken, String> {
let inferred_default = infer_default_token_type(token_store, team_id, user_id);
let resolved_token_type =
TokenType::resolve(cli_token_type, profile_default_token_type, inferred_default);
let token_key_bot = make_token_key(team_id, user_id);
let token_key_user = format!("{}:{}:user", team_id, user_id);
let token_key = match resolved_token_type {
TokenType::Bot => token_key_bot.clone(),
TokenType::User => token_key_user.clone(),
};
let explicit_request = cli_token_type.is_some() || profile_default_token_type.is_some();
let token = if let Ok(env_token) = std::env::var("SLACK_TOKEN") {
env_token
} else {
match token_store.get(&token_key) {
Ok(t) => t,
Err(_) => {
if explicit_request {
return Err(format!(
"No {} token found for profile '{}' ({}:{}). Explicitly requested token type not available. Set SLACK_TOKEN environment variable or run 'slack login' to obtain a {} token.",
resolved_token_type, profile_name, team_id, user_id, resolved_token_type
));
} else {
if resolved_token_type == TokenType::User {
if let Ok(bot_token) = token_store.get(&token_key_bot) {
eprintln!(
"Warning: User token not found, falling back to bot token for profile '{}'",
profile_name
);
return Ok(ResolvedToken {
token: bot_token,
token_type: TokenType::Bot,
});
} else {
return Err(format!(
"No {} token found for profile '{}' ({}:{}). Set SLACK_TOKEN environment variable or run 'slack login' to obtain a token.",
resolved_token_type, profile_name, team_id, user_id
));
}
} else {
return Err(format!(
"No {} token found for profile '{}' ({}:{}). Set SLACK_TOKEN environment variable or run 'slack login' to obtain a token.",
resolved_token_type, profile_name, team_id, user_id
));
}
}
}
}
};
Ok(ResolvedToken {
token,
token_type: resolved_token_type,
})
}
pub async fn run_api_call(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let api_args = ApiCallArgs::parse(&args)?;
let profile_name = crate::cli::resolve_profile_name(&args);
let config_path = default_config_path()?;
let profile = resolve_profile_full(&config_path, &profile_name)
.map_err(|e| format!("Failed to resolve profile '{}': {}", profile_name, e))?;
let context = ApiCallContext {
profile_name: Some(profile_name.clone()),
team_id: profile.team_id.clone(),
user_id: profile.user_id.clone(),
};
let token_store =
create_token_store().map_err(|e| format!("Failed to create token store: {}", e))?;
let resolved = resolve_token(
&*token_store,
&profile.team_id,
&profile.user_id,
api_args.token_type,
profile.default_token_type,
&profile_name,
)
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
let token = resolved.token;
let resolved_token_type = resolved.token_type;
let debug_level = debug::get_debug_level(&args);
let token_store_backend = if std::env::var("SLACK_TOKEN").is_ok() {
"environment"
} else {
"file"
};
let endpoint = format!("https://slack.com/api/{}", api_args.method);
debug::log_api_context(
debug_level,
Some(&profile_name),
token_store_backend,
resolved_token_type.as_str(),
&api_args.method,
&endpoint,
);
let client = ApiClient::new();
let response = execute_api_call(
&client,
&api_args,
&token,
&context,
resolved_token_type.as_str(),
"api call",
)
.await?;
debug::log_error_code(debug_level, &response.response);
crate::api::display_error_guidance(&response);
if should_show_private_channel_guidance(&api_args, resolved_token_type.as_str(), &response) {
eprintln!();
eprintln!("Note: The conversation list for private channels is empty.");
eprintln!("Bot tokens can only see private channels where the bot is a member.");
eprintln!("To list all private channels, use a User Token with appropriate scopes.");
eprintln!("Run: slackcli auth login (with user_scopes) or use --token-type user");
eprintln!();
}
let json = if api_args.raw {
serde_json::to_string_pretty(&response.response)?
} else {
serde_json::to_string_pretty(&response)?
};
println!("{}", json);
Ok(())
}
struct ExportImportArgs {
passphrase_env: Option<String>,
yes: bool,
lang: Option<String>,
}
impl ExportImportArgs {
fn parse(args: &[String]) -> (Self, Vec<(usize, String)>) {
let mut passphrase_env: Option<String> = None;
let mut yes = false;
let mut lang: Option<String> = None;
let mut remaining = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--passphrase-env" => {
i += 1;
if i < args.len() {
passphrase_env = Some(args[i].clone());
}
}
"--passphrase-prompt" => {
}
"--yes" => {
yes = true;
}
"--lang" => {
i += 1;
if i < args.len() {
lang = Some(args[i].clone());
}
}
_ => {
remaining.push((i, args[i].clone()));
}
}
i += 1;
}
(
Self {
passphrase_env,
yes,
lang,
},
remaining,
)
}
fn get_messages(&self) -> auth::Messages {
if let Some(ref lang_code) = self.lang {
if let Some(language) = auth::Language::from_code(lang_code) {
auth::Messages::new(language)
} else {
auth::Messages::default()
}
} else {
auth::Messages::default()
}
}
fn get_passphrase(&self, messages: &auth::Messages) -> Result<String, String> {
if let Some(ref env_var) = self.passphrase_env {
match std::env::var(env_var) {
Ok(val) => Ok(val),
Err(_) => {
eprintln!(
"Warning: Environment variable {} not found, prompting for passphrase",
env_var
);
rpassword::prompt_password(messages.get("prompt.passphrase"))
.map_err(|e| format!("Error reading passphrase: {}", e))
}
}
} else {
rpassword::prompt_password(messages.get("prompt.passphrase"))
.map_err(|e| format!("Error reading passphrase: {}", e))
}
}
}
pub async fn handle_export_command(args: &[String]) {
if args.iter().any(|arg| arg == "-h" || arg == "--help") {
super::help::print_export_help();
return;
}
let (common_args, remaining) = ExportImportArgs::parse(args);
let mut profile_name: Option<String> = None;
let mut all = false;
let mut output_path: Option<String> = None;
for (idx, arg) in remaining {
match arg.as_str() {
"--profile" => {
if idx + 1 < args.len() {
profile_name = Some(args[idx + 1].clone());
}
}
"--all" => {
all = true;
}
"--out" => {
if idx + 1 < args.len() {
output_path = Some(args[idx + 1].clone());
}
}
_ => {
if idx > 0 {
let prev = &args[idx - 1];
if prev == "--profile"
|| prev == "--out"
|| prev == "--passphrase-env"
|| prev == "--lang"
{
continue;
}
}
eprintln!("Unknown option: {}", arg);
std::process::exit(1);
}
}
}
let messages = common_args.get_messages();
if !common_args.yes {
eprintln!("{}", messages.get("warn.export_sensitive"));
eprintln!("Error: --yes flag is required to confirm this dangerous operation");
std::process::exit(1);
}
let output = match output_path {
Some(path) => path,
None => {
eprintln!("Error: --out <file> is required");
std::process::exit(1);
}
};
let passphrase = match common_args.get_passphrase(&messages) {
Ok(pass) => pass,
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
};
let options = auth::ExportOptions {
profile_name,
all,
output_path: output,
passphrase,
yes: common_args.yes,
};
let token_store = create_token_store().expect("Failed to create token store");
match auth::export_profiles(&*token_store, &options) {
Ok(result) => {
if !result.skipped_profiles.is_empty() {
eprintln!("{}", messages.get("warn.export_skipped"));
for profile_name in &result.skipped_profiles {
eprintln!(" - {}", profile_name);
}
eprintln!();
eprintln!(
"{}",
messages
.get("info.export_summary")
.replace("{exported}", &result.exported_count.to_string())
.replace("{skipped}", &result.skipped_profiles.len().to_string())
);
eprintln!();
}
println!("{}", messages.get("success.export"));
}
Err(e) => {
eprintln!("Export failed: {}", e);
std::process::exit(1);
}
}
}
pub async fn handle_import_command(args: &[String]) {
if args.iter().any(|arg| arg == "-h" || arg == "--help") {
super::help::print_import_help();
return;
}
let (common_args, remaining) = ExportImportArgs::parse(args);
let mut input_path: Option<String> = None;
let mut force = false;
let mut dry_run = false;
let mut json = false;
for (idx, arg) in remaining {
match arg.as_str() {
"--in" => {
if idx + 1 < args.len() {
input_path = Some(args[idx + 1].clone());
}
}
"--force" => {
force = true;
}
"--dry-run" => {
dry_run = true;
}
"--json" => {
json = true;
}
_ => {
if idx > 0 {
let prev = &args[idx - 1];
if prev == "--in" || prev == "--passphrase-env" || prev == "--lang" {
continue;
}
}
eprintln!("Unknown option: {}", arg);
std::process::exit(1);
}
}
}
let messages = common_args.get_messages();
let input = match input_path {
Some(path) => path,
None => {
eprintln!("Error: --in <file> is required");
std::process::exit(1);
}
};
let passphrase = match common_args.get_passphrase(&messages) {
Ok(pass) => pass,
Err(e) => {
eprintln!("{}", e);
std::process::exit(1);
}
};
let options = auth::ImportOptions {
input_path: input,
passphrase,
yes: common_args.yes,
force,
dry_run,
json,
};
let token_store = create_token_store().expect("Failed to create token store");
match auth::import_profiles(&*token_store, &options) {
Ok(result) => {
if json {
match serde_json::to_string_pretty(&result) {
Ok(json_output) => {
println!("{}", json_output);
}
Err(e) => {
eprintln!("Failed to serialize result to JSON: {}", e);
std::process::exit(1);
}
}
} else {
if result.dry_run {
println!("Dry-run mode: no changes were written.");
println!();
}
println!("Import Summary:");
println!(" Total: {}", result.summary.total);
println!(" Updated: {}", result.summary.updated);
println!(" Skipped: {}", result.summary.skipped);
println!(" Overwritten: {}", result.summary.overwritten);
println!();
println!("Profile Details:");
for profile_result in &result.profiles {
println!(
" {} - {} ({})",
profile_result.profile_name, profile_result.action, profile_result.reason
);
}
println!();
if result.dry_run {
println!("Dry-run complete. Re-run without --dry-run to apply changes.");
} else {
println!("{}", messages.get("success.import"));
}
}
}
Err(e) => {
eprintln!("Import failed: {}", e);
std::process::exit(1);
}
}
}
pub fn run_install_skill(args: &[String]) -> Result<(), String> {
use crate::skills;
use serde_json::json;
let global = args.iter().any(|arg| arg == "--global");
let source = args
.iter()
.find(|arg| !arg.starts_with("--"))
.map(|s| s.as_str());
let installed = skills::install_skill(source, global).map_err(|e| e.to_string())?;
let response = json!({
"schemaVersion": "1.0",
"type": "skill-installation",
"ok": true,
"skills": [
{
"name": installed.name,
"path": installed.path,
"source_type": installed.source_type,
}
]
});
println!("{}", serde_json::to_string_pretty(&response).unwrap());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::call::ApiCallMeta;
use crate::profile::{InMemoryTokenStore, TokenStore};
use serde_json::json;
use serial_test::serial;
use std::collections::HashMap;
#[test]
fn test_parse_login_args_empty() {
let args = vec![];
let result = parse_login_args(&args);
assert!(result.is_ok());
let parsed = result.unwrap();
assert_eq!(parsed.profile_name, None);
assert_eq!(parsed.client_id, None);
assert_eq!(parsed.bot_scopes, None);
assert_eq!(parsed.user_scopes, None);
assert_eq!(parsed.tunnel_mode, TunnelMode::None);
}
#[test]
fn test_parse_login_args_profile_only() {
let args = vec!["my-profile".to_string()];
let result = parse_login_args(&args);
assert!(result.is_ok());
let parsed = result.unwrap();
assert_eq!(parsed.profile_name, Some("my-profile".to_string()));
assert_eq!(parsed.tunnel_mode, TunnelMode::None);
}
#[test]
fn test_parse_login_args_with_client_id() {
let args = vec!["--client-id".to_string(), "123.456".to_string()];
let result = parse_login_args(&args);
assert!(result.is_ok());
let parsed = result.unwrap();
assert_eq!(parsed.client_id, Some("123.456".to_string()));
}
#[test]
fn test_parse_login_args_cloudflared_default() {
let args = vec!["--cloudflared".to_string()];
let result = parse_login_args(&args);
assert!(result.is_ok());
let parsed = result.unwrap();
assert!(matches!(
parsed.tunnel_mode,
TunnelMode::Cloudflared(Some(_))
));
if let TunnelMode::Cloudflared(Some(path)) = parsed.tunnel_mode {
assert_eq!(path, "cloudflared");
}
}
#[test]
fn test_parse_login_args_cloudflared_with_path() {
let args = vec![
"--cloudflared".to_string(),
"/usr/bin/cloudflared".to_string(),
];
let result = parse_login_args(&args);
assert!(result.is_ok());
let parsed = result.unwrap();
if let TunnelMode::Cloudflared(Some(path)) = parsed.tunnel_mode {
assert_eq!(path, "/usr/bin/cloudflared");
} else {
panic!("Expected Cloudflared tunnel mode");
}
}
#[test]
fn test_parse_login_args_ngrok_default() {
let args = vec!["--ngrok".to_string()];
let result = parse_login_args(&args);
assert!(result.is_ok());
let parsed = result.unwrap();
assert!(matches!(parsed.tunnel_mode, TunnelMode::Ngrok(Some(_))));
if let TunnelMode::Ngrok(Some(path)) = parsed.tunnel_mode {
assert_eq!(path, "ngrok");
}
}
#[test]
fn test_parse_login_args_cloudflared_ngrok_mutual_exclusion() {
let args = vec!["--cloudflared".to_string(), "--ngrok".to_string()];
let result = parse_login_args(&args);
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Cannot specify both --cloudflared and --ngrok"));
}
#[test]
fn test_parse_login_args_bot_scopes() {
let args = vec![
"--bot-scopes".to_string(),
"chat:write,users:read".to_string(),
];
let result = parse_login_args(&args);
assert!(result.is_ok());
let parsed = result.unwrap();
assert!(parsed.bot_scopes.is_some());
let scopes = parsed.bot_scopes.unwrap();
assert!(scopes.contains(&"chat:write".to_string()));
assert!(scopes.contains(&"users:read".to_string()));
}
#[test]
fn test_parse_login_args_user_scopes() {
let args = vec![
"--user-scopes".to_string(),
"search:read,users:read".to_string(),
];
let result = parse_login_args(&args);
assert!(result.is_ok());
let parsed = result.unwrap();
assert!(parsed.user_scopes.is_some());
}
#[test]
fn test_parse_login_args_all_parameters() {
let args = vec![
"work".to_string(),
"--client-id".to_string(),
"123.456".to_string(),
"--bot-scopes".to_string(),
"chat:write".to_string(),
"--user-scopes".to_string(),
"users:read".to_string(),
"--cloudflared".to_string(),
];
let result = parse_login_args(&args);
assert!(result.is_ok());
let parsed = result.unwrap();
assert_eq!(parsed.profile_name, Some("work".to_string()));
assert_eq!(parsed.client_id, Some("123.456".to_string()));
assert!(parsed.bot_scopes.is_some());
assert!(parsed.user_scopes.is_some());
assert!(parsed.tunnel_mode.is_cloudflared());
}
#[test]
fn test_parse_login_args_unknown_option() {
let args = vec!["--unknown-flag".to_string()];
let result = parse_login_args(&args);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unknown option"));
}
#[test]
fn test_parse_login_args_unexpected_positional() {
let args = vec!["profile1".to_string(), "profile2".to_string()];
let result = parse_login_args(&args);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unexpected argument"));
}
#[test]
fn test_parse_login_args_client_id_missing_value() {
let args = vec!["--client-id".to_string()];
let result = parse_login_args(&args);
assert!(result.is_err());
assert!(result.unwrap_err().contains("--client-id requires a value"));
}
#[test]
fn test_parse_login_args_bot_scopes_missing_value() {
let args = vec!["--bot-scopes".to_string()];
let result = parse_login_args(&args);
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("--bot-scopes requires a value"));
}
#[test]
fn test_tunnel_mode_none() {
let mode = TunnelMode::None;
assert!(!mode.is_enabled());
assert!(!mode.is_cloudflared());
assert!(!mode.is_ngrok());
}
#[test]
fn test_tunnel_mode_cloudflared() {
let mode = TunnelMode::Cloudflared(Some("cloudflared".to_string()));
assert!(mode.is_enabled());
assert!(mode.is_cloudflared());
assert!(!mode.is_ngrok());
}
#[test]
fn test_tunnel_mode_ngrok() {
let mode = TunnelMode::Ngrok(Some("ngrok".to_string()));
assert!(mode.is_enabled());
assert!(!mode.is_cloudflared());
assert!(mode.is_ngrok());
}
#[test]
fn test_should_show_private_channel_guidance_empty_response() {
let mut params = HashMap::new();
params.insert("types".to_string(), "private_channel".to_string());
let args = ApiCallArgs {
method: "conversations.list".to_string(),
params,
use_json: false,
use_get: false,
token_type: None,
raw: false,
};
let response = ApiCallResponse {
response: json!({
"ok": true,
"channels": []
}),
meta: ApiCallMeta {
profile_name: Some("default".to_string()),
team_id: "T123".to_string(),
user_id: "U123".to_string(),
method: "conversations.list".to_string(),
command: "api call".to_string(),
token_type: "bot".to_string(),
},
};
assert!(should_show_private_channel_guidance(
&args, "bot", &response
));
}
#[test]
fn test_should_show_private_channel_guidance_non_empty_response() {
let mut params = HashMap::new();
params.insert("types".to_string(), "private_channel".to_string());
let args = ApiCallArgs {
method: "conversations.list".to_string(),
params,
use_json: false,
use_get: false,
token_type: None,
raw: false,
};
let response = ApiCallResponse {
response: json!({
"ok": true,
"channels": [
{"id": "C123", "name": "private-channel"}
]
}),
meta: ApiCallMeta {
profile_name: Some("default".to_string()),
team_id: "T123".to_string(),
user_id: "U123".to_string(),
method: "conversations.list".to_string(),
command: "api call".to_string(),
token_type: "bot".to_string(),
},
};
assert!(!should_show_private_channel_guidance(
&args, "bot", &response
));
}
#[test]
fn test_should_show_private_channel_guidance_user_token() {
let mut params = HashMap::new();
params.insert("types".to_string(), "private_channel".to_string());
let args = ApiCallArgs {
method: "conversations.list".to_string(),
params,
use_json: false,
use_get: false,
token_type: None,
raw: false,
};
let response = ApiCallResponse {
response: json!({
"ok": true,
"channels": []
}),
meta: ApiCallMeta {
profile_name: Some("default".to_string()),
team_id: "T123".to_string(),
user_id: "U123".to_string(),
method: "conversations.list".to_string(),
command: "api call".to_string(),
token_type: "user".to_string(),
},
};
assert!(!should_show_private_channel_guidance(
&args, "user", &response
));
}
#[test]
fn test_infer_default_token_type_with_user_token() {
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
token_store
.set(
&format!("{}:{}:user", team_id, user_id),
"xoxp-test-user-token",
)
.unwrap();
let inferred = infer_default_token_type(&token_store, team_id, user_id);
assert_eq!(inferred, TokenType::User);
}
#[test]
fn test_infer_default_token_type_without_user_token() {
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
token_store
.set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
.unwrap();
let inferred = infer_default_token_type(&token_store, team_id, user_id);
assert_eq!(inferred, TokenType::Bot);
}
#[test]
fn test_infer_default_token_type_with_both_tokens() {
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
token_store
.set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
.unwrap();
token_store
.set(
&format!("{}:{}:user", team_id, user_id),
"xoxp-test-user-token",
)
.unwrap();
let inferred = infer_default_token_type(&token_store, team_id, user_id);
assert_eq!(inferred, TokenType::User);
}
#[test]
fn test_infer_default_token_type_with_no_tokens() {
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
let inferred = infer_default_token_type(&token_store, team_id, user_id);
assert_eq!(inferred, TokenType::Bot);
}
#[test]
#[serial]
fn test_resolve_token_with_bot_token_in_store() {
std::env::remove_var("SLACK_TOKEN");
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
token_store
.set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
.unwrap();
let result = resolve_token(&token_store, team_id, user_id, None, None, "default");
assert!(result.is_ok());
let resolved = result.unwrap();
assert_eq!(resolved.token, "xoxb-test-bot-token");
assert_eq!(resolved.token_type, TokenType::Bot);
}
#[test]
#[serial]
fn test_resolve_token_with_user_token_in_store() {
std::env::remove_var("SLACK_TOKEN");
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
token_store
.set(
&format!("{}:{}:user", team_id, user_id),
"xoxp-test-user-token",
)
.unwrap();
let result = resolve_token(&token_store, team_id, user_id, None, None, "default");
assert!(result.is_ok());
let resolved = result.unwrap();
assert_eq!(resolved.token, "xoxp-test-user-token");
assert_eq!(resolved.token_type, TokenType::User);
}
#[test]
#[serial]
fn test_resolve_token_with_slack_token_env() {
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
std::env::set_var("SLACK_TOKEN", "xoxb-env-token");
let result = resolve_token(&token_store, team_id, user_id, None, None, "default");
std::env::remove_var("SLACK_TOKEN");
assert!(result.is_ok());
let resolved = result.unwrap();
assert_eq!(resolved.token, "xoxb-env-token");
assert_eq!(resolved.token_type, TokenType::Bot);
}
#[test]
#[serial]
fn test_resolve_token_explicit_bot_request_fails_without_bot_token() {
std::env::remove_var("SLACK_TOKEN");
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
token_store
.set(
&format!("{}:{}:user", team_id, user_id),
"xoxp-test-user-token",
)
.unwrap();
let result = resolve_token(
&token_store,
team_id,
user_id,
Some(TokenType::Bot),
None,
"default",
);
assert!(result.is_err());
let error_msg = result.unwrap_err();
assert!(error_msg.contains("No bot token found"));
assert!(error_msg.contains("Explicitly requested token type not available"));
}
#[test]
#[serial]
fn test_resolve_token_explicit_user_request_fails_without_user_token() {
std::env::remove_var("SLACK_TOKEN");
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
token_store
.set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
.unwrap();
let result = resolve_token(
&token_store,
team_id,
user_id,
Some(TokenType::User),
None,
"default",
);
assert!(result.is_err());
let error_msg = result.unwrap_err();
assert!(error_msg.contains("No user token found"));
assert!(error_msg.contains("Explicitly requested token type not available"));
}
#[test]
#[serial]
fn test_resolve_token_fallback_from_user_to_bot() {
std::env::remove_var("SLACK_TOKEN");
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
token_store
.set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
.unwrap();
let result = resolve_token(
&token_store,
team_id,
user_id,
None,
Some(TokenType::User), "default",
);
assert!(result.is_err());
}
#[test]
#[serial]
fn test_resolve_token_no_fallback_when_profile_default_set() {
std::env::remove_var("SLACK_TOKEN");
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
token_store
.set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
.unwrap();
let result = resolve_token(
&token_store,
team_id,
user_id,
None,
Some(TokenType::User),
"default",
);
assert!(result.is_err());
let error_msg = result.unwrap_err();
assert!(error_msg.contains("Explicitly requested token type not available"));
}
#[test]
#[serial]
fn test_resolve_token_cli_overrides_profile_default() {
std::env::remove_var("SLACK_TOKEN");
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
token_store
.set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
.unwrap();
token_store
.set(
&format!("{}:{}:user", team_id, user_id),
"xoxp-test-user-token",
)
.unwrap();
let result = resolve_token(
&token_store,
team_id,
user_id,
Some(TokenType::User), Some(TokenType::Bot), "default",
);
assert!(result.is_ok());
let resolved = result.unwrap();
assert_eq!(resolved.token, "xoxp-test-user-token");
assert_eq!(resolved.token_type, TokenType::User);
}
#[test]
#[serial]
fn test_resolve_token_slack_token_prioritized_over_store() {
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
token_store
.set(&format!("{}:{}", team_id, user_id), "xoxb-store-token")
.unwrap();
std::env::set_var("SLACK_TOKEN", "xoxb-env-token");
let result = resolve_token(&token_store, team_id, user_id, None, None, "default");
std::env::remove_var("SLACK_TOKEN");
assert!(result.is_ok());
let resolved = result.unwrap();
assert_eq!(resolved.token, "xoxb-env-token");
assert_eq!(resolved.token_type, TokenType::Bot);
}
#[test]
#[serial]
fn test_resolve_token_with_both_tokens_prefers_user() {
std::env::remove_var("SLACK_TOKEN");
let token_store = InMemoryTokenStore::new();
let team_id = "T123";
let user_id = "U456";
token_store
.set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
.unwrap();
token_store
.set(
&format!("{}:{}:user", team_id, user_id),
"xoxp-test-user-token",
)
.unwrap();
let result = resolve_token(&token_store, team_id, user_id, None, None, "default");
assert!(result.is_ok());
let resolved = result.unwrap();
assert_eq!(resolved.token, "xoxp-test-user-token");
assert_eq!(resolved.token_type, TokenType::User);
}
}