diff --git a/README.md b/README.md index 11b3ad1..45139c9 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ A comprehensive Rust CLI tool for Slack, designed for AI agents and automation. - **Multiple authentication methods**: OAuth, browser tokens (xoxc+xoxd), direct tokens (xoxp/xoxb) - **Full workspace access**: Channels, messages, threads, search, files, reactions, reminders, status +- **Generic API escape hatch**: `slack api` calls any Slack Web API method with your stored auth - **Agent-first design**: JSON output by default, optimized for AI consumption - **Minimal footprint**: No config files, tokens stored in system keyring - **Fast and reliable**: Built with Rust for performance and safety @@ -321,6 +322,73 @@ slack reminders complete Rm123456789 slack reminders delete Rm123456789 ``` +### Generic API (`slack api`) + +Escape hatch for any Slack Web API method that doesn't have a dedicated +command. Reuses your stored credentials (including browser xoxc token + +xoxd cookie), the `-w`/`--workspace` selector, and `--token`/`SLACK_TOKEN` +overrides — your token's scopes still apply, so a method can fail with +`missing_scope` just as it would with curl. + +```bash +# Call a method (POST is the default HTTP method) +slack api conversations.create -f name=my-new-channel + +# GET with query parameters +slack api conversations.info -X GET -f channel=C123456789 + +# Typed fields: -F parses JSON booleans, numbers, arrays and objects +slack api conversations.list -X GET -F limit=200 -F exclude_archived=true +slack api chat.postMessage -f channel=C123 -f text=hi -F unfurl_links=false + +# Load the parameter object from a JSON file or stdin +slack api chat.postMessage --input params.json +echo '{"channel":"C123","text":"hi"}' | slack api chat.postMessage --input - + +# Full Slack URLs are accepted and normalized to the method name +slack api https://slack.com/api/team.info -X GET +``` + +**Flags** + +| Flag | Description | +|------|-------------| +| `-X, --method ` | HTTP method. Defaults to `POST` (unlike `gh api`, which defaults to GET). | +| `-f, --raw-field key=value` | Add a parameter as a plain string. Repeatable. | +| `-F, --field key=value` | Add a typed parameter: `true`/`false`, numbers, and JSON arrays/objects are parsed as JSON; anything else is a string. `-F key=null` is rejected — omit the field instead. Repeatable. | +| `--input FILE` | Read the parameter object from a JSON file, or from stdin with `--input -`. Cannot be combined with `-f`/`-F`. Fields whose value is `null` are omitted, matching the CLI's form encoder. | + +Duplicate parameter names (across `-f`/`-F`) are rejected. + +**Request conventions** + +- Only `GET` and `POST` are supported. `GET` sends parameters as URL query + parameters; `POST` sends them form-encoded + (`application/x-www-form-urlencoded`), which is what the Slack Web API + expects. +- There is no raw JSON request body: `--input` loads a JSON *parameter + object* which is then form-encoded like `-f`/`-F` fields. Nested arrays + and objects (e.g. `blocks`, `attachments`) are serialized as JSON strings, + per Slack convention. +- Endpoints are Web API method names (`conversations.info`) or full + `https://slack.com/api/` URLs, which are normalized to the method + name. URLs with other hosts, embedded credentials, query strings, + fragments, or extra path segments are rejected. (`SLACK_API_BASE_URL` + still overrides the base URL for testing/mocks.) +- HTTP redirects are never followed, so your token and cookies can't leak + to another host. +- No automatic pagination — pass `cursor`/`limit` yourself and follow + `response_metadata.next_cursor`. +- Not supported: custom headers, file uploads, name→ID resolution, jq + filtering, or Edge API endpoints. + +**Output** is the full JSON response from Slack on success. `--plain` is not +supported for `slack api`. Slack `ok: false` responses, rate limits, and +network failures exit with status 1 and structured JSON error codes; +invalid arguments exit with status 2. Rate limits are retried at most five +times; a server-requested delay over 60 seconds is returned immediately +as a rate-limit error rather than blocking or retrying too early. + ## Output Modes By default, output is JSON (optimized for AI agents). Use `--plain` for human-readable TSV output: @@ -334,6 +402,8 @@ slack --plain channels list slack channels list --plain ``` +`slack api` is JSON-only and rejects `--plain`. + ## Global Options ```bash diff --git a/src/api/client.rs b/src/api/client.rs index d30d18e..45a869c 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -29,6 +29,144 @@ fn get_api_base_url() -> String { std::env::var(SLACK_API_BASE_ENV).unwrap_or_else(|_| DEFAULT_SLACK_API_BASE.to_string()) } +/// Check whether `s` is a syntactically valid Slack Web API method name +/// (e.g. `chat.postMessage`, `admin.users.list`, `api.test`). +/// +/// Segments of ASCII alphanumerics/underscores separated by single dots. +/// This rejects path separators, percent-encoding, whitespace, traversal +/// sequences and every other URL trick by construction. +fn is_valid_method_name(s: &str) -> bool { + !s.is_empty() + && s.split('.').all(|seg| { + !seg.is_empty() && seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + }) +} + +/// Normalize a user-supplied endpoint into a bare Slack method name. +/// +/// Accepts either: +/// - a bare method name (`chat.postMessage`), or +/// - a full canonical URL `https://slack.com/api/`. +/// +/// Full URLs are strictly validated (https only, exact `slack.com` host, no +/// userinfo, no non-default port, no query, no fragment, exactly one method +/// path segment under `/api/`) and normalized to the bare method name so the +/// request is always issued against the configured base URL (which keeps +/// `SLACK_API_BASE_URL` usable for mocks). Everything else is rejected +/// before any HTTP request is made. +pub(crate) fn normalize_api_endpoint(endpoint: &str) -> Result { + let endpoint = endpoint.trim(); + + if endpoint.is_empty() { + return Err(SlackError::Usage( + "API method must not be empty".to_string(), + )); + } + + // Bare method name: the common, safe case. + if is_valid_method_name(endpoint) { + return Ok(endpoint.to_string()); + } + + // Otherwise it must be a full canonical Slack API URL. + let url = url::Url::parse(endpoint).map_err(|_| { + SlackError::Usage(format!( + "invalid API method '{}': expected a method name like 'chat.postMessage' \ + or a full URL like 'https://slack.com/api/chat.postMessage'", + endpoint + )) + })?; + + if url.scheme() != "https" { + return Err(SlackError::Usage(format!( + "invalid API URL '{}': only https:// URLs are allowed", + endpoint + ))); + } + + if !url.username().is_empty() || url.password().is_some() { + return Err(SlackError::Usage(format!( + "invalid API URL '{}': credentials in the URL are not allowed", + endpoint + ))); + } + + if url.host_str() != Some("slack.com") { + return Err(SlackError::Usage(format!( + "invalid API URL '{}': host must be exactly slack.com", + endpoint + ))); + } + + // `Url` drops the default port (443) during parsing, so any remaining + // explicit port is a non-default one. + if url.port().is_some() { + return Err(SlackError::Usage(format!( + "invalid API URL '{}': a custom port is not allowed", + endpoint + ))); + } + + if url.query().is_some() { + return Err(SlackError::Usage(format!( + "invalid API URL '{}': query strings are not allowed (pass parameters as fields)", + endpoint + ))); + } + + if url.fragment().is_some() { + return Err(SlackError::Usage(format!( + "invalid API URL '{}': fragments are not allowed", + endpoint + ))); + } + + // Path must be exactly /api/. `Url::parse` has already resolved + // `.`/`..` segments, and percent-encoded characters remain encoded in + // `path()` so they fail the method-name check below. + let method = url + .path() + .strip_prefix("/api/") + .filter(|m| is_valid_method_name(m)) + .ok_or_else(|| { + SlackError::Usage(format!( + "invalid API URL '{}': path must be exactly /api/", + endpoint + )) + })?; + + Ok(method.to_string()) +} + +/// Truncate a string to at most `max_chars` characters for error details. +fn truncate_str(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + let truncated: String = s.chars().take(max_chars).collect(); + format!("{}… (truncated)", truncated) + } +} + +/// Build the HTTP clients used by `SlackClient`. +/// +/// Returns `(default, no_redirect)`. The default client follows redirects +/// (needed for file downloads); the no-redirect client is used for generic +/// `api_request` calls so an attacker-controlled redirect can never leak the +/// Authorization header or session cookie to another host. +fn build_http_clients() -> Result<(reqwest::Client, reqwest::Client)> { + let default = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(SlackError::Network)?; + let no_redirect = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(SlackError::Network)?; + Ok((default, no_redirect)) +} + /// Convert arbitrary serializable params into form fields for /// `application/x-www-form-urlencoded` requests. /// @@ -64,6 +202,9 @@ where #[derive(Clone)] pub struct SlackClient { http: reqwest::Client, + /// Client with redirects disabled, used for generic `api_request` calls + /// so credentials can never follow a redirect off-host. + http_no_redirect: reqwest::Client, token: TokenSet, rate_limiter: RateLimiter, base_url: String, @@ -79,13 +220,11 @@ impl SlackClient { pub fn with_base_url(token: TokenSet, base_url: String) -> Result { token.validate()?; - let http = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .map_err(SlackError::Network)?; + let (http, http_no_redirect) = build_http_clients()?; Ok(Self { http, + http_no_redirect, token, rate_limiter: RateLimiter::new(), base_url, @@ -96,13 +235,11 @@ impl SlackClient { pub fn with_rate_limiter(token: TokenSet, rate_limiter: RateLimiter) -> Result { token.validate()?; - let http = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .map_err(SlackError::Network)?; + let (http, http_no_redirect) = build_http_clients()?; Ok(Self { http, + http_no_redirect, token, rate_limiter, base_url: get_api_base_url(), @@ -221,6 +358,179 @@ impl SlackClient { } } + /// Make a generic request to an arbitrary Slack Web API method and return + /// the full raw JSON response (escape hatch, `slack api`). + /// + /// `endpoint` is a bare method name (`chat.postMessage`) or a full + /// canonical `https://slack.com/api/` URL, validated and + /// normalized before any HTTP request via `normalize_api_endpoint`. + /// + /// Only GET and POST are supported. GET sends `params` as query + /// parameters; POST sends them form-encoded — both use the same encoding + /// as `to_form_params`: scalars stringified, nested arrays/objects + /// encoded as JSON strings, and explicit JSON `null` values omitted + /// entirely. + /// + /// Redirects are disabled so tokens/cookies cannot leak to another host. + /// Rate-limit (429) responses are retried with bounded backoff, exactly + /// like [`SlackClient::request`]; no other status (including 5xx) is + /// retried, so mutations are never replayed. + pub async fn api_request( + &self, + endpoint: &str, + http_method: reqwest::Method, + params: &serde_json::Value, + ) -> Result { + // Validate everything before any network I/O. + let method_name = normalize_api_endpoint(endpoint)?; + + if http_method != reqwest::Method::GET && http_method != reqwest::Method::POST { + return Err(SlackError::Usage(format!( + "unsupported HTTP method '{}': only GET and POST are allowed", + http_method + ))); + } + + match params { + serde_json::Value::Null | serde_json::Value::Object(_) => {} + _ => { + return Err(SlackError::Usage( + "request parameters must be a JSON object".to_string(), + )) + } + } + + let pairs = to_form_params(params)?; + let url = format!("{}/{}", self.base_url, method_name); + let headers = self.build_auth_headers(); + + let mut retries = 0; + let mut backoff = INITIAL_BACKOFF_MS; + + loop { + // Wait for rate limiter + self.rate_limiter.acquire().await; + + let builder = if http_method == reqwest::Method::GET { + self.http_no_redirect.get(&url).query(&pairs) + } else { + self.http_no_redirect.post(&url).form(&pairs) + }; + + let response = builder + .headers(headers.clone()) + .send() + .await + .map_err(SlackError::Network)?; + + let status = response.status(); + + // Bounded retries on 429, mirroring `request`. 429 means the + // request was not executed, so retrying is safe for mutations. + if status == reqwest::StatusCode::TOO_MANY_REQUESTS { + if retries >= MAX_RETRIES { + let retry_after = response + .headers() + .get("retry-after") + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.parse().ok()) + .unwrap_or(60); + return Err(SlackError::RateLimited(retry_after)); + } + + let retry_after = response + .headers() + .get("retry-after") + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.parse::().ok()); + // Do not overflow or block indefinitely on an excessive server + // delay. Return it to the caller rather than retrying too early. + if let Some(seconds) = retry_after { + if seconds > 60 { + return Err(SlackError::RateLimited(seconds)); + } + } + let wait_time = retry_after.map(|s| s * 1000).unwrap_or(backoff); + + tokio::time::sleep(Duration::from_millis(wait_time)).await; + + retries += 1; + backoff *= 2; + continue; + } + + // Redirects are disabled: a 3xx here means the server tried to + // send us elsewhere. Fail loudly instead of following. + if status.is_redirection() { + return Err(SlackError::Api { + error: format!("HTTP {}", status), + detail: Some( + "server attempted a redirect; redirects are disabled for generic API \ + requests to protect credentials" + .to_string(), + ), + }); + } + + let body = response.text().await.map_err(SlackError::Network)?; + + let value: serde_json::Value = match serde_json::from_str(&body) { + Ok(v) => v, + Err(e) => { + // Malformed body: report the HTTP status when it already + // indicates failure, otherwise the parse problem. + if !status.is_success() { + return Err(SlackError::Api { + error: format!("HTTP {}", status), + detail: Some(format!( + "response body was not valid JSON: {}", + truncate_str(&body, 300) + )), + }); + } + return Err(SlackError::Api { + error: "invalid_response".to_string(), + detail: Some(format!("response was not valid JSON: {}", e)), + }); + } + }; + + // Slack-level failure takes precedence regardless of HTTP status. + if value.get("ok").and_then(|v| v.as_bool()) == Some(false) { + let error = value + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or("unknown_error") + .to_string(); + let detail = value + .get("response_metadata") + .and_then(|m| m.get("messages")) + .and_then(|m| m.as_array()) + .map(|msgs| { + msgs.iter() + .filter_map(|m| m.as_str()) + .collect::>() + .join(", ") + }) + .filter(|s| !s.is_empty()); + return Err(SlackError::Api { error, detail }); + } + + // Valid JSON without `ok: false` but a failing HTTP status is + // still a failure (e.g. proxies, gateways). + if !status.is_success() { + return Err(SlackError::Api { + error: format!("HTTP {}", status), + detail: Some(truncate_str(&body, 300)), + }); + } + + // Success: return the full raw JSON response (including `ok`, + // warnings and metadata) untouched. + return Ok(value); + } + } + /// Make a GET request to download a file /// /// Returns the raw bytes of the file. @@ -365,6 +675,18 @@ mod tests { assert!(pairs.is_empty()); } + #[test] + fn test_to_form_params_json_value_null_fields_omitted() { + // api_request feeds serde_json::Value params through to_form_params: + // explicit nulls must be omitted entirely (predictable null handling). + let params = serde_json::json!({"channel": "C1", "thread_ts": null}); + let pairs = to_form_params(¶ms).unwrap(); + assert_eq!(pairs, vec![("channel".to_string(), "C1".to_string())]); + + // Null params (no fields at all) encode as an empty pair list. + assert!(to_form_params(&serde_json::Value::Null).unwrap().is_empty()); + } + #[test] fn test_client_creation_user_token() { let token = create_test_token(TokenType::UserOAuth); @@ -497,6 +819,490 @@ mod tests { assert!(client.base_url().contains("slack.com") || client.base_url().starts_with("http")); } + // --- normalize_api_endpoint / api_request validation ------------------- + + #[test] + fn test_normalize_endpoint_bare_method_names() { + for m in [ + "api.test", + "chat.postMessage", + "admin.users.list", + "conversations.history", + "users.profile.set", + "team_info", // underscores allowed within a segment + ] { + assert_eq!(normalize_api_endpoint(m).unwrap(), m, "method {}", m); + } + } + + #[test] + fn test_normalize_endpoint_trims_whitespace() { + assert_eq!( + normalize_api_endpoint(" chat.postMessage ").unwrap(), + "chat.postMessage" + ); + } + + #[test] + fn test_normalize_endpoint_full_url_is_normalized() { + assert_eq!( + normalize_api_endpoint("https://slack.com/api/chat.postMessage").unwrap(), + "chat.postMessage" + ); + // Host case-insensitivity (Url lowercases the host) + assert_eq!( + normalize_api_endpoint("https://SLACK.COM/api/auth.test").unwrap(), + "auth.test" + ); + // Explicit default port is normalized away by Url and is fine + assert_eq!( + normalize_api_endpoint("https://slack.com:443/api/auth.test").unwrap(), + "auth.test" + ); + } + + #[test] + fn test_normalize_endpoint_rejects_unsafe_inputs() { + let bad = [ + "", + " ", + ".", + "..", + "chat..postMessage", + ".chat.postMessage", + "chat.postMessage.", + "chat/postMessage", + "../auth.test", + "chat.postMessage?foo=bar", + "chat.post Message", + "chat.postMessage#frag", + // Wrong scheme + "http://slack.com/api/auth.test", + "ftp://slack.com/api/auth.test", + "file:///etc/passwd", + // Wrong host / lookalikes + "https://evil.com/api/auth.test", + "https://slack.com.evil.com/api/auth.test", + "https://api.slack.com/api/auth.test", + "https://slack.com@evil.com/api/auth.test", + // Userinfo + "https://user:pass@slack.com/api/auth.test", + "https://user@slack.com/api/auth.test", + // Non-default port + "https://slack.com:8443/api/auth.test", + // Query / fragment + "https://slack.com/api/auth.test?token=x", + "https://slack.com/api/auth.test#frag", + // Path tricks + "https://slack.com/api/", + "https://slack.com/api", + "https://slack.com/auth.test", + "https://slack.com/api/auth.test/extra", + "https://slack.com/api/auth.test/", + "https://slack.com/api/../admin", + "https://slack.com/api/%2e%2e/admin", + "https://slack.com/api/auth%2Etest", + "https://slack.com//api/auth.test", + ]; + for input in bad { + let result = normalize_api_endpoint(input); + match result { + Err(SlackError::Usage(_)) => {} + other => panic!("expected Usage error for {:?}, got {:?}", input, other), + } + } + } + + #[tokio::test] + async fn test_api_request_rejects_bad_endpoint_before_http() { + let token = create_test_token(TokenType::UserOAuth); + // Unroutable base URL: if validation didn't happen first, this would + // fail with a network error instead of a usage error. + let client = SlackClient::with_base_url(token, "http://127.0.0.1:1".to_string()).unwrap(); + let err = client + .api_request( + "https://evil.com/api/auth.test", + reqwest::Method::GET, + &serde_json::Value::Null, + ) + .await + .unwrap_err(); + assert!(matches!(err, SlackError::Usage(_)), "got {:?}", err); + } + + #[tokio::test] + async fn test_api_request_rejects_unsupported_http_method() { + let token = create_test_token(TokenType::UserOAuth); + let client = SlackClient::with_base_url(token, "http://127.0.0.1:1".to_string()).unwrap(); + for method in [ + reqwest::Method::PUT, + reqwest::Method::DELETE, + reqwest::Method::PATCH, + reqwest::Method::HEAD, + ] { + let err = client + .api_request("auth.test", method.clone(), &serde_json::Value::Null) + .await + .unwrap_err(); + assert!( + matches!(err, SlackError::Usage(_)), + "method {} should be rejected, got {:?}", + method, + err + ); + } + } + + #[tokio::test] + async fn test_api_request_rejects_non_object_params() { + let token = create_test_token(TokenType::UserOAuth); + let client = SlackClient::with_base_url(token, "http://127.0.0.1:1".to_string()).unwrap(); + for params in [ + serde_json::json!([1, 2]), + serde_json::json!("str"), + serde_json::json!(42), + serde_json::json!(true), + ] { + let err = client + .api_request("auth.test", reqwest::Method::POST, ¶ms) + .await + .unwrap_err(); + assert!(matches!(err, SlackError::Usage(_)), "got {:?}", err); + } + } + + fn mock_tests_enabled() -> bool { + if std::env::var("SLACK_RUN_MOCK_TESTS").unwrap_or_default() != "1" { + eprintln!("Skipping mock test (set SLACK_RUN_MOCK_TESTS=1 to run)"); + false + } else { + true + } + } + + #[tokio::test] + async fn test_api_request_get_encodes_query_params() { + if !mock_tests_enabled() { + return; + } + use mockito::{Matcher, Server}; + + let mut server = Server::new_async().await; + let m = server + .mock("GET", "/conversations.history") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), "C123".into()), + Matcher::UrlEncoded("limit".into(), "5".into()), + Matcher::UrlEncoded("inclusive".into(), "true".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok": true, "messages": []}"#) + .create_async() + .await; + + let token = create_test_token(TokenType::UserOAuth); + let client = SlackClient::with_base_url(token, server.url()).unwrap(); + + let params = serde_json::json!({"channel": "C123", "limit": 5, "inclusive": true}); + let value = client + .api_request("conversations.history", reqwest::Method::GET, ¶ms) + .await + .unwrap(); + + assert_eq!(value["ok"], serde_json::json!(true)); + m.assert_async().await; + } + + #[tokio::test] + async fn test_api_request_post_form_encodes_nested_json_and_skips_null() { + if !mock_tests_enabled() { + return; + } + use mockito::{Matcher, Server}; + + let mut server = Server::new_async().await; + let m = server + .mock("POST", "/chat.postMessage") + .match_header( + "content-type", + Matcher::Regex("application/x-www-form-urlencoded".to_string()), + ) + .match_header("authorization", Matcher::Regex("Bearer xoxp-".to_string())) + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), "C123".into()), + Matcher::UrlEncoded("blocks".into(), r#"[{"type":"section"}]"#.into()), + Matcher::UrlEncoded("count".into(), "3".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok": true, "ts": "1.2"}"#) + .create_async() + .await; + + let token = create_test_token(TokenType::UserOAuth); + let client = SlackClient::with_base_url(token, server.url()).unwrap(); + + let params = serde_json::json!({ + "channel": "C123", + "blocks": [{"type": "section"}], + "count": 3, + "thread_ts": null + }); + let value = client + .api_request("chat.postMessage", reqwest::Method::POST, ¶ms) + .await + .unwrap(); + + assert_eq!(value["ts"], serde_json::json!("1.2")); + m.assert_async().await; + } + + #[tokio::test] + async fn test_api_request_slack_error_maps_to_api_error() { + if !mock_tests_enabled() { + return; + } + use mockito::Server; + + let mut server = Server::new_async().await; + let _m = server + .mock("POST", "/chat.postMessage") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"ok": false, "error": "channel_not_found", + "response_metadata": {"messages": ["[ERROR] no such channel"]}}"#, + ) + .create_async() + .await; + + let token = create_test_token(TokenType::UserOAuth); + let client = SlackClient::with_base_url(token, server.url()).unwrap(); + + let err = client + .api_request( + "chat.postMessage", + reqwest::Method::POST, + &serde_json::json!({"channel": "C404"}), + ) + .await + .unwrap_err(); + + match err { + SlackError::Api { error, detail } => { + assert_eq!(error, "channel_not_found"); + assert!(detail.unwrap().contains("no such channel")); + } + other => panic!("expected Api error, got {:?}", other), + } + } + + #[tokio::test] + async fn test_api_request_http_error_without_ok_field() { + if !mock_tests_enabled() { + return; + } + use mockito::Server; + + let mut server = Server::new_async().await; + let _m = server + .mock("GET", "/auth.test") + .with_status(502) + .with_header("content-type", "application/json") + .with_body(r#"{"message": "bad gateway"}"#) + .expect(1) // 5xx must NOT be retried + .create_async() + .await; + + let token = create_test_token(TokenType::UserOAuth); + let client = SlackClient::with_base_url(token, server.url()).unwrap(); + + let err = client + .api_request("auth.test", reqwest::Method::GET, &serde_json::Value::Null) + .await + .unwrap_err(); + + match err { + SlackError::Api { error, .. } => assert!(error.contains("502"), "got {}", error), + other => panic!("expected Api error, got {:?}", other), + } + server.reset(); + } + + #[tokio::test] + async fn test_api_request_malformed_json_response() { + if !mock_tests_enabled() { + return; + } + use mockito::Server; + + let mut server = Server::new_async().await; + let _m = server + .mock("GET", "/auth.test") + .with_status(200) + .with_body("not json") + .create_async() + .await; + + let token = create_test_token(TokenType::UserOAuth); + let client = SlackClient::with_base_url(token, server.url()).unwrap(); + + let err = client + .api_request("auth.test", reqwest::Method::GET, &serde_json::Value::Null) + .await + .unwrap_err(); + + match err { + SlackError::Api { error, .. } => assert_eq!(error, "invalid_response"), + other => panic!("expected Api error, got {:?}", other), + } + } + + #[tokio::test] + async fn test_api_request_does_not_follow_redirects() { + if !mock_tests_enabled() { + return; + } + use mockito::Server; + + // "Attacker" server that must never receive our credentials. + let mut attacker = Server::new_async().await; + let leak = attacker + .mock("GET", "/steal") + .with_status(200) + .with_body(r#"{"ok": true}"#) + .expect(0) + .create_async() + .await; + + let mut server = Server::new_async().await; + let _m = server + .mock("GET", "/auth.test") + .with_status(302) + .with_header("location", &format!("{}/steal", attacker.url())) + .create_async() + .await; + + let token = create_test_token(TokenType::Browser); + let client = SlackClient::with_base_url(token, server.url()).unwrap(); + + let err = client + .api_request("auth.test", reqwest::Method::GET, &serde_json::Value::Null) + .await + .unwrap_err(); + + match err { + SlackError::Api { error, detail } => { + assert!(error.contains("302"), "got {}", error); + assert!(detail.unwrap().contains("redirect")); + } + other => panic!("expected Api error, got {:?}", other), + } + + // The redirect target must never have been contacted. + leak.assert_async().await; + } + + #[tokio::test] + async fn test_api_request_bounded_429_retries() { + if !mock_tests_enabled() { + return; + } + use mockito::Server; + + let mut server = Server::new_async().await; + // Always 429 with a tiny retry-after; after MAX_RETRIES the client + // must give up with RateLimited (MAX_RETRIES + 1 total requests). + let m = server + .mock("POST", "/chat.postMessage") + .with_status(429) + .with_header("retry-after", "0") + .expect((MAX_RETRIES + 1) as usize) + .create_async() + .await; + + let token = create_test_token(TokenType::UserOAuth); + let client = SlackClient::with_base_url(token, server.url()).unwrap(); + + let err = client + .api_request( + "chat.postMessage", + reqwest::Method::POST, + &serde_json::json!({"channel": "C123"}), + ) + .await + .unwrap_err(); + + assert!(matches!(err, SlackError::RateLimited(_)), "got {:?}", err); + m.assert_async().await; + } + + #[tokio::test] + async fn test_api_request_browser_token_sends_cookie() { + if !mock_tests_enabled() { + return; + } + use mockito::{Matcher, Server}; + + let mut server = Server::new_async().await; + let m = server + .mock("GET", "/auth.test") + .match_header("authorization", Matcher::Regex("Bearer xoxc-".to_string())) + .match_header("cookie", Matcher::Regex("d=xoxd-test-cookie".to_string())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok": true, "team_id": "T12345"}"#) + .create_async() + .await; + + let token = create_test_token(TokenType::Browser); + let client = SlackClient::with_base_url(token, server.url()).unwrap(); + + let value = client + .api_request("auth.test", reqwest::Method::GET, &serde_json::Value::Null) + .await + .unwrap(); + + assert_eq!(value["team_id"], serde_json::json!("T12345")); + m.assert_async().await; + } + + #[tokio::test] + async fn test_api_request_full_url_normalized_to_base_url() { + if !mock_tests_enabled() { + return; + } + use mockito::Server; + + // Passing the canonical https://slack.com/api/ URL must still + // hit the configured (mock) base URL, proving normalization to a bare + // method name. + let mut server = Server::new_async().await; + let m = server + .mock("GET", "/auth.test") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok": true}"#) + .create_async() + .await; + + let token = create_test_token(TokenType::UserOAuth); + let client = SlackClient::with_base_url(token, server.url()).unwrap(); + + let value = client + .api_request( + "https://slack.com/api/auth.test", + reqwest::Method::GET, + &serde_json::Value::Null, + ) + .await + .unwrap(); + + assert_eq!(value["ok"], serde_json::json!(true)); + m.assert_async().await; + } + #[tokio::test] async fn test_rate_limiter_integration() { let token = create_test_token(TokenType::UserOAuth); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index d10654c..795a50e 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -5,6 +5,7 @@ pub mod browser; pub mod extract; pub mod oauth; +mod resolve; mod storage; pub mod store; mod tokens; @@ -15,6 +16,7 @@ pub use extract::{ ExtractedWorkspace, }; pub use oauth::{OAuthConfig, OAuthFlow, DEFAULT_SCOPES}; +pub use resolve::resolve_token; pub use storage::{KeyringStore, WorkspaceInfo}; pub use store::{ get_token_store, FileTokenStore, KeyringTokenStore, TokenStore, TOKEN_STORE_PATH_ENV, diff --git a/src/auth/resolve.rs b/src/auth/resolve.rs new file mode 100644 index 0000000..3a185db --- /dev/null +++ b/src/auth/resolve.rs @@ -0,0 +1,128 @@ +//! Shared token resolution for CLI commands +//! +//! Resolves the authentication token to use for an API call, honoring (in +//! order of precedence): +//! 1. An explicit `--token` override (xoxp-/xoxb- only; browser tokens are +//! rejected because they also require the xoxd cookie via `auth add`) +//! 2. A `-w/--workspace` selection matched against stored workspaces +//! 3. The default (or first) stored workspace + +use crate::auth::{get_token_store, workspace_matches, TokenSet, TokenType}; +use crate::error::{Result, SlackError}; + +/// Resolve the authentication token for a command invocation. +/// +/// `token_override` takes precedence over `workspace`. Browser tokens +/// (xoxc-*) cannot be supplied as overrides because they require the paired +/// xoxd cookie, which is only available via `auth add`. +pub fn resolve_token(workspace: Option<&str>, token_override: Option<&str>) -> Result { + if let Some(token_str) = token_override { + let token_type = TokenType::from_prefix(token_str).ok_or_else(|| { + SlackError::InvalidToken("Token must start with xoxp-, xoxb-, or xoxc-".into()) + })?; + + if token_type == TokenType::Browser { + return Err(SlackError::InvalidToken( + "Browser tokens require --xoxc and --xoxd flags in 'auth add'".into(), + )); + } + + TokenSet::new_oauth( + token_str.to_string(), + "unknown".into(), + "unknown".into(), + "unknown".into(), + vec![], + ) + } else { + let store = get_token_store(); + + if let Some(ws_name) = workspace { + let workspaces = store.get_workspace_info()?; + let ws = workspaces + .iter() + .find(|w| workspace_matches(ws_name, &w.team_id, w.team_domain.as_deref())) + .ok_or_else(|| SlackError::WorkspaceNotFound(ws_name.to_string()))?; + store + .get_token(&ws.team_id)? + .ok_or(SlackError::AuthRequired) + } else { + store + .get_default_or_first()? + .ok_or(SlackError::AuthRequired) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // These tests only exercise the `token_override` code path, which never + // touches the token store or environment, so no process-wide env + // mutations are needed. + + #[test] + fn test_override_user_oauth_token() { + let token = resolve_token(None, Some("xoxp-1234567890-abcdef")).unwrap(); + assert_eq!(token.token_type, TokenType::UserOAuth); + assert_eq!(token.access_token, "xoxp-1234567890-abcdef"); + assert_eq!(token.team_id, "unknown"); + assert_eq!(token.team_name, "unknown"); + assert_eq!(token.user_id, "unknown"); + assert!(token.scopes.is_empty()); + assert!(token.xoxd_cookie.is_none()); + } + + #[test] + fn test_override_bot_oauth_token() { + let token = resolve_token(None, Some("xoxb-1234567890-abcdef")).unwrap(); + assert_eq!(token.token_type, TokenType::BotOAuth); + assert_eq!(token.access_token, "xoxb-1234567890-abcdef"); + } + + #[test] + fn test_override_takes_precedence_over_workspace() { + // Even with a workspace selection, the override wins and the store is + // never consulted (a nonexistent workspace would otherwise error). + let token = resolve_token( + Some("definitely-not-a-real-workspace"), + Some("xoxp-1234567890-abcdef"), + ) + .unwrap(); + assert_eq!(token.access_token, "xoxp-1234567890-abcdef"); + } + + #[test] + fn test_override_browser_token_rejected() { + let err = resolve_token(None, Some("xoxc-1234567890-abcdef")).unwrap_err(); + match err { + SlackError::InvalidToken(msg) => { + assert!(msg.contains("--xoxc and --xoxd"), "unexpected msg: {}", msg); + } + other => panic!("Expected InvalidToken, got {:?}", other), + } + } + + #[test] + fn test_override_invalid_prefix_rejected() { + let err = resolve_token(None, Some("not-a-token")).unwrap_err(); + match err { + SlackError::InvalidToken(msg) => { + assert!( + msg.contains("xoxp-, xoxb-, or xoxc-"), + "unexpected msg: {}", + msg + ); + } + other => panic!("Expected InvalidToken, got {:?}", other), + } + } + + #[test] + fn test_override_malformed_token_rejected() { + // Valid prefix but invalid characters fails TokenSet validation. + let err = resolve_token(None, Some("xoxp-bad token!")).unwrap_err(); + assert!(matches!(err, SlackError::InvalidToken(_))); + } +} diff --git a/src/cli/api.rs b/src/cli/api.rs new file mode 100644 index 0000000..ae0a000 --- /dev/null +++ b/src/cli/api.rs @@ -0,0 +1,474 @@ +//! Generic Slack API escape hatch for Slack CLI +//! +//! `slack api ` makes an authenticated request to any Slack Web API +//! method, printing the full JSON response. Modeled after `gh api`. + +use clap::Args; + +/// Generic Slack API request command +#[derive(Args, Debug)] +#[command(after_help = "Examples:\n \ + slack api conversations.list -X GET -f limit=10\n \ + slack api chat.postMessage -f channel=C123456 -f text='hello'\n \ + slack api chat.postMessage --input payload.json\n \ + echo '{\"channel\":\"C123456\",\"text\":\"hi\"}' | slack api chat.postMessage --input -\n\n\ + Parameters are sent as query parameters for GET and form-encoded for POST.\n\ + Nested JSON values (from -F, or --input) are serialized as JSON strings,\n\ + matching how the built-in commands encode parameters.")] +pub struct ApiCmd { + /// Slack API method name (e.g. "chat.postMessage") or a full + /// `https://slack.com/api/` URL + pub endpoint: String, + + /// HTTP method to use (GET or POST) + #[arg( + short = 'X', + long, + value_name = "METHOD", + default_value = "POST", + value_parser = parse_method + )] + pub method: String, + + /// Add a string parameter as key=value (value is always sent as a string; + /// repeatable) + #[arg(short = 'f', long = "raw-field", value_name = "KEY=VALUE")] + pub raw_fields: Vec, + + /// Add a typed parameter as key=value: JSON booleans, numbers, arrays and + /// objects are parsed; anything else is sent as a string. A literal + /// "null" is rejected (Slack's form encoding omits null values); use -f + /// to send the string "null", or omit the field entirely. Repeatable. + #[arg(short = 'F', long = "field", value_name = "KEY=VALUE")] + pub fields: Vec, + + /// Read parameters from FILE containing a JSON object ("-" reads stdin). + /// Cannot be combined with -f/-F. Note: null-valued fields in the JSON + /// object are omitted from the request (the form encoder skips nulls). + #[arg( + long, + value_name = "FILE", + conflicts_with_all = ["raw_fields", "fields"] + )] + pub input: Option, +} + +/// Run the api command +pub async fn run( + cmd: &ApiCmd, + plain: bool, + workspace: Option<&str>, + token_override: Option<&str>, +) -> crate::error::Result<()> { + use crate::api::SlackClient; + use crate::error::SlackError; + use crate::output::write_json; + + if plain { + return Err(SlackError::Usage( + "'slack api' does not support --plain; the full JSON response is always printed".into(), + )); + } + + let method = parse_method(&cmd.method).map_err(SlackError::Usage)?; + let http_method = if method == "GET" { + reqwest::Method::GET + } else { + reqwest::Method::POST + }; + + let params = if let Some(input) = &cmd.input { + read_input(input)? + } else { + build_params(&cmd.raw_fields, &cmd.fields)? + }; + + let token = crate::auth::resolve_token(workspace, token_override)?; + let client = SlackClient::new(token)?; + + let response = client + .api_request(&cmd.endpoint, http_method, ¶ms) + .await?; + + write_json(&response)?; + Ok(()) +} + +/// Validate and normalize the HTTP method flag (GET/POST only) +fn parse_method(s: &str) -> Result { + let upper = s.to_ascii_uppercase(); + match upper.as_str() { + "GET" | "POST" => Ok(upper), + _ => Err(format!( + "unsupported HTTP method '{s}': only GET and POST are supported" + )), + } +} + +/// Split a KEY=VALUE argument into its parts +fn split_field(arg: &str) -> crate::error::Result<(&str, &str)> { + use crate::error::SlackError; + + let (key, value) = arg.split_once('=').ok_or_else(|| { + SlackError::Usage(format!("invalid field '{arg}': expected KEY=VALUE format")) + })?; + if key.is_empty() { + return Err(SlackError::Usage(format!( + "invalid field '{arg}': key must not be empty" + ))); + } + Ok((key, value)) +} + +/// Parse a typed (-F/--field) value: JSON booleans/numbers/arrays/objects are +/// parsed, null is rejected, anything else stays a string. +fn parse_typed_value(key: &str, raw: &str) -> crate::error::Result { + use crate::error::SlackError; + + match serde_json::from_str::(raw) { + Ok(serde_json::Value::Null) => Err(SlackError::Usage(format!( + "field '{key}' has value null, which cannot be sent (form encoding omits nulls); \ + omit the field, or use -f {key}=null to send the string \"null\"" + ))), + Ok(value) => Ok(value), + Err(_) => Ok(serde_json::Value::String(raw.to_string())), + } +} + +/// Build the request parameter object from -f/--raw-field and -F/--field args +fn build_params( + raw_fields: &[String], + fields: &[String], +) -> crate::error::Result { + use crate::error::SlackError; + + let mut map = serde_json::Map::new(); + + for arg in raw_fields { + let (key, value) = split_field(arg)?; + if map + .insert( + key.to_string(), + serde_json::Value::String(value.to_string()), + ) + .is_some() + { + return Err(SlackError::Usage(format!( + "duplicate field key '{key}': each key may only be specified once" + ))); + } + } + + for arg in fields { + let (key, value) = split_field(arg)?; + let parsed = parse_typed_value(key, value)?; + if map.insert(key.to_string(), parsed).is_some() { + return Err(SlackError::Usage(format!( + "duplicate field key '{key}': each key may only be specified once" + ))); + } + } + + Ok(serde_json::Value::Object(map)) +} + +/// Read request parameters from a file (or stdin when "-"), requiring a JSON +/// object at the top level. +fn read_input(path: &str) -> crate::error::Result { + use crate::error::SlackError; + use std::io::Read; + + let contents = if path == "-" { + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + buf + } else { + std::fs::read_to_string(path)? + }; + + let value: serde_json::Value = serde_json::from_str(&contents) + .map_err(|e| SlackError::Usage(format!("--input is not valid JSON: {e}")))?; + + if !value.is_object() { + return Err(SlackError::Usage( + "--input must contain a JSON object of parameters (e.g. {\"channel\": \"C123\"})" + .into(), + )); + } + + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::{Cli, Commands}; + use crate::error::SlackError; + use clap::{CommandFactory, Parser}; + use serde_json::json; + + #[test] + fn test_api_cmd_valid() { + Cli::command().debug_assert(); + } + + // --- clap parsing --- + + fn parse_api(args: &[&str]) -> ApiCmd { + let mut full = vec!["slack", "api"]; + full.extend_from_slice(args); + let cli = Cli::try_parse_from(full).unwrap(); + match cli.command { + Commands::Api(cmd) => cmd, + _ => panic!("Expected Api command"), + } + } + + #[test] + fn test_parse_defaults_to_post() { + let cmd = parse_api(&["chat.postMessage"]); + assert_eq!(cmd.endpoint, "chat.postMessage"); + assert_eq!(cmd.method, "POST"); + assert!(cmd.raw_fields.is_empty()); + assert!(cmd.fields.is_empty()); + assert!(cmd.input.is_none()); + } + + #[test] + fn test_parse_get_method_case_insensitive() { + let cmd = parse_api(&["conversations.list", "-X", "get"]); + assert_eq!(cmd.method, "GET"); + let cmd = parse_api(&["conversations.list", "--method", "GET"]); + assert_eq!(cmd.method, "GET"); + } + + #[test] + fn test_parse_rejects_other_methods() { + for method in ["PUT", "DELETE", "PATCH", "HEAD", "bogus"] { + let result = Cli::try_parse_from(["slack", "api", "users.list", "-X", method]); + assert!(result.is_err(), "method {method} should be rejected"); + } + } + + #[test] + fn test_parse_fields_repeatable() { + let cmd = parse_api(&[ + "chat.postMessage", + "-f", + "channel=C123", + "--raw-field", + "text=hello", + "-F", + "limit=10", + "--field", + "extra=true", + ]); + assert_eq!(cmd.raw_fields, vec!["channel=C123", "text=hello"]); + assert_eq!(cmd.fields, vec!["limit=10", "extra=true"]); + } + + #[test] + fn test_parse_rejects_input_mixed_with_fields() { + let result = Cli::try_parse_from([ + "slack", + "api", + "chat.postMessage", + "--input", + "params.json", + "-f", + "text=hi", + ]); + assert!(result.is_err()); + + let result = Cli::try_parse_from([ + "slack", + "api", + "chat.postMessage", + "--input", + "-", + "-F", + "limit=10", + ]); + assert!(result.is_err()); + } + + #[test] + fn test_parse_requires_endpoint() { + let result = Cli::try_parse_from(["slack", "api"]); + assert!(result.is_err()); + } + + // --- split_field --- + + #[test] + fn test_split_field_basic() { + assert_eq!(split_field("key=value").unwrap(), ("key", "value")); + // Only the first '=' splits; values may contain '=' + assert_eq!(split_field("k=a=b").unwrap(), ("k", "a=b")); + // Empty values are allowed + assert_eq!(split_field("k=").unwrap(), ("k", "")); + } + + #[test] + fn test_split_field_rejects_missing_separator() { + let err = split_field("noequals").unwrap_err(); + assert!(matches!(err, SlackError::Usage(_))); + } + + #[test] + fn test_split_field_rejects_empty_key() { + let err = split_field("=value").unwrap_err(); + assert!(matches!(err, SlackError::Usage(_))); + } + + // --- parse_typed_value --- + + #[test] + fn test_typed_value_booleans_and_numbers() { + assert_eq!(parse_typed_value("k", "true").unwrap(), json!(true)); + assert_eq!(parse_typed_value("k", "false").unwrap(), json!(false)); + assert_eq!(parse_typed_value("k", "42").unwrap(), json!(42)); + assert_eq!(parse_typed_value("k", "-1.5").unwrap(), json!(-1.5)); + } + + #[test] + fn test_typed_value_arrays_and_objects() { + assert_eq!(parse_typed_value("k", "[1,2,3]").unwrap(), json!([1, 2, 3])); + assert_eq!( + parse_typed_value("k", r#"{"a":{"b":true}}"#).unwrap(), + json!({"a": {"b": true}}) + ); + } + + #[test] + fn test_typed_value_falls_back_to_string() { + assert_eq!(parse_typed_value("k", "hello").unwrap(), json!("hello")); + // Invalid JSON stays a string, predictably + assert_eq!(parse_typed_value("k", "007").unwrap(), json!("007")); + assert_eq!(parse_typed_value("k", "[1,2").unwrap(), json!("[1,2")); + // Quoted JSON strings parse to the unquoted string + assert_eq!(parse_typed_value("k", "\"null\"").unwrap(), json!("null")); + } + + #[test] + fn test_typed_value_rejects_null() { + let err = parse_typed_value("k", "null").unwrap_err(); + match err { + SlackError::Usage(msg) => { + assert!(msg.contains("null"), "message should mention null: {msg}"); + } + other => panic!("Expected Usage error, got {other:?}"), + } + } + + // --- build_params --- + + #[test] + fn test_build_params_combines_raw_and_typed() { + let params = build_params( + &["channel=C123".into(), "text=hi there".into()], + &["limit=10".into(), "unfurl=false".into()], + ) + .unwrap(); + assert_eq!( + params, + json!({ + "channel": "C123", + "text": "hi there", + "limit": 10, + "unfurl": false, + }) + ); + } + + #[test] + fn test_build_params_raw_field_never_parses_json() { + let params = build_params(&["count=10".into(), "flag=true".into()], &[]).unwrap(); + assert_eq!(params, json!({"count": "10", "flag": "true"})); + } + + #[test] + fn test_build_params_empty() { + let params = build_params(&[], &[]).unwrap(); + assert_eq!(params, json!({})); + } + + #[test] + fn test_build_params_rejects_duplicate_keys() { + // Duplicate within raw fields + let err = build_params(&["a=1".into(), "a=2".into()], &[]).unwrap_err(); + assert!(matches!(err, SlackError::Usage(_))); + + // Duplicate within typed fields + let err = build_params(&[], &["a=1".into(), "a=2".into()]).unwrap_err(); + assert!(matches!(err, SlackError::Usage(_))); + + // Duplicate across raw and typed fields + let err = build_params(&["a=1".into()], &["a=2".into()]).unwrap_err(); + assert!(matches!(err, SlackError::Usage(_))); + } + + // --- read_input --- + + #[test] + fn test_read_input_valid_object() { + let dir = std::env::temp_dir(); + let path = dir.join(format!("slack_cli_api_test_{}.json", std::process::id())); + std::fs::write(&path, r#"{"channel": "C123", "text": "hi"}"#).unwrap(); + let params = read_input(path.to_str().unwrap()).unwrap(); + assert_eq!(params, json!({"channel": "C123", "text": "hi"})); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_read_input_rejects_non_object() { + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "slack_cli_api_test_arr_{}.json", + std::process::id() + )); + std::fs::write(&path, r#"[1, 2, 3]"#).unwrap(); + let err = read_input(path.to_str().unwrap()).unwrap_err(); + assert!(matches!(err, SlackError::Usage(_))); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_read_input_rejects_invalid_json() { + let dir = std::env::temp_dir(); + let path = dir.join(format!( + "slack_cli_api_test_bad_{}.json", + std::process::id() + )); + std::fs::write(&path, "not json").unwrap(); + let err = read_input(path.to_str().unwrap()).unwrap_err(); + assert!(matches!(err, SlackError::Usage(_))); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_read_input_missing_file_is_io_error() { + let err = read_input("/nonexistent/definitely-missing.json").unwrap_err(); + assert!(matches!(err, SlackError::Io(_))); + } + + // --- run guards --- + + #[tokio::test] + async fn test_run_rejects_plain() { + let cmd = parse_api(&["auth.test"]); + let err = run(&cmd, true, None, None).await.unwrap_err(); + match err { + SlackError::Usage(msg) => assert!(msg.contains("--plain")), + other => panic!("Expected Usage error, got {other:?}"), + } + } + + #[test] + fn test_parse_method_validator() { + assert_eq!(parse_method("get").unwrap(), "GET"); + assert_eq!(parse_method("Post").unwrap(), "POST"); + assert!(parse_method("PUT").is_err()); + assert!(parse_method("").is_err()); + } +} diff --git a/src/cli/messages.rs b/src/cli/messages.rs index 4759692..62cb595 100644 --- a/src/cli/messages.rs +++ b/src/cli/messages.rs @@ -159,7 +159,7 @@ pub async fn run( let output_mode = OutputMode::from_flags(plain); // Get the token - let token = get_token(workspace, token_override)?; + let token = crate::auth::resolve_token(workspace, token_override)?; let client = SlackClient::new(token)?; match &cmd.command { @@ -258,53 +258,6 @@ pub async fn run( Ok(()) } -/// Get the authentication token -fn get_token( - workspace: Option<&str>, - token_override: Option<&str>, -) -> Result { - use crate::auth::{get_token_store, TokenSet, TokenType}; - - if let Some(token_str) = token_override { - let token_type = TokenType::from_prefix(token_str).ok_or_else(|| { - SlackError::InvalidToken("Token must start with xoxp-, xoxb-, or xoxc-".into()) - })?; - - if token_type == TokenType::Browser { - return Err(SlackError::InvalidToken( - "Browser tokens require --xoxc and --xoxd flags in 'auth add'".into(), - )); - } - - TokenSet::new_oauth( - token_str.to_string(), - "unknown".into(), - "unknown".into(), - "unknown".into(), - vec![], - ) - } else { - let store = get_token_store(); - - if let Some(ws_name) = workspace { - let workspaces = store.get_workspace_info()?; - let ws = workspaces - .iter() - .find(|w| { - crate::auth::workspace_matches(ws_name, &w.team_id, w.team_domain.as_deref()) - }) - .ok_or_else(|| SlackError::WorkspaceNotFound(ws_name.to_string()))?; - store - .get_token(&ws.team_id)? - .ok_or(SlackError::AuthRequired) - } else { - store - .get_default_or_first()? - .ok_or(SlackError::AuthRequired) - } - } -} - /// List messages in a channel async fn list_messages( client: &SlackClient, diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0b418ae..649d3f3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -2,6 +2,7 @@ //! //! Contains command-line interface definitions and handlers. +pub mod api; pub mod auth; pub mod channels; pub mod completions; @@ -13,6 +14,7 @@ pub mod root; pub mod status; pub mod users; +pub use api::ApiCmd; pub use auth::AuthCmd; pub use channels::ChannelsCmd; pub use completions::{generate_completions, CompletionsArgs}; diff --git a/src/cli/root.rs b/src/cli/root.rs index 75dceba..86fbba0 100644 --- a/src/cli/root.rs +++ b/src/cli/root.rs @@ -4,6 +4,7 @@ use clap::{Parser, Subcommand}; +use super::api::ApiCmd; use super::auth::AuthCmd; use super::channels::ChannelsCmd; use super::completions::CompletionsArgs; @@ -81,6 +82,9 @@ pub enum Commands { /// Reminder operations Reminders(RemindersCmd), + /// Make an authenticated request to any Slack API method + Api(ApiCmd), + /// Generate shell completions Completions(CompletionsArgs), } diff --git a/src/main.rs b/src/main.rs index 68f1eff..6aebf51 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,8 @@ use clap::Parser; use tracing_subscriber::EnvFilter; use slack_cli::cli::{ - auth, channels, files, generate_completions, messages, reactions, reminders, status, users, - Cli, Commands, + api, auth, channels, files, generate_completions, messages, reactions, reminders, status, + users, Cli, Commands, }; use slack_cli::error::SlackError; use slack_cli::output::OutputMode; @@ -135,6 +135,15 @@ async fn run_command(cli: &Cli) -> Result<(), SlackError> { ) .await } + Commands::Api(cmd) => { + api::run( + cmd, + cli.plain, + cli.workspace.as_deref(), + cli.token.as_deref(), + ) + .await + } Commands::Completions(args) => { generate_completions(args.shell); Ok(()) diff --git a/tests/cli_api.rs b/tests/cli_api.rs new file mode 100644 index 0000000..4709c9a --- /dev/null +++ b/tests/cli_api.rs @@ -0,0 +1,844 @@ +//! End-to-end tests for the `slack api` escape hatch command. +//! +//! Runs the real binary via assert_cmd against a mockito mock Slack server +//! (via `SLACK_API_BASE_URL`) and a file-based token store (via +//! `SLACK_TOKEN_STORE_PATH`). Unlike the tests in tests/integration/, these +//! run under an ordinary `cargo test` with no environment gating. + +use assert_cmd::cargo::cargo_bin_cmd; +use assert_cmd::Command; +use mockito::{Matcher, ServerGuard}; +use predicates::prelude::*; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +const ENV_TOKEN: &str = "xoxp-env-token-1234567890"; +const WS1_TOKEN: &str = "xoxp-ws1-token-1234567890"; +const WS2_TOKEN: &str = "xoxp-ws2-token-1234567890"; +const XOXC_TOKEN: &str = "xoxc-browser-token-1234567890"; +const XOXD_COOKIE: &str = "xoxd-test-cookie-abc123"; + +async fn mock_server() -> ServerGuard { + mockito::Server::new_async().await +} + +/// Build a `slack` command isolated from the developer's real environment, +/// pointed at the given mock server and token store path. +fn slack_cmd(server_url: &str, store_path: &Path) -> Command { + let mut cmd = cargo_bin_cmd!("slack"); + cmd.env("SLACK_API_BASE_URL", server_url); + cmd.env("SLACK_TOKEN_STORE_PATH", store_path); + cmd.env_remove("SLACK_TOKEN"); + cmd.env_remove("SLACK_WORKSPACE"); + cmd.env_remove("SLACK_PLAIN"); + cmd +} + +/// A token store path that does not exist -> commands see "no auth". +fn empty_store(tmp: &TempDir) -> PathBuf { + tmp.path().join("no-tokens.json") +} + +fn oauth_token_json( + team_id: &str, + team_name: &str, + domain: &str, + token: &str, +) -> serde_json::Value { + serde_json::json!({ + "token_type": "user_o_auth", + "access_token": token, + "team_id": team_id, + "team_name": team_name, + "team_domain": domain, + "user_id": "U12345TEST", + "created_at": "2024-01-01T00:00:00Z", + "scopes": [] + }) +} + +/// Write a token store file with two OAuth workspaces (T_WS1 default) plus a +/// browser-token workspace (T_WS3, with xoxd cookie). +fn write_multi_store(tmp: &TempDir) -> PathBuf { + let path = tmp.path().join("tokens.json"); + let data = serde_json::json!({ + "tokens": { + "T_WS1": oauth_token_json("T_WS1", "Workspace One", "wsone", WS1_TOKEN), + "T_WS2": oauth_token_json("T_WS2", "Workspace Two", "wstwo", WS2_TOKEN), + "T_WS3": { + "token_type": "browser", + "access_token": XOXC_TOKEN, + "xoxd_cookie": XOXD_COOKIE, + "team_id": "T_WS3", + "team_name": "Workspace Three", + "team_domain": "wsthree", + "user_id": "U12345TEST", + "created_at": "2024-01-01T00:00:00Z", + "scopes": [] + } + }, + "default": "T_WS1", + "workspaces": ["T_WS1", "T_WS2", "T_WS3"] + }); + std::fs::write(&path, data.to_string()).expect("write token store"); + path +} + +// ============================================================================ +// Help / CLI surface +// ============================================================================ + +#[test] +fn test_api_help() { + let tmp = TempDir::new().unwrap(); + slack_cmd("http://127.0.0.1:1", &empty_store(&tmp)) + .args(["api", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--raw-field")) + .stdout(predicate::str::contains("--field")) + .stdout(predicate::str::contains("--input")) + .stdout(predicate::str::contains("--method")); +} + +// ============================================================================ +// Successful requests: method name, full URL, default POST +// ============================================================================ + +#[tokio::test] +async fn test_api_post_method_name_success_full_response() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + // Default method is POST; -f values are raw strings, form-encoded. + let mock = server + .mock("POST", "/chat.postMessage") + .match_header("authorization", format!("Bearer {}", ENV_TOKEN).as_str()) + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), "C123456".into()), + Matcher::UrlEncoded("text".into(), "hello world".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true,"channel":"C123456","ts":"111.222","extra":{"deep":[1,2,3]}}"#) + .create_async() + .await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args([ + "api", + "chat.postMessage", + "-f", + "channel=C123456", + "-f", + "text=hello world", + ]) + .assert() + .success() + // Full raw JSON response, including `ok` and unknown fields. + .stdout(predicate::str::contains("\"ok\": true")) + .stdout(predicate::str::contains("111.222")) + .stdout(predicate::str::contains("\"deep\"")); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_api_full_url_normalized_to_method_name() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + // A canonical https://slack.com/api/ URL is normalized to the + // bare method and issued against the configured (mock) base URL. + let mock = server + .mock("POST", "/auth.test") + .with_status(200) + .with_body(r#"{"ok":true,"team_id":"T_WS1"}"#) + .create_async() + .await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "https://slack.com/api/auth.test"]) + .assert() + .success() + .stdout(predicate::str::contains("\"ok\": true")); + + mock.assert_async().await; +} + +// ============================================================================ +// Field typing and form encoding +// ============================================================================ + +#[tokio::test] +async fn test_api_typed_fields_form_encoding() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + // -F parses JSON booleans/numbers/arrays/objects; nested values are sent + // as JSON strings (matching to_form_params); -f is always a raw string. + let mock = server + .mock("POST", "/chat.postMessage") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), "C123456".into()), + Matcher::UrlEncoded("count".into(), "42".into()), + Matcher::UrlEncoded("flag".into(), "true".into()), + Matcher::UrlEncoded("blocks".into(), r#"[{"type":"divider"}]"#.into()), + Matcher::UrlEncoded("meta".into(), r#"{"a":1}"#.into()), + Matcher::UrlEncoded("note".into(), "plain text".into()), + ])) + .with_status(200) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args([ + "api", + "chat.postMessage", + "-f", + "channel=C123456", + "-F", + "count=42", + "-F", + "flag=true", + "-F", + r#"blocks=[{"type":"divider"}]"#, + "-F", + r#"meta={"a":1}"#, + "-F", + "note=plain text", + ]) + .assert() + .success(); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_api_raw_field_never_parses_json() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + // -f "count=10" must be sent as the exact string; body is form-encoded + // with keys in sorted order (serde_json object ordering). + let mock = server + .mock("POST", "/some.method") + .match_body(Matcher::Exact("count=10&flag=true".to_string())) + .with_status(200) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "some.method", "-f", "count=10", "-f", "flag=true"]) + .assert() + .success(); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_api_typed_null_field_rejected_before_request() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let catch_all = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + // -F key=null has explicit, predictable handling: rejected as a usage + // error (form encoding omits nulls) before any request is made. + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "chat.postMessage", "-F", "text=null"]) + .assert() + .failure() + .code(2) + .stdout(predicate::str::contains("usage_error")) + .stdout(predicate::str::contains("null")); + + catch_all.assert_async().await; +} + +// ============================================================================ +// GET requests: query parameters +// ============================================================================ + +#[tokio::test] +async fn test_api_get_sends_query_params() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let mock = server + .mock("GET", "/conversations.list") + .match_query(Matcher::AllOf(vec![ + Matcher::UrlEncoded("limit".into(), "2".into()), + Matcher::UrlEncoded("cursor".into(), "abc123".into()), + ])) + .match_header("authorization", format!("Bearer {}", ENV_TOKEN).as_str()) + .with_status(200) + .with_body(r#"{"ok":true,"channels":[]}"#) + .create_async() + .await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args([ + "api", + "conversations.list", + "-X", + "GET", + "-F", + "limit=2", + "-f", + "cursor=abc123", + ]) + .assert() + .success() + .stdout(predicate::str::contains("\"channels\"")); + + mock.assert_async().await; +} + +// ============================================================================ +// --input: file, stdin, validation +// ============================================================================ + +#[tokio::test] +async fn test_api_input_file() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let input_path = tmp.path().join("params.json"); + // A null-valued field in the input object is omitted from the body + // (form encoding skips nulls) -- exact-match body asserts its absence. + std::fs::write( + &input_path, + r#"{"channel":"C9CHANNEL","note":null,"text":"from file"}"#, + ) + .unwrap(); + + let mock = server + .mock("POST", "/chat.postMessage") + .match_body(Matcher::Exact( + "channel=C9CHANNEL&text=from+file".to_string(), + )) + .with_status(200) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args([ + "api", + "chat.postMessage", + "--input", + input_path.to_str().unwrap(), + ]) + .assert() + .success(); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_api_input_stdin() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let mock = server + .mock("POST", "/chat.postMessage") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), "C9CHANNEL".into()), + Matcher::UrlEncoded("text".into(), "from stdin".into()), + // Nested JSON in the input object is sent as a JSON string. + Matcher::UrlEncoded("blocks".into(), r#"[{"type":"divider"}]"#.into()), + ])) + .with_status(200) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "chat.postMessage", "--input", "-"]) + .write_stdin(r#"{"channel":"C9CHANNEL","text":"from stdin","blocks":[{"type":"divider"}]}"#) + .assert() + .success(); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_api_input_rejects_non_object_before_request() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let catch_all = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "chat.postMessage", "--input", "-"]) + .write_stdin("[1, 2, 3]") + .assert() + .failure() + .code(2) + .stdout(predicate::str::contains("usage_error")); + + catch_all.assert_async().await; +} + +#[tokio::test] +async fn test_api_rejects_mixing_input_and_fields() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let catch_all = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + // --input with -f + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args([ + "api", + "chat.postMessage", + "--input", + "params.json", + "-f", + "text=hi", + ]) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used with")); + + // --input with -F + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "chat.postMessage", "--input", "-", "-F", "limit=10"]) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used with")); + + catch_all.assert_async().await; +} + +// ============================================================================ +// Invalid arguments rejected before any request +// ============================================================================ + +#[tokio::test] +async fn test_api_rejects_plain() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let catch_all = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + // In --plain mode errors go to stderr; usage errors exit 2. + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "auth.test", "--plain"]) + .assert() + .failure() + .code(2) + .stderr(predicate::str::contains("--plain")); + + catch_all.assert_async().await; +} + +#[tokio::test] +async fn test_api_rejects_unsupported_http_methods() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let catch_all = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + for method in ["DELETE", "PUT", "PATCH", "HEAD"] { + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "users.list", "-X", method]) + .assert() + .failure() + .stderr(predicate::str::contains("only GET and POST")); + } + + catch_all.assert_async().await; +} + +#[tokio::test] +async fn test_api_rejects_invalid_field_syntax() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let catch_all = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + // Missing '=' separator + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "auth.test", "-f", "noequals"]) + .assert() + .failure() + .code(2) + .stdout(predicate::str::contains("usage_error")); + + // Duplicate keys + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "auth.test", "-f", "a=1", "-F", "a=2"]) + .assert() + .failure() + .code(2) + .stdout(predicate::str::contains("usage_error")); + + catch_all.assert_async().await; +} + +#[tokio::test] +async fn test_api_rejects_unsafe_endpoints_before_request() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let catch_all_post = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + let catch_all_get = server + .mock("GET", Matcher::Any) + .expect(0) + .create_async() + .await; + + let unsafe_endpoints = [ + // Wrong host + "https://evil.com/api/auth.test", + // Subdomain is not exactly slack.com + "https://api.slack.com/api/auth.test", + // Not https + "http://slack.com/api/auth.test", + // Credentials in URL + "https://user:pass@slack.com/api/auth.test", + // Query string + "https://slack.com/api/auth.test?x=1", + // Fragment + "https://slack.com/api/auth.test#frag", + // Custom port + "https://slack.com:8443/api/auth.test", + // Path not under /api/ + "https://slack.com/auth.test", + // More than one method segment + "https://slack.com/api/auth.test/extra", + // Path traversal + "https://slack.com/api/../auth.test", + // Bare name with a path separator + "auth/test", + "../auth.test", + ]; + + for endpoint in unsafe_endpoints { + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", endpoint]) + .assert() + .failure() + .code(2) + .stdout(predicate::str::contains("usage_error")); + } + + catch_all_post.assert_async().await; + catch_all_get.assert_async().await; +} + +// ============================================================================ +// Error conventions: ok:false, HTTP failure, rate limits +// ============================================================================ + +#[tokio::test] +async fn test_api_slack_ok_false_is_api_error() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let mock = server + .mock("POST", "/chat.postMessage") + .with_status(200) + .with_body(r#"{"ok":false,"error":"channel_not_found"}"#) + .create_async() + .await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "chat.postMessage", "-f", "channel=C123456"]) + .assert() + .failure() + .code(1) + .stdout(predicate::str::contains("api_error")) + .stdout(predicate::str::contains("channel_not_found")); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_api_http_failure_is_api_error() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let mock = server + .mock("POST", "/auth.test") + .with_status(500) + .with_body("gateway on fire") + .create_async() + .await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "auth.test"]) + .assert() + .failure() + .code(1) + .stdout(predicate::str::contains("api_error")) + .stdout(predicate::str::contains("HTTP 500")); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_api_rate_limited() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + // Retry-After: 0 keeps retries instant; after bounded retries the CLI + // reports the standard rate_limited error. + let mock = server + .mock("POST", "/auth.test") + .with_status(429) + .with_header("retry-after", "0") + .with_body(r#"{"ok":false,"error":"ratelimited"}"#) + .expect_at_least(1) + .create_async() + .await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "auth.test"]) + .assert() + .failure() + .code(1) + .stdout(predicate::str::contains("rate_limited")); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_api_excessive_retry_delay_returns_without_retrying() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + let mock = server + .mock("POST", "/auth.test") + .with_status(429) + .with_header("retry-after", "18446744073709551615") + .expect(1) + .create_async() + .await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "auth.test"]) + .assert() + .code(1) + .stdout(predicate::str::contains("rate_limited")); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_api_does_not_follow_redirects() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let redirect = server + .mock("POST", "/auth.test") + .with_status(302) + .with_header("location", &format!("{}/leak", server.url())) + .create_async() + .await; + let leak_get = server.mock("GET", "/leak").expect(0).create_async().await; + let leak_post = server.mock("POST", "/leak").expect(0).create_async().await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "auth.test"]) + .assert() + .failure() + .code(1) + .stdout(predicate::str::contains("api_error")); + + redirect.assert_async().await; + leak_get.assert_async().await; + leak_post.assert_async().await; +} + +// ============================================================================ +// Auth: required, SLACK_TOKEN override, workspace selection, browser cookies +// ============================================================================ + +#[tokio::test] +async fn test_api_auth_required() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + + let catch_all = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + slack_cmd(&server.url(), &empty_store(&tmp)) + .args(["api", "auth.test"]) + .assert() + .failure() + .code(1) + .stdout(predicate::str::contains("auth_required")); + + catch_all.assert_async().await; +} + +#[tokio::test] +async fn test_api_token_env_overrides_store() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + let store = write_multi_store(&tmp); + + // SLACK_TOKEN takes precedence over the stored default workspace token. + let mock = server + .mock("POST", "/auth.test") + .match_header("authorization", format!("Bearer {}", ENV_TOKEN).as_str()) + .with_status(200) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + slack_cmd(&server.url(), &store) + .env("SLACK_TOKEN", ENV_TOKEN) + .args(["api", "auth.test"]) + .assert() + .success() + .stdout(predicate::str::contains("\"ok\": true")); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_api_uses_default_workspace_from_file_store() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + let store = write_multi_store(&tmp); + + let mock = server + .mock("POST", "/auth.test") + .match_header("authorization", format!("Bearer {}", WS1_TOKEN).as_str()) + .with_status(200) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + slack_cmd(&server.url(), &store) + .args(["api", "auth.test"]) + .assert() + .success(); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_api_workspace_flag_selects_stored_token() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + let store = write_multi_store(&tmp); + + let mock = server + .mock("POST", "/auth.test") + .match_header("authorization", format!("Bearer {}", WS2_TOKEN).as_str()) + .with_status(200) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + slack_cmd(&server.url(), &store) + .args(["-w", "T_WS2", "api", "auth.test"]) + .assert() + .success(); + + mock.assert_async().await; +} + +#[tokio::test] +async fn test_api_workspace_not_found() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + let store = write_multi_store(&tmp); + + let catch_all = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + slack_cmd(&server.url(), &store) + .args(["-w", "nosuchworkspace", "api", "auth.test"]) + .assert() + .failure() + .code(1) + .stdout(predicate::str::contains("workspace_not_found")); + + catch_all.assert_async().await; +} + +#[tokio::test] +async fn test_api_browser_token_sends_cookie() { + let mut server = mock_server().await; + let tmp = TempDir::new().unwrap(); + let store = write_multi_store(&tmp); + + // Browser (xoxc) workspace: bearer token plus the stored xoxd cookie. + let mock = server + .mock("POST", "/auth.test") + .match_header("authorization", format!("Bearer {}", XOXC_TOKEN).as_str()) + .match_header("cookie", format!("d={}", XOXD_COOKIE).as_str()) + .with_status(200) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + slack_cmd(&server.url(), &store) + .args(["-w", "T_WS3", "api", "auth.test"]) + .assert() + .success(); + + mock.assert_async().await; +}