From 82b80c5d29a66bc65cb637896ae69381692d4388 Mon Sep 17 00:00:00 2001 From: Chris Raethke Date: Wed, 9 Sep 2026 22:16:43 +1000 Subject: [PATCH 01/22] feat(identity-users): Identity resolution, direct messages, and user groups --- CHANGELOG.md | 6 + README.md | 16 +- skills/slack/SKILL.md | 19 +- skills/slack/USERS.md | 76 +++++++ src/api/identity_ops.rs | 142 ++++++++++++ src/api/mod.rs | 1 + src/api/resolve.rs | 72 +++++- src/cli/mod.rs | 1 + src/cli/usergroups.rs | 284 +++++++++++++++++++++++ src/cli/users.rs | 27 ++- tests/cli_identity_users.rs | 436 ++++++++++++++++++++++++++++++++++++ 11 files changed, 1068 insertions(+), 12 deletions(-) create mode 100644 skills/slack/USERS.md create mode 100644 src/api/identity_ops.rs create mode 100644 src/cli/usergroups.rs create mode 100644 tests/cli_identity_users.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2780de2..147612c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Identity and user groups**: send direct messages with `messages send @user`, + look up users by email, and list user groups or their members with optional + bulk user-name resolution. + ## [0.2.1] - 2026-09-08 ### Fixed diff --git a/README.md b/README.md index 45139c9..9ff8520 100644 --- a/README.md +++ b/README.md @@ -243,14 +243,28 @@ slack users list --active-only # Get current user info slack users me -# Get user info by ID or name +# Get user info by ID, name, or email slack users info U123456789 slack users info @username +slack users info alice@example.com + +# Send a direct message (opens or reuses the IM, then sends normally) +slack messages send @username "Hello directly" + +# List user groups and group members +slack users groups list +slack users groups members @engineering +slack users groups members S123456789 --resolve # Export users to CSV slack users export --output users.csv ``` +Email lookup requires `users:read.email`, and user-group commands require +`usergroups:read`. Direct-message opening uses `conversations.open` and normally +requires `im:write` (or the applicable conversation-write scope for the Slack +token type). Slack API missing-scope errors are returned unchanged. + ### Files (`slack files` or `slack f`) ```bash diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md index a56a7d9..fcaf1bc 100644 --- a/skills/slack/SKILL.md +++ b/skills/slack/SKILL.md @@ -179,8 +179,25 @@ slack channels list --sort-popularity --exclude-archived slack users me # current authenticated user slack users list # all workspace users slack users info @alice +slack users info alice@example.com ``` +### Identity resolution and user groups +```bash +# A leading @ opens or reuses an IM before sending +slack messages send @alice "Can you review this?" + +slack users groups list +slack users groups members @engineering +slack users groups members S123456789 --resolve +``` + +Bare names in message channel position remain channel names; use `@name` or a +U-ID to select a user. Email lookup requires `users:read.email`, groups require +`usergroups:read`, and opening an IM through `conversations.open` generally +requires `im:write` (or the applicable conversation-write scope for the token +type). Missing scopes are reported as Slack API errors. See [USERS.md](USERS.md). + ### Set status ```bash slack status set "In a meeting" --emoji meeting --expires 1h @@ -194,7 +211,7 @@ slack status clear | [AUTH.md](AUTH.md) | `auth add/discover/list/remove/status/switch/browser-help` | | [CHANNELS.md](CHANNELS.md) | `channels list/info/dms/export` | | [MESSAGES.md](MESSAGES.md) | `messages list/send/search/thread/get` | -| [USERS.md](USERS.md) | `users list/info/me/export` | +| [USERS.md](USERS.md) | `users list/info/me/groups/export` | | [FILES.md](FILES.md) | `files list/info/get` | | [REACTIONS.md](REACTIONS.md) | `reactions add/remove/list` | | [STATUS.md](STATUS.md) | `status get/set/clear/presence` | diff --git a/skills/slack/USERS.md b/skills/slack/USERS.md new file mode 100644 index 0000000..d0b8c97 --- /dev/null +++ b/skills/slack/USERS.md @@ -0,0 +1,76 @@ +# slack users + +Inspect workspace identities and user groups. Output is JSON by default; add +the global `--plain` flag for TSV intended for scripts. + +## List and inspect users + +```bash +slack users list +slack users list --include-deactivated --limit 200 +slack users info @alice +slack users info U123456789 +slack users me +``` + +Names and display names are resolved through `users.list`. Valid U-IDs are used +directly. `users info` then fetches the complete user record. + +## Look up by email + +```bash +slack users info alice@example.com +slack users info alice+alerts@example.com --plain +``` + +A trimmed value with nonempty text on both sides of `@` is sent directly to +`users.lookupByEmail`; the returned user is printed without a redundant +`users.info` request. The token needs `users:read.email`. Lookup failures, +including missing scopes, are returned unchanged and do not fall back to a +workspace-wide username search. + +## Send a direct message + +```bash +slack messages send @alice "Hello" +slack messages send U123456789 "Hello" +``` + +In message channel position, a leading `@` or a valid U-ID resolves the user, +calls `conversations.open`, and sends to the returned IM channel. This can +create/open an IM and is therefore a write-side effect even before the message +is posted. Bare `alice` remains a channel name; existing C/D/G channel IDs are +never reinterpreted as users. Direct IM opening generally requires `im:write` +(or the applicable conversation-write scope for the Slack token type). + +## User groups + +```bash +# Enabled groups with member counts +slack users groups list + +# Resolve an exact handle (the @ is optional) +slack users groups members @engineering +slack users groups members engineering --plain + +# An S-ID avoids the handle lookup +slack users groups members S123456789 + +# Resolve member IDs with one paginated users.list traversal +slack users groups members S123456789 --resolve +``` + +User-group operations require `usergroups:read`. `groups list` JSON is +`{"usergroups":[...]}`; plain output columns are `id`, `handle`, `name`, and +`user_count`. `groups members` emits `{"usergroup":"S…","members":[...]}` or +one ID per line. With `--resolve`, members are `{id,user_name}` objects, and +plain output is `iduser_name`. Resolution prefers the Slack username, +then the display name, and finally the ID. It does not make one `users.info` +request per member. + +## Export + +```bash +slack users export --output users.csv +slack users export --include-deactivated +``` diff --git a/src/api/identity_ops.rs b/src/api/identity_ops.rs new file mode 100644 index 0000000..3b29153 --- /dev/null +++ b/src/api/identity_ops.rs @@ -0,0 +1,142 @@ +//! Slack Web API operations used for identity and user-group commands. + +use serde::{Deserialize, Serialize}; + +use crate::error::Result; +use crate::models::{Channel, User}; + +use super::client::SlackClient; + +/// Parameters for `conversations.open`. +#[derive(Debug, Serialize)] +pub struct ConversationsOpenParams<'a> { + /// Comma-separated user IDs. Direct-message resolution supplies one ID. + pub users: &'a str, +} + +/// Response from `conversations.open`. +#[derive(Debug, Deserialize)] +pub struct ConversationsOpenResponse { + /// The opened or existing direct-message conversation. + pub channel: Channel, +} + +/// Parameters for `users.lookupByEmail`. +#[derive(Debug, Serialize)] +pub struct UsersLookupByEmailParams<'a> { + /// Email address to look up. + pub email: &'a str, +} + +/// Response from `users.lookupByEmail`. +#[derive(Debug, Deserialize)] +pub struct UsersLookupByEmailResponse { + /// Matching user. + pub user: User, +} + +/// Parameters for `usergroups.list`. +#[derive(Debug, Serialize)] +pub struct UsergroupsListParams { + /// Include disabled user groups. + pub include_disabled: bool, + /// Include each group's member count. + pub include_count: bool, + /// Include the member ID array on each group. + pub include_users: bool, +} + +/// A Slack user group. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Usergroup { + /// User-group ID. + pub id: String, + /// User-group handle, without `@`. + #[serde(default)] + pub handle: String, + /// Display name. + #[serde(default)] + pub name: String, + /// Number of users, when requested. + #[serde(default, deserialize_with = "deserialize_user_count")] + pub user_count: u64, + /// Additional fields returned by Slack. + #[serde(flatten)] + pub extra: serde_json::Map, +} + +fn deserialize_user_count<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + + match serde_json::Value::deserialize(deserializer)? { + serde_json::Value::Null => Ok(0), + serde_json::Value::Number(value) => value + .as_u64() + .ok_or_else(|| D::Error::custom("user_count must be a nonnegative integer")), + serde_json::Value::String(value) => value.parse().map_err(D::Error::custom), + _ => Err(D::Error::custom("user_count must be an integer or string")), + } +} + +/// Response from `usergroups.list`. +#[derive(Debug, Deserialize)] +pub struct UsergroupsListResponse { + /// Workspace user groups. + #[serde(default)] + pub usergroups: Vec, +} + +/// Parameters for `usergroups.users.list`. +#[derive(Debug, Serialize)] +pub struct UsergroupsUsersListParams<'a> { + /// User-group ID. + pub usergroup: &'a str, + /// Include disabled users. + pub include_disabled: bool, +} + +/// Response from `usergroups.users.list`. +#[derive(Debug, Deserialize)] +pub struct UsergroupsUsersListResponse { + /// IDs of users in the group. + #[serde(default)] + pub users: Vec, +} + +impl SlackClient { + /// Open or find a direct-message conversation with one user. + pub async fn conversations_open(&self, user_id: &str) -> Result { + self.request( + "conversations.open", + &ConversationsOpenParams { users: user_id }, + ) + .await + } + + /// Look up a user by email address. + pub async fn users_lookup_by_email(&self, email: &str) -> Result { + let response: UsersLookupByEmailResponse = self + .request("users.lookupByEmail", &UsersLookupByEmailParams { email }) + .await?; + Ok(response.user) + } + + /// List workspace user groups. + pub async fn usergroups_list( + &self, + params: UsergroupsListParams, + ) -> Result { + self.request("usergroups.list", ¶ms).await + } + + /// List member IDs for a user group. + pub async fn usergroups_users_list( + &self, + params: UsergroupsUsersListParams<'_>, + ) -> Result { + self.request("usergroups.users.list", ¶ms).await + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 3ddd2ee..0cad2df 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -9,6 +9,7 @@ mod client; pub mod edge; +pub mod identity_ops; mod rate_limiter; mod resolve; pub mod types; diff --git a/src/api/resolve.rs b/src/api/resolve.rs index 66bf64c..dd4e41f 100644 --- a/src/api/resolve.rs +++ b/src/api/resolve.rs @@ -30,15 +30,27 @@ fn is_user_id(s: &str) -> bool { matches!(s.chars().next(), Some('U')) } +/// Return a normalized email address when the identifier has nonempty local +/// and domain components. +fn email_identifier(identifier: &str) -> Option<&str> { + let trimmed = identifier.trim(); + let (local, domain) = trimmed.split_once('@')?; + if local.is_empty() || domain.is_empty() || domain.contains('@') { + None + } else { + Some(trimmed) + } +} + impl SlackClient { /// Resolve a channel identifier to a channel ID /// /// If the identifier already looks like a channel ID (starts with C/D/G /// and is 9+ characters), it is returned as-is. /// - /// Otherwise, the identifier is treated as a channel name. The leading # - /// is stripped if present, and the API is searched to find the matching - /// channel ID. + /// A leading `@` or a valid user ID opens a direct-message conversation + /// and returns its channel ID. Otherwise, the identifier is treated as a + /// channel name. The leading `#` is stripped if present before searching. /// /// # Errors /// @@ -49,6 +61,28 @@ impl SlackClient { return Ok(identifier.to_string()); } + // A leading @ explicitly selects a user. A valid bare user ID is also + // unambiguous; ordinary bare names remain channel names. + let dm_target = if let Some(target) = identifier.strip_prefix('@') { + let target = target.trim(); + if target.is_empty() { + return Err(SlackError::Usage( + "direct-message target after @ cannot be empty".to_string(), + )); + } + Some(target) + } else if is_user_id(identifier) { + Some(identifier) + } else { + None + }; + + if let Some(target) = dm_target { + let user_id = self.resolve_user(target).await?; + let response = self.conversations_open(&user_id).await?; + return Ok(response.channel.id); + } + // Strip leading # if present let name = identifier.strip_prefix('#').unwrap_or(identifier); @@ -104,9 +138,10 @@ impl SlackClient { /// If the identifier already looks like a user ID (starts with U /// and is 9+ characters), it is returned as-is. /// - /// Otherwise, the identifier is treated as a username. The leading @ - /// is stripped if present, and the API is searched to find the matching - /// user ID by matching on `name` or `profile.display_name`. + /// Email addresses are resolved with `users.lookupByEmail`. Otherwise, the + /// identifier is treated as a username. The leading `@` is stripped if + /// present, and `users.list` is searched for `name` or + /// `profile.display_name`. /// /// # Errors /// @@ -117,6 +152,10 @@ impl SlackClient { return Ok(identifier.to_string()); } + if let Some(email) = email_identifier(identifier) { + return Ok(self.users_lookup_by_email(email).await?.id); + } + // Strip leading @ if present let name = identifier.strip_prefix('@').unwrap_or(identifier); let name_lower = name.to_lowercase(); @@ -168,9 +207,14 @@ impl SlackClient { /// Resolve a user identifier and return the full User object /// - /// Similar to `resolve_user`, but returns the full User object - /// including all metadata from users.info. + /// Similar to `resolve_user`, but returns the full user object. Email + /// lookups return the `users.lookupByEmail` user directly; other + /// identifiers are fetched with `users.info` after resolution. pub async fn resolve_user_info(&self, identifier: &str) -> Result { + if let Some(email) = email_identifier(identifier) { + return self.users_lookup_by_email(email).await; + } + let user_id = self.resolve_user(identifier).await?; self.users_info(&user_id).await } @@ -236,4 +280,16 @@ mod tests { assert!(!is_user_id("johndoe")); // Name assert!(!is_user_id("@johndoe")); // Name with @ } + + #[test] + fn test_email_identifier() { + assert_eq!( + email_identifier(" alice+ops@example.com "), + Some("alice+ops@example.com") + ); + assert_eq!(email_identifier("@example.com"), None); + assert_eq!(email_identifier("alice@"), None); + assert_eq!(email_identifier("alice"), None); + assert_eq!(email_identifier("a@b@example.com"), None); + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 649d3f3..f170ed9 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -12,6 +12,7 @@ pub mod reactions; pub mod reminders; pub mod root; pub mod status; +pub mod usergroups; pub mod users; pub use api::ApiCmd; diff --git a/src/cli/usergroups.rs b/src/cli/usergroups.rs new file mode 100644 index 0000000..19da3b6 --- /dev/null +++ b/src/cli/usergroups.rs @@ -0,0 +1,284 @@ +//! Nested `slack users groups` commands. + +use std::collections::{HashMap, HashSet}; +use std::io::{self, Write}; + +use clap::{Args, Subcommand}; +use serde::Serialize; + +use crate::api::identity_ops::{UsergroupsListParams, UsergroupsUsersListParams}; +use crate::api::{PaginationParams, SlackClient}; +use crate::error::{Result, SlackError}; +use crate::models::User; +use crate::output::{write_json, OutputMode}; + +/// User-group operations. +#[derive(Args, Debug)] +pub struct UsergroupsCmd { + /// User-group command to run. + #[command(subcommand)] + pub command: UsergroupsCommands, +} + +/// User-group subcommands. +#[derive(Subcommand, Debug)] +pub enum UsergroupsCommands { + /// List enabled workspace user groups. + List, + + /// List members of a user group. + Members { + /// User-group handle (optionally prefixed by @) or S-ID. + usergroup: String, + + /// Resolve member IDs to user names. + #[arg(long)] + resolve: bool, + }, +} + +#[derive(Serialize)] +struct ResolvedMember { + id: String, + user_name: String, +} + +/// Run a nested user-group command with an authenticated client. +pub async fn run(cmd: &UsergroupsCmd, client: &SlackClient, output_mode: OutputMode) -> Result<()> { + match &cmd.command { + UsergroupsCommands::List => list(client, output_mode).await, + UsergroupsCommands::Members { usergroup, resolve } => { + members(client, usergroup, *resolve, output_mode).await + } + } +} + +async fn list(client: &SlackClient, output_mode: OutputMode) -> Result<()> { + let response = client + .usergroups_list(UsergroupsListParams { + include_disabled: false, + include_count: true, + include_users: false, + }) + .await?; + + if output_mode == OutputMode::Plain { + let stdout = io::stdout(); + let mut output = stdout.lock(); + for group in &response.usergroups { + writeln!( + output, + "{}\t{}\t{}\t{}", + escape_tsv(&group.id), + escape_tsv(&group.handle), + escape_tsv(&group.name), + group.user_count + )?; + } + } else { + write_json(&serde_json::json!({ "usergroups": response.usergroups }))?; + } + + Ok(()) +} + +async fn members( + client: &SlackClient, + identifier: &str, + resolve: bool, + output_mode: OutputMode, +) -> Result<()> { + let usergroup = resolve_usergroup(client, identifier).await?; + let response = client + .usergroups_users_list(UsergroupsUsersListParams { + usergroup: &usergroup, + include_disabled: false, + }) + .await?; + + if resolve { + let users = users_for_resolution(client).await?; + let names: HashMap = users + .into_iter() + .map(|user| { + let display_name = user.display_name(); + let name = user + .name + .as_deref() + .filter(|name| !name.is_empty()) + .map(str::to_string) + .or_else(|| (!display_name.is_empty()).then_some(display_name)) + .unwrap_or_else(|| user.id.clone()); + (user.id, name) + }) + .collect(); + let members: Vec = response + .users + .into_iter() + .map(|id| ResolvedMember { + user_name: names.get(&id).cloned().unwrap_or_else(|| id.clone()), + id, + }) + .collect(); + + if output_mode == OutputMode::Plain { + let stdout = io::stdout(); + let mut output = stdout.lock(); + for member in &members { + writeln!( + output, + "{}\t{}", + escape_tsv(&member.id), + escape_tsv(&member.user_name) + )?; + } + } else { + write_json(&serde_json::json!({ + "usergroup": usergroup, + "members": members, + }))?; + } + } else if output_mode == OutputMode::Plain { + let stdout = io::stdout(); + let mut output = stdout.lock(); + for id in &response.users { + writeln!(output, "{}", escape_tsv(id))?; + } + } else { + write_json(&serde_json::json!({ + "usergroup": usergroup, + "members": response.users, + }))?; + } + + Ok(()) +} + +async fn users_for_resolution(client: &SlackClient) -> Result> { + let mut users = Vec::new(); + let mut cursor: Option = None; + let mut seen_cursors = HashSet::new(); + + loop { + let mut params = PaginationParams::new().with_limit(200); + if let Some(value) = cursor { + params = params.with_cursor(value); + } + + let response = client.users_list(params).await?; + users.extend(response.members); + cursor = response + .response_metadata + .and_then(|metadata| metadata.next_cursor) + .filter(|value| !value.is_empty()); + + match cursor.as_ref() { + Some(value) if !seen_cursors.insert(value.clone()) => { + return Err(SlackError::Api { + error: "repeated_cursor".to_string(), + detail: Some("users.list returned a repeated pagination cursor".to_string()), + }); + } + Some(_) => {} + None => break, + } + } + + Ok(users) +} + +async fn resolve_usergroup(client: &SlackClient, identifier: &str) -> Result { + if identifier.len() >= 9 && identifier.starts_with('S') { + return Ok(identifier.to_string()); + } + + let handle = identifier.strip_prefix('@').unwrap_or(identifier); + let response = client + .usergroups_list(UsergroupsListParams { + include_disabled: false, + include_count: true, + include_users: false, + }) + .await?; + + response + .usergroups + .into_iter() + .find(|group| group.handle == handle) + .map(|group| group.id) + .ok_or_else(|| SlackError::Api { + error: "usergroup_not_found".to_string(), + detail: Some(format!("No user group has handle {}", identifier)), + }) +} + +fn escape_tsv(value: &str) -> String { + value + .replace('\t', "\\t") + .replace('\n', "\\n") + .replace('\r', "\\r") +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + use crate::cli::{Cli, Commands}; + + #[test] + fn parse_groups_list() { + let cli = Cli::try_parse_from(["slack", "users", "groups", "list"]).unwrap(); + match cli.command { + Commands::Users(users) => { + assert!(matches!( + users.command, + crate::cli::users::UsersCommands::Groups(UsergroupsCmd { + command: UsergroupsCommands::List + }) + )); + } + _ => panic!("expected users command"), + } + } + + #[test] + fn parse_groups_members_resolve() { + let cli = Cli::try_parse_from([ + "slack", + "users", + "groups", + "members", + "@engineering", + "--resolve", + ]) + .unwrap(); + match cli.command { + Commands::Users(users) => match users.command { + crate::cli::users::UsersCommands::Groups(UsergroupsCmd { + command: UsergroupsCommands::Members { usergroup, resolve }, + }) => { + assert_eq!(usergroup, "@engineering"); + assert!(resolve); + } + _ => panic!("expected groups members command"), + }, + _ => panic!("expected users command"), + } + } + + #[test] + fn parse_groups_members_defaults_to_ids() { + let cli = + Cli::try_parse_from(["slack", "users", "groups", "members", "S12345678"]).unwrap(); + match cli.command { + Commands::Users(users) => match users.command { + crate::cli::users::UsersCommands::Groups(UsergroupsCmd { + command: UsergroupsCommands::Members { resolve, .. }, + }) => assert!(!resolve), + _ => panic!("expected groups members command"), + }, + _ => panic!("expected users command"), + } + } +} diff --git a/src/cli/users.rs b/src/cli/users.rs index dddbca5..59d0712 100644 --- a/src/cli/users.rs +++ b/src/cli/users.rs @@ -1,9 +1,11 @@ //! Users CLI commands for Slack CLI //! -//! Handles user operations: list, info, me, export. +//! Handles user operations: list, info, me, groups, export. use clap::{Args, Subcommand}; +use super::usergroups::UsergroupsCmd; + /// User operations commands #[derive(Args, Debug)] pub struct UsersCmd { @@ -31,13 +33,16 @@ pub enum UsersCommands { /// Show user info Info { - /// Username or user ID + /// Username, email address, or user ID user: String, }, /// Show current authenticated user Me, + /// Manage workspace user groups + Groups(UsergroupsCmd), + /// Export all users to CSV Export { /// Output file (stdout if not specified) @@ -90,6 +95,10 @@ pub async fn run( me(&client, output_mode).await?; } + UsersCommands::Groups(groups) => { + super::usergroups::run(groups, &client, output_mode).await?; + } + UsersCommands::Export { output, include_deactivated, @@ -461,6 +470,20 @@ mod tests { } } + #[test] + fn test_parse_users_info_by_email() { + let cli = Cli::try_parse_from(["slack", "users", "info", "alice+cli@example.com"]).unwrap(); + if let crate::cli::Commands::Users(users_cmd) = cli.command { + if let UsersCommands::Info { user } = users_cmd.command { + assert_eq!(user, "alice+cli@example.com"); + } else { + panic!("Expected Info command"); + } + } else { + panic!("Expected Users command"); + } + } + #[test] fn test_parse_users_me() { let cli = Cli::try_parse_from(["slack", "users", "me"]).unwrap(); diff --git a/tests/cli_identity_users.rs b/tests/cli_identity_users.rs new file mode 100644 index 0000000..910be66 --- /dev/null +++ b/tests/cli_identity_users.rs @@ -0,0 +1,436 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use mockito::{Matcher, ServerGuard}; +use predicates::prelude::*; +use serde_json::Value; +use slack_cli::api::SlackClient; +use slack_cli::auth::TokenSet; +use tempfile::TempDir; + +const TOKEN: &str = "xoxp-test-token-12345678901234"; + +async fn mock_server() -> ServerGuard { + mockito::Server::new_async().await +} + +fn command(server: &ServerGuard, temp: &TempDir) -> assert_cmd::Command { + let mut cmd = cargo_bin_cmd!("slack"); + cmd.env("SLACK_API_BASE_URL", server.url()) + .env("SLACK_TOKEN_STORE_PATH", temp.path().join("tokens.json")) + .env("SLACK_TOKEN", TOKEN) + .env_remove("SLACK_WORKSPACE") + .env_remove("SLACK_PLAIN"); + cmd +} + +fn post_message_response(channel: &str, ts: &str) -> String { + format!( + r#"{{"ok":true,"channel":"{}","ts":"{}","message":{{"ts":"{}"}}}}"#, + channel, ts, ts + ) +} + +#[tokio::test] +async fn send_to_named_user_opens_dm_then_posts() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let users = server + .mock("POST", "/users.list") + .match_body(Matcher::UrlEncoded("limit".into(), "200".into())) + .with_body(r#"{"ok":true,"members":[{"id":"U12345678","name":"alice"}],"response_metadata":{"next_cursor":""}}"#) + .create_async() + .await; + let open = server + .mock("POST", "/conversations.open") + .match_body(Matcher::UrlEncoded("users".into(), "U12345678".into())) + .with_body(r#"{"ok":true,"channel":{"id":"D12345678"}}"#) + .create_async() + .await; + let post = server + .mock("POST", "/chat.postMessage") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), "D12345678".into()), + Matcher::UrlEncoded("text".into(), "hello".into()), + ])) + .with_body(post_message_response("D12345678", "111.222")) + .create_async() + .await; + + command(&server, &temp) + .args(["--plain", "messages", "send", "@alice", "hello"]) + .assert() + .success() + .stdout("111.222\n"); + + users.assert_async().await; + open.assert_async().await; + post.assert_async().await; +} + +#[tokio::test] +async fn user_ids_with_or_without_at_bypass_users_list() { + for target in ["U12345678", "@U12345678"] { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let open = server + .mock("POST", "/conversations.open") + .match_body(Matcher::UrlEncoded("users".into(), "U12345678".into())) + .with_body(r#"{"ok":true,"channel":{"id":"D12345678"}}"#) + .create_async() + .await; + let post = server + .mock("POST", "/chat.postMessage") + .match_body(Matcher::UrlEncoded("channel".into(), "D12345678".into())) + .with_body(post_message_response("D12345678", "111.223")) + .create_async() + .await; + + command(&server, &temp) + .args(["--plain", "messages", "send", target, "hello"]) + .assert() + .success() + .stdout("111.223\n"); + open.assert_async().await; + post.assert_async().await; + } +} + +#[tokio::test] +async fn channel_id_never_opens_a_dm() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let post = server + .mock("POST", "/chat.postMessage") + .match_body(Matcher::UrlEncoded("channel".into(), "C12345678".into())) + .with_body(post_message_response("C12345678", "111.224")) + .create_async() + .await; + + command(&server, &temp) + .args(["--plain", "messages", "send", "C12345678", "hello"]) + .assert() + .success(); + post.assert_async().await; +} + +#[tokio::test] +async fn unknown_user_and_open_failure_prevent_posting() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let users = server + .mock("POST", "/users.list") + .with_body(r#"{"ok":true,"members":[],"response_metadata":{"next_cursor":""}}"#) + .create_async() + .await; + command(&server, &temp) + .args(["messages", "send", "@missing", "hello"]) + .assert() + .failure() + .stdout(predicate::str::contains("user_not_found")); + users.assert_async().await; + + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let open = server + .mock("POST", "/conversations.open") + .with_body(r#"{"ok":false,"error":"missing_scope"}"#) + .create_async() + .await; + command(&server, &temp) + .args(["messages", "send", "@U12345678", "hello"]) + .assert() + .failure() + .stdout(predicate::str::contains("missing_scope")); + open.assert_async().await; +} + +#[tokio::test] +async fn empty_at_target_is_usage_without_io() { + let server = mock_server().await; + let temp = TempDir::new().unwrap(); + command(&server, &temp) + .args(["messages", "send", "@", "hello"]) + .assert() + .code(2) + .stdout(predicate::str::contains("usage_error")); +} + +#[tokio::test] +async fn users_info_by_email_uses_lookup_response_directly() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let lookup = server + .mock("POST", "/users.lookupByEmail") + .match_body(Matcher::UrlEncoded( + "email".into(), + "alice+cli@example.com".into(), + )) + .with_body(r#"{"ok":true,"user":{"id":"U12345678","name":"alice","profile":{"email":"alice+cli@example.com"}}}"#) + .create_async() + .await; + + let output = command(&server, &temp) + .args(["users", "info", " alice+cli@example.com "]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["id"], "U12345678"); + assert_eq!(json["profile"]["email"], "alice+cli@example.com"); + lookup.assert_async().await; +} + +#[tokio::test] +async fn resolve_user_email_library_call_and_api_failure_do_not_fallback() { + let mut server = mock_server().await; + let lookup = server + .mock("POST", "/users.lookupByEmail") + .match_body(Matcher::UrlEncoded( + "email".into(), + "alice+ops@example.com".into(), + )) + .with_body(r#"{"ok":true,"user":{"id":"U87654321","name":"alice"}}"#) + .create_async() + .await; + let token = TokenSet::new_oauth( + TOKEN.to_string(), + "T12345678".to_string(), + "test".to_string(), + "U00000000".to_string(), + vec![], + ) + .unwrap(); + let client = SlackClient::with_base_url(token, server.url()).unwrap(); + assert_eq!( + client + .resolve_user(" alice+ops@example.com ") + .await + .unwrap(), + "U87654321" + ); + lookup.assert_async().await; + + let mut server = mock_server().await; + let lookup = server + .mock("POST", "/users.lookupByEmail") + .match_body(Matcher::UrlEncoded( + "email".into(), + "nobody@example.com".into(), + )) + .with_body(r#"{"ok":false,"error":"users_not_found"}"#) + .create_async() + .await; + let token = TokenSet::new_oauth( + TOKEN.to_string(), + "T12345678".to_string(), + "test".to_string(), + "U00000000".to_string(), + vec![], + ) + .unwrap(); + let client = SlackClient::with_base_url(token, server.url()).unwrap(); + let error = client.resolve_user("nobody@example.com").await.unwrap_err(); + assert!(matches!( + error, + slack_cli::error::SlackError::Api { ref error, .. } if error == "users_not_found" + )); + lookup.assert_async().await; +} + +#[tokio::test] +async fn usergroups_list_outputs_json_and_tsv() { + for plain in [false, true] { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/usergroups.list") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("include_disabled".into(), "false".into()), + Matcher::UrlEncoded("include_count".into(), "true".into()), + Matcher::UrlEncoded("include_users".into(), "false".into()), + ])) + .with_body(r#"{"ok":true,"usergroups":[{"id":"S12345678","handle":"eng","name":"Engineering","user_count":"2"}]}"#) + .create_async() + .await; + let mut cmd = command(&server, &temp); + if plain { + cmd.arg("--plain"); + } + let output = cmd.args(["users", "groups", "list"]).output().unwrap(); + assert!(output.status.success()); + if plain { + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + "S12345678\teng\tEngineering\t2\n" + ); + } else { + let json: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["usergroups"][0]["handle"], "eng"); + } + list.assert_async().await; + } +} + +#[tokio::test] +async fn usergroup_members_resolves_handle_and_supports_empty_members() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let groups = server + .mock("POST", "/usergroups.list") + .with_body(r#"{"ok":true,"usergroups":[{"id":"S12345678","handle":"eng","name":"Engineering","user_count":0}]}"#) + .create_async() + .await; + let members = server + .mock("POST", "/usergroups.users.list") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("usergroup".into(), "S12345678".into()), + Matcher::UrlEncoded("include_disabled".into(), "false".into()), + ])) + .with_body(r#"{"ok":true,"users":[]}"#) + .create_async() + .await; + let output = command(&server, &temp) + .args(["users", "groups", "members", "@eng"]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + json, + serde_json::json!({"usergroup":"S12345678","members":[]}) + ); + groups.assert_async().await; + members.assert_async().await; +} + +#[tokio::test] +async fn usergroup_id_bypasses_group_list_and_plain_outputs_ids() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let members = server + .mock("POST", "/usergroups.users.list") + .match_body(Matcher::UrlEncoded("usergroup".into(), "S12345678".into())) + .with_body(r#"{"ok":true,"users":["U11111111","U22222222"]}"#) + .create_async() + .await; + command(&server, &temp) + .args(["--plain", "users", "groups", "members", "S12345678"]) + .assert() + .success() + .stdout("U11111111\nU22222222\n"); + members.assert_async().await; +} + +#[tokio::test] +async fn unknown_usergroup_handle_returns_api_error() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let groups = server + .mock("POST", "/usergroups.list") + .with_body(r#"{"ok":true,"usergroups":[]}"#) + .create_async() + .await; + command(&server, &temp) + .args(["users", "groups", "members", "missing"]) + .assert() + .failure() + .stdout(predicate::str::contains("usergroup_not_found")); + groups.assert_async().await; +} + +#[tokio::test] +async fn resolved_members_use_one_paginated_users_traversal_and_fallback_names() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let members = server + .mock("POST", "/usergroups.users.list") + .with_body(r#"{"ok":true,"users":["U11111111","U22222222","U33333333","U44444444"]}"#) + .create_async() + .await; + let page_one = server + .mock("POST", "/users.list") + .match_body(Matcher::UrlEncoded("limit".into(), "200".into())) + .with_body(r#"{"ok":true,"members":[{"id":"U11111111","name":"alice"}],"response_metadata":{"next_cursor":"next"}}"#) + .create_async() + .await; + let page_two = server + .mock("POST", "/users.list") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("limit".into(), "200".into()), + Matcher::UrlEncoded("cursor".into(), "next".into()), + ])) + .with_body(r#"{"ok":true,"members":[{"id":"U22222222","name":"","deleted":true,"profile":{"display_name":"Former User"}},{"id":"U44444444","name":""}],"response_metadata":{"next_cursor":""}}"#) + .create_async() + .await; + let output = command(&server, &temp) + .args(["users", "groups", "members", "S12345678", "--resolve"]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "usergroup":"S12345678", + "members":[ + {"id":"U11111111","user_name":"alice"}, + {"id":"U22222222","user_name":"Former User"}, + {"id":"U33333333","user_name":"U33333333"}, + {"id":"U44444444","user_name":"U44444444"} + ] + }) + ); + members.assert_async().await; + page_one.assert_async().await; + page_two.assert_async().await; +} + +#[tokio::test] +async fn repeated_users_cursor_fails_instead_of_looping() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + server + .mock("POST", "/usergroups.users.list") + .with_body(r#"{"ok":true,"users":["U11111111"]}"#) + .create_async() + .await; + let users = server + .mock("POST", "/users.list") + .with_body(r#"{"ok":true,"members":[],"response_metadata":{"next_cursor":"same"}}"#) + .expect(2) + .create_async() + .await; + command(&server, &temp) + .args(["users", "groups", "members", "S12345678", "--resolve"]) + .assert() + .failure() + .stdout(predicate::str::contains("repeated_cursor")); + users.assert_async().await; +} + +#[tokio::test] +async fn resolved_members_plain_escapes_names() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + server + .mock("POST", "/usergroups.users.list") + .with_body(r#"{"ok":true,"users":["U11111111"]}"#) + .create_async() + .await; + server + .mock("POST", "/users.list") + .with_body(r#"{"ok":true,"members":[{"id":"U11111111","name":"line\tbreak\r\n"}],"response_metadata":{"next_cursor":""}}"#) + .create_async() + .await; + command(&server, &temp) + .args([ + "--plain", + "users", + "groups", + "members", + "S12345678", + "--resolve", + ]) + .assert() + .success() + .stdout("U11111111\tline\\tbreak\\r\\n\n"); +} From 81fb429cc2fd5cb4e29bf51a78509f25c7a451d5 Mon Sep 17 00:00:00 2001 From: Chris Raethke Date: Wed, 9 Sep 2026 22:30:29 +1000 Subject: [PATCH 02/22] feat(pins-emoji): Pins and custom emoji command groups --- CHANGELOG.md | 4 + README.md | 26 +++ skills/slack/EMOJI.md | 19 ++ skills/slack/PINS.md | 40 ++++ skills/slack/SKILL.md | 20 ++ src/api/mod.rs | 1 + src/api/pin_emoji_ops.rs | 78 ++++++++ src/cli/emoji.rs | 86 +++++++++ src/cli/mod.rs | 4 + src/cli/pins.rs | 209 +++++++++++++++++++++ src/cli/root.rs | 8 + src/main.rs | 22 ++- tests/cli_pins_emoji.rs | 390 +++++++++++++++++++++++++++++++++++++++ 13 files changed, 905 insertions(+), 2 deletions(-) create mode 100644 skills/slack/EMOJI.md create mode 100644 skills/slack/PINS.md create mode 100644 src/api/pin_emoji_ops.rs create mode 100644 src/cli/emoji.rs create mode 100644 src/cli/pins.rs create mode 100644 tests/cli_pins_emoji.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 147612c..0d3a0d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Identity and user groups**: send direct messages with `messages send @user`, look up users by email, and list user groups or their members with optional bulk user-name resolution. +- **Pins**: add, remove, and list channel pins with `slack pins`, preserving + message, file, and other pin item payloads in JSON output. +- **Custom emoji list**: list workspace custom emoji URLs and aliases with + `slack emoji list`, with sorted TSV output available through `--plain`. ## [0.2.1] - 2026-09-08 diff --git a/README.md b/README.md index 9ff8520..6828adf 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,32 @@ slack reactions remove C123456789 1234567890.123456 thumbsup slack reactions list C123456789 1234567890.123456 ``` +### Pins (`slack pins`) + +```bash +# Pin or unpin a message +slack pins add "#general" 1234567890.123456 +slack pins remove C123456789 1234567890.123456 + +# List all pinned items in a channel +slack pins list "#general" +``` + +Adding and removing pins requires `pins:write`; listing requires `pins:read`. +Pin list JSON preserves message, file, and other item records returned by Slack. + +### Emoji (`slack emoji`) + +```bash +# List workspace custom emoji +slack emoji list +slack --plain emoji list +``` + +`emoji list` requires `emoji:read` and returns custom workspace emoji only, +including unchanged image URLs and `alias:` values. It does not include +Slack's built-in Unicode emoji. + ### Status (`slack status` or `slack s`) ```bash diff --git a/skills/slack/EMOJI.md b/skills/slack/EMOJI.md new file mode 100644 index 0000000..9b1f28d --- /dev/null +++ b/skills/slack/EMOJI.md @@ -0,0 +1,19 @@ +# slack emoji + +List custom emoji configured for a Slack workspace. + +## List custom emoji + +```bash +slack emoji list +slack --plain emoji list +slack -w cadence-app emoji list +``` + +This command requires `emoji:read`. JSON output maps each custom emoji name to +its Slack image URL or unchanged `alias:` value. Plain output is sorted by +name and uses `namevalue` rows. + +The command does not download images or expand aliases. Slack's built-in +Unicode emoji are not included; `emoji list` reports workspace custom emoji +only. diff --git a/skills/slack/PINS.md b/skills/slack/PINS.md new file mode 100644 index 0000000..7437ed1 --- /dev/null +++ b/skills/slack/PINS.md @@ -0,0 +1,40 @@ +# slack pins + +Manage pinned items in a Slack conversation. Channel names (with or without `#`) +and channel, group, or DM IDs are accepted. + +## Add a message pin + +```bash +slack pins add "#general" 1234567890.123456 +slack pins add C123456789 1234567890.123456 +``` + +Requires `pins:write`. Success JSON contains `ok`, the resolved `channel`, and +`ts`; `--plain` prints only the timestamp. Slack errors such as +`already_pinned` are returned unchanged. + +## Remove a message pin + +```bash +slack pins remove "#general" 1234567890.123456 +``` + +Requires `pins:write`. Slack errors such as `not_pinned` are returned unchanged. + +## List pins + +```bash +slack pins list "#general" +slack --plain pins list C123456789 +``` + +Requires `pins:read`. JSON preserves every item returned by Slack, including +message, file, and file-comment records and their optional metadata, in API +order. Plain output is TSV: + +```text +typemessage-ts-or-file-idauthor-idtext-or-title +``` + +Missing fields are empty, and an empty list produces no plain rows. diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md index fcaf1bc..d9f875f 100644 --- a/skills/slack/SKILL.md +++ b/skills/slack/SKILL.md @@ -198,6 +198,24 @@ U-ID to select a user. Email lookup requires `users:read.email`, groups require requires `im:write` (or the applicable conversation-write scope for the token type). Missing scopes are reported as Slack API errors. See [USERS.md](USERS.md). +### Manage pins +```bash +slack pins add "#general" 1234567890.123456 +slack pins list "#general" +``` + +Adding/removing requires `pins:write`; listing requires `pins:read`. See +[PINS.md](PINS.md). + +### List custom emoji +```bash +slack emoji list +slack --plain emoji list +``` + +This lists custom workspace emoji only and requires `emoji:read`. See +[EMOJI.md](EMOJI.md). + ### Set status ```bash slack status set "In a meeting" --emoji meeting --expires 1h @@ -214,5 +232,7 @@ slack status clear | [USERS.md](USERS.md) | `users list/info/me/groups/export` | | [FILES.md](FILES.md) | `files list/info/get` | | [REACTIONS.md](REACTIONS.md) | `reactions add/remove/list` | +| [PINS.md](PINS.md) | `pins add/remove/list` | +| [EMOJI.md](EMOJI.md) | `emoji list` | | [STATUS.md](STATUS.md) | `status get/set/clear/presence` | | [REMINDERS.md](REMINDERS.md) | `reminders list/add/complete/delete` | diff --git a/src/api/mod.rs b/src/api/mod.rs index 0cad2df..22fe7fd 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -10,6 +10,7 @@ mod client; pub mod edge; pub mod identity_ops; +pub mod pin_emoji_ops; mod rate_limiter; mod resolve; pub mod types; diff --git a/src/api/pin_emoji_ops.rs b/src/api/pin_emoji_ops.rs new file mode 100644 index 0000000..39f7601 --- /dev/null +++ b/src/api/pin_emoji_ops.rs @@ -0,0 +1,78 @@ +//! Slack Web API operations for pins and custom emoji. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::error::Result; + +use super::client::SlackClient; + +#[derive(Debug, Serialize)] +struct PinParams<'a> { + channel: &'a str, + timestamp: &'a str, +} + +#[derive(Debug, Serialize)] +struct ChannelParams<'a> { + channel: &'a str, +} + +#[derive(Debug, Serialize)] +struct EmptyParams {} + +#[derive(Debug, Deserialize)] +struct PinMutationResponse {} + +/// One item returned by `pins.list`. +/// +/// Pin payloads vary by item type. Keeping the raw object means message, file, +/// file-comment, and future item types retain all metadata returned by Slack. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct PinItem(pub serde_json::Value); + +/// Response from `pins.list`. +#[derive(Debug, Serialize, Deserialize)] +pub struct PinsListResponse { + /// Pin items in Slack API order. + #[serde(default)] + pub items: Vec, +} + +/// Response from `emoji.list`. +#[derive(Debug, Serialize, Deserialize)] +pub struct EmojiListResponse { + /// Custom emoji names mapped to image URLs or `alias:` values. + #[serde(default)] + pub emoji: BTreeMap, +} + +impl SlackClient { + /// Add a pin to a message. + pub async fn pins_add(&self, channel: &str, timestamp: &str) -> Result<()> { + let _: PinMutationResponse = self + .request("pins.add", &PinParams { channel, timestamp }) + .await?; + Ok(()) + } + + /// Remove a pin from a message. + pub async fn pins_remove(&self, channel: &str, timestamp: &str) -> Result<()> { + let _: PinMutationResponse = self + .request("pins.remove", &PinParams { channel, timestamp }) + .await?; + Ok(()) + } + + /// List all pins in a channel. + pub async fn pins_list(&self, channel: &str) -> Result { + self.request("pins.list", &ChannelParams { channel }).await + } + + /// List custom emoji for the workspace. + pub async fn emoji_list(&self) -> Result { + self.request("emoji.list", &EmptyParams {}).await + } +} diff --git a/src/cli/emoji.rs b/src/cli/emoji.rs new file mode 100644 index 0000000..3b32a89 --- /dev/null +++ b/src/cli/emoji.rs @@ -0,0 +1,86 @@ +//! Custom emoji commands for Slack CLI. + +use std::io::{self, Write}; + +use clap::{Args, Subcommand}; + +use crate::api::SlackClient; +use crate::error::Result; +use crate::output::{write_json, OutputMode}; + +/// Custom emoji operations. +#[derive(Args, Debug)] +pub struct EmojiCmd { + /// Emoji command to run. + #[command(subcommand)] + pub command: EmojiCommands, +} + +/// Custom emoji subcommands. +#[derive(Subcommand, Debug)] +pub enum EmojiCommands { + /// List custom workspace emoji. + List, +} + +/// Run a custom emoji command. +pub async fn run( + cmd: &EmojiCmd, + plain: bool, + workspace: Option<&str>, + token_override: Option<&str>, +) -> Result<()> { + let token = crate::auth::resolve_token(workspace, token_override)?; + let client = SlackClient::new(token)?; + let output_mode = OutputMode::from_flags(plain); + + match &cmd.command { + EmojiCommands::List => list_emoji(&client, output_mode).await, + } +} + +async fn list_emoji(client: &SlackClient, output_mode: OutputMode) -> Result<()> { + let response = client.emoji_list().await?; + + if output_mode == OutputMode::Plain { + let stdout = io::stdout(); + let mut output = stdout.lock(); + for (name, value) in &response.emoji { + writeln!(output, "{}\t{}", escape_tsv(name), escape_tsv(value))?; + } + } else { + write_json(&serde_json::json!({ "emoji": response.emoji }))?; + } + Ok(()) +} + +fn escape_tsv(value: &str) -> String { + value + .replace('\t', "\\t") + .replace('\n', "\\n") + .replace('\r', "\\r") +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + use crate::cli::{Cli, Commands}; + + #[test] + fn parse_emoji_list() { + let cli = Cli::try_parse_from(["slack", "emoji", "list"]).unwrap(); + assert!(matches!( + cli.command, + Commands::Emoji(EmojiCmd { + command: EmojiCommands::List + }) + )); + } + + #[test] + fn escapes_all_tsv_controls() { + assert_eq!(escape_tsv("a\tb\r\nc"), "a\\tb\\r\\nc"); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f170ed9..4a28920 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,8 +6,10 @@ pub mod api; pub mod auth; pub mod channels; pub mod completions; +pub mod emoji; pub mod files; pub mod messages; +pub mod pins; pub mod reactions; pub mod reminders; pub mod root; @@ -19,8 +21,10 @@ pub use api::ApiCmd; pub use auth::AuthCmd; pub use channels::ChannelsCmd; pub use completions::{generate_completions, CompletionsArgs}; +pub use emoji::EmojiCmd; pub use files::FilesCmd; pub use messages::MessagesCmd; +pub use pins::PinsCmd; pub use reactions::ReactionsCmd; pub use reminders::RemindersCmd; pub use root::{Cli, Commands}; diff --git a/src/cli/pins.rs b/src/cli/pins.rs new file mode 100644 index 0000000..4ec6677 --- /dev/null +++ b/src/cli/pins.rs @@ -0,0 +1,209 @@ +//! Pin commands for Slack CLI. + +use std::io::{self, Write}; + +use clap::{Args, Subcommand}; + +use crate::api::pin_emoji_ops::PinItem; +use crate::api::SlackClient; +use crate::error::Result; +use crate::output::{write_json, OutputMode}; + +/// Pin operations. +#[derive(Args, Debug)] +pub struct PinsCmd { + /// Pin command to run. + #[command(subcommand)] + pub command: PinsCommands, +} + +/// Pin subcommands. +#[derive(Subcommand, Debug)] +pub enum PinsCommands { + /// Pin a message in a channel. + Add { + /// Channel name or ID. + channel: String, + /// Message timestamp. + ts: String, + }, + + /// Remove a message pin from a channel. + Remove { + /// Channel name or ID. + channel: String, + /// Message timestamp. + ts: String, + }, + + /// List pins in a channel. + List { + /// Channel name or ID. + channel: String, + }, +} + +/// Run a pin command. +pub async fn run( + cmd: &PinsCmd, + plain: bool, + workspace: Option<&str>, + token_override: Option<&str>, +) -> Result<()> { + let token = crate::auth::resolve_token(workspace, token_override)?; + let client = SlackClient::new(token)?; + let output_mode = OutputMode::from_flags(plain); + + match &cmd.command { + PinsCommands::Add { channel, ts } => { + mutate_pin(&client, channel, ts, true, output_mode).await + } + PinsCommands::Remove { channel, ts } => { + mutate_pin(&client, channel, ts, false, output_mode).await + } + PinsCommands::List { channel } => list_pins(&client, channel, output_mode).await, + } +} + +async fn mutate_pin( + client: &SlackClient, + channel: &str, + ts: &str, + add: bool, + output_mode: OutputMode, +) -> Result<()> { + let channel = client.resolve_channel(channel).await?; + if add { + client.pins_add(&channel, ts).await?; + } else { + client.pins_remove(&channel, ts).await?; + } + + if output_mode == OutputMode::Plain { + let stdout = io::stdout(); + writeln!(stdout.lock(), "{}", escape_tsv(ts))?; + } else { + write_json(&serde_json::json!({ + "ok": true, + "channel": channel, + "ts": ts, + }))?; + } + Ok(()) +} + +async fn list_pins(client: &SlackClient, channel: &str, output_mode: OutputMode) -> Result<()> { + let channel = client.resolve_channel(channel).await?; + let response = client.pins_list(&channel).await?; + + if output_mode == OutputMode::Plain { + let stdout = io::stdout(); + let mut output = stdout.lock(); + for item in &response.items { + let (item_type, id, author, text) = plain_fields(item); + writeln!( + output, + "{}\t{}\t{}\t{}", + escape_tsv(item_type), + escape_tsv(id), + escape_tsv(author), + escape_tsv(text) + )?; + } + } else { + write_json(&serde_json::json!({ + "channel": channel, + "items": response.items, + }))?; + } + Ok(()) +} + +fn plain_fields(item: &PinItem) -> (&str, &str, &str, &str) { + let value = &item.0; + let message = value.get("message"); + let file = value.get("file"); + + ( + value + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or(""), + nested_string(message, "ts") + .or_else(|| nested_string(file, "id")) + .unwrap_or(""), + nested_string(message, "user") + .or_else(|| nested_string(file, "user")) + .unwrap_or(""), + nested_string(message, "text") + .or_else(|| nested_string(file, "title")) + .unwrap_or(""), + ) +} + +fn nested_string<'a>(value: Option<&'a serde_json::Value>, field: &str) -> Option<&'a str> { + value + .and_then(|value| value.get(field)) + .and_then(|value| value.as_str()) +} + +fn escape_tsv(value: &str) -> String { + value + .replace('\t', "\\t") + .replace('\n', "\\n") + .replace('\r', "\\r") +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + use crate::cli::{Cli, Commands}; + + #[test] + fn parse_pins_add() { + let cli = Cli::try_parse_from(["slack", "pins", "add", "general", "123.456"]).unwrap(); + match cli.command { + Commands::Pins(PinsCmd { + command: PinsCommands::Add { channel, ts }, + }) => { + assert_eq!(channel, "general"); + assert_eq!(ts, "123.456"); + } + _ => panic!("expected pins add command"), + } + } + + #[test] + fn parse_pins_remove() { + let cli = Cli::try_parse_from(["slack", "pins", "remove", "C12345678", "123.456"]).unwrap(); + assert!(matches!( + cli.command, + Commands::Pins(PinsCmd { + command: PinsCommands::Remove { .. } + }) + )); + } + + #[test] + fn parse_pins_list() { + let cli = Cli::try_parse_from(["slack", "pins", "list", "#general"]).unwrap(); + assert!(matches!( + cli.command, + Commands::Pins(PinsCmd { + command: PinsCommands::List { .. } + }) + )); + } + + #[test] + fn extracts_plain_fields_and_escapes_all_tsv_controls() { + let item = PinItem(serde_json::json!({ + "type": "message", + "message": {"ts": "1", "user": "U1", "text": "a\tb\r\nc"} + })); + assert_eq!(plain_fields(&item), ("message", "1", "U1", "a\tb\r\nc")); + assert_eq!(escape_tsv("a\tb\r\nc"), "a\\tb\\r\\nc"); + } +} diff --git a/src/cli/root.rs b/src/cli/root.rs index 86fbba0..4067f3d 100644 --- a/src/cli/root.rs +++ b/src/cli/root.rs @@ -8,8 +8,10 @@ use super::api::ApiCmd; use super::auth::AuthCmd; use super::channels::ChannelsCmd; use super::completions::CompletionsArgs; +use super::emoji::EmojiCmd; use super::files::FilesCmd; use super::messages::MessagesCmd; +use super::pins::PinsCmd; use super::reactions::ReactionsCmd; use super::reminders::RemindersCmd; use super::status::StatusCmd; @@ -75,6 +77,12 @@ pub enum Commands { #[command(alias = "r")] Reactions(ReactionsCmd), + /// Pin operations + Pins(PinsCmd), + + /// Custom emoji operations + Emoji(EmojiCmd), + /// User status/presence #[command(alias = "s")] Status(StatusCmd), diff --git a/src/main.rs b/src/main.rs index 6aebf51..83ec730 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,8 @@ use clap::Parser; use tracing_subscriber::EnvFilter; use slack_cli::cli::{ - api, auth, channels, files, generate_completions, messages, reactions, reminders, status, - users, Cli, Commands, + api, auth, channels, emoji, files, generate_completions, messages, pins, reactions, reminders, + status, users, Cli, Commands, }; use slack_cli::error::SlackError; use slack_cli::output::OutputMode; @@ -117,6 +117,24 @@ async fn run_command(cli: &Cli) -> Result<(), SlackError> { ) .await } + Commands::Pins(cmd) => { + pins::run( + cmd, + cli.plain, + cli.workspace.as_deref(), + cli.token.as_deref(), + ) + .await + } + Commands::Emoji(cmd) => { + emoji::run( + cmd, + cli.plain, + cli.workspace.as_deref(), + cli.token.as_deref(), + ) + .await + } Commands::Status(cmd) => { status::run( cmd, diff --git a/tests/cli_pins_emoji.rs b/tests/cli_pins_emoji.rs new file mode 100644 index 0000000..3874053 --- /dev/null +++ b/tests/cli_pins_emoji.rs @@ -0,0 +1,390 @@ +use std::path::Path; + +use assert_cmd::cargo::cargo_bin_cmd; +use clap::{CommandFactory, Parser}; +use mockito::{Matcher, ServerGuard}; +use predicates::prelude::*; +use serde_json::Value; +use slack_cli::cli::{Cli, Commands}; +use tempfile::TempDir; + +const TOKEN: &str = "xoxp-test-token-12345678901234"; +const STORED_TOKEN: &str = "xoxp-workspace-token-1234567890"; + +async fn mock_server() -> ServerGuard { + mockito::Server::new_async().await +} + +fn isolated_command(server: &ServerGuard, store_path: &Path) -> assert_cmd::Command { + let mut cmd = cargo_bin_cmd!("slack"); + cmd.env("SLACK_API_BASE_URL", server.url()) + .env("SLACK_TOKEN_STORE_PATH", store_path) + .env_remove("SLACK_TOKEN") + .env_remove("SLACK_WORKSPACE") + .env_remove("SLACK_PLAIN"); + cmd +} + +fn command(server: &ServerGuard, temp: &TempDir) -> assert_cmd::Command { + let mut cmd = isolated_command(server, &temp.path().join("no-tokens.json")); + cmd.env("SLACK_TOKEN", TOKEN); + cmd +} + +fn write_workspace_store(temp: &TempDir) -> std::path::PathBuf { + let path = temp.path().join("tokens.json"); + let data = serde_json::json!({ + "tokens": { + "T12345678": { + "token_type": "user_o_auth", + "access_token": STORED_TOKEN, + "team_id": "T12345678", + "team_name": "Workspace One", + "team_domain": "workspace-one", + "user_id": "U12345678", + "created_at": "2024-01-01T00:00:00Z", + "scopes": [] + } + }, + "default": "T12345678", + "workspaces": ["T12345678"] + }); + std::fs::write(&path, data.to_string()).unwrap(); + path +} + +#[test] +fn root_routes_pins_and_emoji_and_renders_help() { + Cli::command().debug_assert(); + + let pins = Cli::try_parse_from(["slack", "pins", "list", "C12345678"]).unwrap(); + assert!(matches!(pins.command, Commands::Pins(_))); + let emoji = Cli::try_parse_from(["slack", "emoji", "list"]).unwrap(); + assert!(matches!(emoji.command, Commands::Emoji(_))); + + let help = Cli::command().render_help().to_string(); + assert!(help.contains("pins")); + assert!(help.contains("emoji")); + + let pins_help = Cli::try_parse_from(["slack", "pins", "--help"]).unwrap_err(); + assert_eq!(pins_help.kind(), clap::error::ErrorKind::DisplayHelp); + let pins_help = pins_help.to_string(); + assert!(pins_help.contains("add")); + assert!(pins_help.contains("remove")); + assert!(pins_help.contains("list")); + + let emoji_help = Cli::try_parse_from(["slack", "emoji", "--help"]).unwrap_err(); + assert_eq!(emoji_help.kind(), clap::error::ErrorKind::DisplayHelp); + assert!(emoji_help.to_string().contains("list")); +} + +#[tokio::test] +async fn pins_add_posts_form_and_outputs_json_with_global_token() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let add = server + .mock("POST", "/pins.add") + .match_header("authorization", format!("Bearer {}", TOKEN).as_str()) + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), "C12345678".into()), + Matcher::UrlEncoded("timestamp".into(), "111.222".into()), + ])) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + let output = isolated_command(&server, &temp.path().join("no-tokens.json")) + .args(["pins", "add", "C12345678", "111.222", "--token", TOKEN]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + serde_json::json!({"ok":true,"channel":"C12345678","ts":"111.222"}) + ); + add.assert_async().await; +} + +#[tokio::test] +async fn pins_remove_resolves_channel_name_and_outputs_json() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let channels = server + .mock("POST", "/conversations.list") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("limit".into(), "200".into()), + Matcher::UrlEncoded("exclude_archived".into(), "false".into()), + Matcher::UrlEncoded( + "types".into(), + "public_channel,private_channel,mpim,im".into(), + ), + ])) + .with_body(r#"{"ok":true,"channels":[{"id":"C87654321","name":"general"}],"response_metadata":{"next_cursor":""}}"#) + .create_async() + .await; + let remove = server + .mock("POST", "/pins.remove") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), "C87654321".into()), + Matcher::UrlEncoded("timestamp".into(), "333.444".into()), + ])) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + let output = command(&server, &temp) + .args(["pins", "remove", "#general", "333.444"]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + serde_json::json!({"ok":true,"channel":"C87654321","ts":"333.444"}) + ); + channels.assert_async().await; + remove.assert_async().await; +} + +#[tokio::test] +async fn pins_remove_plain_outputs_only_escaped_timestamp() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let remove = server + .mock("POST", "/pins.remove") + .match_body(Matcher::UrlEncoded( + "timestamp".into(), + "333\t444\r\n".into(), + )) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + command(&server, &temp) + .args(["pins", "remove", "G12345678", "333\t444\r\n", "--plain"]) + .assert() + .success() + .stdout("333\\t444\\r\\n\n"); + remove.assert_async().await; +} + +#[tokio::test] +async fn pins_list_retains_all_item_shapes_metadata_and_order() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/pins.list") + .match_body(Matcher::UrlEncoded("channel".into(), "C12345678".into())) + .with_body( + r#"{"ok":true,"items":[ + {"type":"message","created":10,"created_by":"UCREATOR1","channel":"C12345678","message":{"ts":"1.1","user":"U11111111","text":"first","blocks":[{"type":"section"}]}}, + {"type":"file","created":20,"file":{"id":"F12345678","user":"U22222222","title":"report","mimetype":"text/plain"}}, + {"type":"file_comment","file":{"id":"F87654321","title":"notes"},"comment":{"id":"Fc123","user":"U33333333","comment":"keep me"},"optional_metadata":{"future":true}} + ]}"#, + ) + .create_async() + .await; + + let output = command(&server, &temp) + .args(["pins", "list", "C12345678"]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["channel"], "C12345678"); + assert_eq!(json["items"].as_array().unwrap().len(), 3); + assert_eq!(json["items"][0]["message"]["blocks"][0]["type"], "section"); + assert_eq!(json["items"][1]["file"]["id"], "F12345678"); + assert_eq!(json["items"][2]["comment"]["comment"], "keep me"); + assert_eq!(json["items"][2]["optional_metadata"]["future"], true); + list.assert_async().await; +} + +#[tokio::test] +async fn pins_list_plain_formats_records_escapes_fields_and_handles_missing_fields() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/pins.list") + .match_body(Matcher::UrlEncoded("channel".into(), "D12345678".into())) + .with_body( + r#"{"ok":true,"items":[ + {"type":"message","message":{"ts":"1\t2","user":"U1\nX","text":"line\r\ntext"}}, + {"type":"file","file":{"id":"F1","user":"U2","title":"tab\ttitle"}}, + {"type":"future","metadata":{"kept":true}} + ]}"#, + ) + .create_async() + .await; + + command(&server, &temp) + .args(["--plain", "pins", "list", "D12345678"]) + .assert() + .success() + .stdout( + "message\t1\\t2\tU1\\nX\tline\\r\\ntext\nfile\tF1\tU2\ttab\\ttitle\nfuture\t\t\t\n", + ); + list.assert_async().await; +} + +#[tokio::test] +async fn pins_list_empty_prints_no_plain_rows() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/pins.list") + .match_body(Matcher::UrlEncoded("channel".into(), "C12345678".into())) + .with_body(r#"{"ok":true,"items":[]}"#) + .create_async() + .await; + + command(&server, &temp) + .args(["pins", "list", "C12345678", "--plain"]) + .assert() + .success() + .stdout(""); + list.assert_async().await; +} + +#[tokio::test] +async fn pin_api_errors_are_propagated() { + for (subcommand, endpoint, error) in [ + ("add", "/pins.add", "already_pinned"), + ("remove", "/pins.remove", "not_pinned"), + ] { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let api = server + .mock("POST", endpoint) + .with_body(format!(r#"{{"ok":false,"error":"{}"}}"#, error)) + .create_async() + .await; + command(&server, &temp) + .args(["pins", subcommand, "C12345678", "111.222"]) + .assert() + .failure() + .stdout(predicate::str::contains(error)); + api.assert_async().await; + } + + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/pins.list") + .with_body(r#"{"ok":false,"error":"missing_scope"}"#) + .create_async() + .await; + command(&server, &temp) + .args(["pins", "list", "C12345678"]) + .assert() + .failure() + .stdout(predicate::str::contains("missing_scope")); + list.assert_async().await; +} + +#[tokio::test] +async fn emoji_list_posts_empty_form_and_preserves_urls_and_aliases() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let emoji = server + .mock("POST", "/emoji.list") + .match_body(Matcher::Exact(String::new())) + .with_body(r#"{"ok":true,"emoji":{"party":"https://emoji.slack-edge.com/T/party/abc.png","party_alias":"alias:party"}}"#) + .create_async() + .await; + + let output = command(&server, &temp) + .args(["emoji", "list"]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::(&output.stdout).unwrap(), + serde_json::json!({"emoji": { + "party": "https://emoji.slack-edge.com/T/party/abc.png", + "party_alias": "alias:party" + }}) + ); + emoji.assert_async().await; +} + +#[tokio::test] +async fn emoji_plain_is_sorted_escaped_and_empty_output_is_empty() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let populated = server + .mock("POST", "/emoji.list") + .with_body(r#"{"ok":true,"emoji":{"zeta":"alias:a\tb","alpha\nname":"https://example.com/a\r.png"}}"#) + .create_async() + .await; + command(&server, &temp) + .args(["--plain", "emoji", "list"]) + .assert() + .success() + .stdout("alpha\\nname\thttps://example.com/a\\r.png\nzeta\talias:a\\tb\n"); + populated.assert_async().await; + + let empty = server + .mock("POST", "/emoji.list") + .with_body(r#"{"ok":true,"emoji":{}}"#) + .create_async() + .await; + command(&server, &temp) + .args(["emoji", "list", "--plain"]) + .assert() + .success() + .stdout(""); + empty.assert_async().await; +} + +#[tokio::test] +async fn emoji_uses_global_workspace_selection() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let store = write_workspace_store(&temp); + let emoji = server + .mock("POST", "/emoji.list") + .match_header("authorization", format!("Bearer {}", STORED_TOKEN).as_str()) + .with_body(r#"{"ok":true,"emoji":{}}"#) + .create_async() + .await; + + isolated_command(&server, &store) + .args(["emoji", "list", "--workspace", "workspace-one"]) + .assert() + .success(); + emoji.assert_async().await; +} + +#[tokio::test] +async fn emoji_missing_scope_and_authentication_errors_are_propagated_without_extra_io() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let missing_scope = server + .mock("POST", "/emoji.list") + .with_body(r#"{"ok":false,"error":"missing_scope"}"#) + .create_async() + .await; + command(&server, &temp) + .args(["emoji", "list"]) + .assert() + .failure() + .stdout(predicate::str::contains("missing_scope")); + missing_scope.assert_async().await; + + let no_io = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + isolated_command(&server, &temp.path().join("missing-store.json")) + .args(["pins", "list", "C12345678"]) + .assert() + .failure() + .stdout(predicate::str::contains("auth_required")); + isolated_command(&server, &temp.path().join("missing-store.json")) + .args(["emoji", "list"]) + .assert() + .failure() + .stdout(predicate::str::contains("auth_required")); + no_io.assert_async().await; +} From fea1ceb100f5a1eb1f4c66d7e974fb82502c0971 Mon Sep 17 00:00:00 2001 From: Chris Raethke Date: Wed, 9 Sep 2026 22:46:57 +1000 Subject: [PATCH 03/22] feat(bookmarks): Channel bookmarks command group --- CHANGELOG.md | 2 + README.md | 19 ++ skills/slack/BOOKMARKS.md | 45 ++++ skills/slack/SKILL.md | 11 + src/api/bookmark_ops.rs | 111 ++++++++++ src/api/mod.rs | 1 + src/cli/bookmarks.rs | 353 +++++++++++++++++++++++++++++ src/cli/mod.rs | 2 + src/cli/root.rs | 4 + src/main.rs | 13 +- tests/cli_bookmarks_ops.rs | 439 +++++++++++++++++++++++++++++++++++++ 11 files changed, 998 insertions(+), 2 deletions(-) create mode 100644 skills/slack/BOOKMARKS.md create mode 100644 src/api/bookmark_ops.rs create mode 100644 src/cli/bookmarks.rs create mode 100644 tests/cli_bookmarks_ops.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d3a0d8..0338e4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 message, file, and other pin item payloads in JSON output. - **Custom emoji list**: list workspace custom emoji URLs and aliases with `slack emoji list`, with sorted TSV output available through `--plain`. +- **Channel bookmarks**: list, add, and remove channel link bookmarks with + `slack bookmarks`, including optional emoji and script-friendly TSV output. ## [0.2.1] - 2026-09-08 diff --git a/README.md b/README.md index 6828adf..6c58721 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,25 @@ slack --plain emoji list including unchanged image URLs and `alias:` values. It does not include Slack's built-in Unicode emoji. +### Bookmarks (`slack bookmarks`) + +```bash +# List channel bookmarks +slack bookmarks list "#general" + +# Add a link bookmark, optionally with an emoji +slack bookmarks add "#general" "Team docs" https://example.com/docs +slack bookmarks add C123456789 "Runbook" https://example.com/runbook --emoji :books: + +# Remove a bookmark +slack bookmarks remove "#general" Bk123456789 +``` + +Listing requires `bookmarks:read`; adding and removing require +`bookmarks:write`. JSON output retains optional bookmark metadata. With +`--plain`, lists use `idtitlelinkemoji`, while mutations print +the bookmark ID. + ### Status (`slack status` or `slack s`) ```bash diff --git a/skills/slack/BOOKMARKS.md b/skills/slack/BOOKMARKS.md new file mode 100644 index 0000000..67e48b3 --- /dev/null +++ b/skills/slack/BOOKMARKS.md @@ -0,0 +1,45 @@ +# slack bookmarks + +Manage link bookmarks in a Slack channel. Channel names such as `#general` are +resolved to channel IDs before the bookmark API is called. + +## List bookmarks + +```bash +slack bookmarks list +slack bookmarks list "#general" +slack --plain bookmarks list C123456789 +``` + +JSON output is `{ "channel": "", "bookmarks": [...] }` and retains optional +bookmark metadata returned by Slack. Plain output has one bookmark per line as +`idtitlelinkemoji`, in API order. Missing emoji produce an empty +final column. This command requires `bookmarks:read`. + +## Add a bookmark + +```bash +slack bookmarks add <link> [--emoji <emoji>] +slack bookmarks add "#general" "Runbook" https://example.com/runbook +slack bookmarks add C123456789 "Team docs" https://example.com/docs --emoji books +slack bookmarks add C123456789 "Team docs" https://example.com/docs --emoji :books: +``` + +Links must be absolute HTTP(S) URLs without URL user information. Emoji names +may have surrounding colons; both `books` and `:books:` are sent to Slack as +`:books:`. JSON output is +`{ "ok": true, "channel": "<id>", "bookmark": {...} }`; `--plain` prints the +returned bookmark ID. This command requires `bookmarks:write`. + +## Remove a bookmark + +```bash +slack bookmarks remove <channel> <bookmark_id> +slack bookmarks remove "#general" Bk123456789 +``` + +JSON output is +`{ "ok": true, "channel": "<id>", "bookmark_id": "<bookmark_id>" }`; +`--plain` prints the removed bookmark ID. This command requires +`bookmarks:write`. Missing bookmarks and permission errors are returned as +Slack API errors. diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md index d9f875f..2856a9e 100644 --- a/skills/slack/SKILL.md +++ b/skills/slack/SKILL.md @@ -216,6 +216,16 @@ slack --plain emoji list This lists custom workspace emoji only and requires `emoji:read`. See [EMOJI.md](EMOJI.md). +### Manage channel bookmarks +```bash +slack bookmarks list "#general" +slack bookmarks add "#general" "Runbook" https://example.com/runbook --emoji :books: +slack bookmarks remove "#general" Bk123456789 +``` + +Listing requires `bookmarks:read`; adding and removing require +`bookmarks:write`. See [BOOKMARKS.md](BOOKMARKS.md). + ### Set status ```bash slack status set "In a meeting" --emoji meeting --expires 1h @@ -234,5 +244,6 @@ slack status clear | [REACTIONS.md](REACTIONS.md) | `reactions add/remove/list` | | [PINS.md](PINS.md) | `pins add/remove/list` | | [EMOJI.md](EMOJI.md) | `emoji list` | +| [BOOKMARKS.md](BOOKMARKS.md) | `bookmarks list/add/remove` | | [STATUS.md](STATUS.md) | `status get/set/clear/presence` | | [REMINDERS.md](REMINDERS.md) | `reminders list/add/complete/delete` | diff --git a/src/api/bookmark_ops.rs b/src/api/bookmark_ops.rs new file mode 100644 index 0000000..acf8afd --- /dev/null +++ b/src/api/bookmark_ops.rs @@ -0,0 +1,111 @@ +//! Slack Web API operations for channel bookmarks. + +use serde::{Deserialize, Serialize}; + +use crate::error::Result; + +use super::client::SlackClient; + +#[derive(Debug, Serialize)] +struct BookmarkListParams<'a> { + channel_id: &'a str, +} + +#[derive(Debug, Serialize)] +struct BookmarkAddParams<'a> { + channel_id: &'a str, + title: &'a str, + #[serde(rename = "type")] + bookmark_type: &'static str, + link: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + emoji: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +struct BookmarkRemoveParams<'a> { + channel_id: &'a str, + bookmark_id: &'a str, +} + +#[derive(Debug, Deserialize)] +struct BookmarkRemoveResponse {} + +/// A channel bookmark returned by Slack. +/// +/// Slack may add type-specific or workspace-specific fields to bookmark +/// objects. Unknown fields are retained so JSON output does not discard that +/// metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Bookmark { + /// Bookmark ID. + pub id: String, + /// Bookmark title. + pub title: String, + /// Bookmark destination URL. + pub link: String, + /// Bookmark emoji, when set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub emoji: Option<String>, + /// Additional metadata returned by Slack. + #[serde(flatten)] + pub metadata: serde_json::Map<String, serde_json::Value>, +} + +/// Response from `bookmarks.list`. +#[derive(Debug, Serialize, Deserialize)] +pub struct BookmarksListResponse { + /// Bookmarks in Slack API order. + #[serde(default)] + pub bookmarks: Vec<Bookmark>, +} + +/// Response from `bookmarks.add`. +#[derive(Debug, Serialize, Deserialize)] +pub struct BookmarksAddResponse { + /// The newly created bookmark. + pub bookmark: Bookmark, +} + +impl SlackClient { + /// List all bookmarks in a channel. + pub async fn bookmarks_list(&self, channel_id: &str) -> Result<BookmarksListResponse> { + self.request("bookmarks.list", &BookmarkListParams { channel_id }) + .await + } + + /// Add a link bookmark to a channel. + pub async fn bookmarks_add( + &self, + channel_id: &str, + title: &str, + link: &str, + emoji: Option<&str>, + ) -> Result<BookmarksAddResponse> { + self.request( + "bookmarks.add", + &BookmarkAddParams { + channel_id, + title, + bookmark_type: "link", + link, + emoji, + }, + ) + .await + } + + /// Remove a bookmark from a channel. + pub async fn bookmarks_remove(&self, channel_id: &str, bookmark_id: &str) -> Result<()> { + let _: BookmarkRemoveResponse = self + .request( + "bookmarks.remove", + &BookmarkRemoveParams { + channel_id, + bookmark_id, + }, + ) + .await?; + Ok(()) + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 22fe7fd..de57ae4 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -7,6 +7,7 @@ //! - Response types and API method parameters //! - Name resolution helpers +pub mod bookmark_ops; mod client; pub mod edge; pub mod identity_ops; diff --git a/src/cli/bookmarks.rs b/src/cli/bookmarks.rs new file mode 100644 index 0000000..657f035 --- /dev/null +++ b/src/cli/bookmarks.rs @@ -0,0 +1,353 @@ +//! Channel bookmark commands for Slack CLI. + +use std::io::{self, Write}; + +use clap::{Args, Subcommand}; +use url::Url; + +use crate::api::SlackClient; +use crate::error::{Result, SlackError}; +use crate::output::{write_json, OutputMode}; + +/// Channel bookmark operations. +#[derive(Args, Debug)] +pub struct BookmarksCmd { + /// Bookmark command to run. + #[command(subcommand)] + pub command: BookmarksCommands, +} + +/// Bookmark subcommands. +#[derive(Subcommand, Debug)] +pub enum BookmarksCommands { + /// List bookmarks in a channel. + List { + /// Channel name or ID. + channel: String, + }, + + /// Add a link bookmark to a channel. + Add { + /// Channel name or ID. + channel: String, + /// Bookmark title. + title: String, + /// Absolute HTTP or HTTPS link. + link: String, + /// Emoji name, with or without surrounding colons. + #[arg(long)] + emoji: Option<String>, + }, + + /// Remove a bookmark from a channel. + Remove { + /// Channel name or ID. + channel: String, + /// Bookmark ID. + bookmark_id: String, + }, +} + +/// Run a bookmark command. +pub async fn run( + cmd: &BookmarksCmd, + plain: bool, + workspace: Option<&str>, + token_override: Option<&str>, +) -> Result<()> { + let token = crate::auth::resolve_token(workspace, token_override)?; + let client = SlackClient::new(token)?; + let output_mode = OutputMode::from_flags(plain); + + match &cmd.command { + BookmarksCommands::List { channel } => list_bookmarks(&client, channel, output_mode).await, + BookmarksCommands::Add { + channel, + title, + link, + emoji, + } => add_bookmark(&client, channel, title, link, emoji.as_deref(), output_mode).await, + BookmarksCommands::Remove { + channel, + bookmark_id, + } => remove_bookmark(&client, channel, bookmark_id, output_mode).await, + } +} + +async fn list_bookmarks( + client: &SlackClient, + channel: &str, + output_mode: OutputMode, +) -> Result<()> { + let channel = client.resolve_channel(channel).await?; + let response = client.bookmarks_list(&channel).await?; + + if output_mode == OutputMode::Plain { + let stdout = io::stdout(); + let mut output = stdout.lock(); + for bookmark in &response.bookmarks { + writeln!( + output, + "{}\t{}\t{}\t{}", + escape_tsv(&bookmark.id), + escape_tsv(&bookmark.title), + escape_tsv(&bookmark.link), + escape_tsv(bookmark.emoji.as_deref().unwrap_or("")) + )?; + } + } else { + write_json(&serde_json::json!({ + "channel": channel, + "bookmarks": response.bookmarks, + }))?; + } + Ok(()) +} + +async fn add_bookmark( + client: &SlackClient, + channel: &str, + title: &str, + link: &str, + emoji: Option<&str>, + output_mode: OutputMode, +) -> Result<()> { + validate_title(title)?; + validate_link(link)?; + let emoji = emoji.map(normalize_emoji).transpose()?; + + let channel = client.resolve_channel(channel).await?; + let response = client + .bookmarks_add(&channel, title, link, emoji.as_deref()) + .await?; + + if output_mode == OutputMode::Plain { + let stdout = io::stdout(); + writeln!(stdout.lock(), "{}", escape_tsv(&response.bookmark.id))?; + } else { + write_json(&serde_json::json!({ + "ok": true, + "channel": channel, + "bookmark": response.bookmark, + }))?; + } + Ok(()) +} + +async fn remove_bookmark( + client: &SlackClient, + channel: &str, + bookmark_id: &str, + output_mode: OutputMode, +) -> Result<()> { + if bookmark_id.trim().is_empty() { + return Err(SlackError::Usage( + "bookmark ID must not be empty".to_string(), + )); + } + + let channel = client.resolve_channel(channel).await?; + client.bookmarks_remove(&channel, bookmark_id).await?; + + if output_mode == OutputMode::Plain { + let stdout = io::stdout(); + writeln!(stdout.lock(), "{}", escape_tsv(bookmark_id))?; + } else { + write_json(&serde_json::json!({ + "ok": true, + "channel": channel, + "bookmark_id": bookmark_id, + }))?; + } + Ok(()) +} + +fn validate_title(title: &str) -> Result<()> { + if title.trim().is_empty() { + Err(SlackError::Usage( + "bookmark title must not be empty".to_string(), + )) + } else { + Ok(()) + } +} + +fn validate_link(link: &str) -> Result<()> { + let url = Url::parse(link).map_err(|_| { + SlackError::Usage("bookmark link must be an absolute HTTP(S) URL".to_string()) + })?; + if link.trim() != link || !matches!(url.scheme(), "http" | "https") || !url.has_host() { + return Err(SlackError::Usage( + "bookmark link must be an absolute HTTP(S) URL".to_string(), + )); + } + if !url.username().is_empty() || url.password().is_some() || authority_contains_at_sign(link) { + return Err(SlackError::Usage( + "bookmark link must not contain user information".to_string(), + )); + } + Ok(()) +} + +fn authority_contains_at_sign(link: &str) -> bool { + link.split_once(':') + .map(|(_, rest)| rest.trim_start_matches(['/', '\\'])) + .and_then(|rest| rest.split(['/', '\\', '?', '#']).next()) + .map(|authority| authority.contains('@')) + .unwrap_or(false) +} + +fn normalize_emoji(emoji: &str) -> Result<String> { + if emoji.is_empty() || emoji.trim() != emoji { + return Err(invalid_emoji()); + } + + let name = match (emoji.strip_prefix(':'), emoji.strip_suffix(':')) { + (Some(without_prefix), Some(_)) if emoji.len() >= 2 => { + without_prefix.strip_suffix(':').unwrap_or(without_prefix) + } + (None, None) => emoji, + _ => return Err(invalid_emoji()), + }; + + if name.is_empty() + || !name + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '+')) + { + return Err(invalid_emoji()); + } + + Ok(format!(":{}:", name)) +} + +fn invalid_emoji() -> SlackError { + SlackError::Usage("emoji must be a non-empty name with optional surrounding colons".to_string()) +} + +fn escape_tsv(value: &str) -> String { + value + .replace('\t', "\\t") + .replace('\n', "\\n") + .replace('\r', "\\r") +} + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::*; + use crate::cli::{Cli, Commands}; + + #[test] + fn parse_bookmarks_list() { + let cli = Cli::try_parse_from(["slack", "bookmarks", "list", "#general"]).unwrap(); + match cli.command { + Commands::Bookmarks(BookmarksCmd { + command: BookmarksCommands::List { channel }, + }) => assert_eq!(channel, "#general"), + _ => panic!("expected bookmarks list command"), + } + } + + #[test] + fn parse_bookmarks_add_defaults_emoji_to_absent() { + let cli = Cli::try_parse_from([ + "slack", + "bookmarks", + "add", + "C12345678", + "Docs", + "https://example.com/docs", + ]) + .unwrap(); + match cli.command { + Commands::Bookmarks(BookmarksCmd { + command: + BookmarksCommands::Add { + channel, + title, + link, + emoji, + }, + }) => { + assert_eq!(channel, "C12345678"); + assert_eq!(title, "Docs"); + assert_eq!(link, "https://example.com/docs"); + assert_eq!(emoji, None); + } + _ => panic!("expected bookmarks add command"), + } + } + + #[test] + fn parse_bookmarks_add_with_long_emoji() { + let cli = Cli::try_parse_from([ + "slack", + "bookmarks", + "add", + "C12345678", + "Docs", + "https://example.com", + "--emoji", + ":books:", + ]) + .unwrap(); + assert!(matches!( + cli.command, + Commands::Bookmarks(BookmarksCmd { + command: BookmarksCommands::Add { + emoji: Some(ref value), + .. + } + }) if value == ":books:" + )); + assert!(Cli::try_parse_from([ + "slack", + "bookmarks", + "add", + "C12345678", + "Docs", + "https://example.com", + "-e", + "books", + ]) + .is_err()); + } + + #[test] + fn parse_bookmarks_remove() { + let cli = Cli::try_parse_from(["slack", "bookmarks", "remove", "G12345678", "Bk12345678"]) + .unwrap(); + assert!(matches!( + cli.command, + Commands::Bookmarks(BookmarksCmd { + command: BookmarksCommands::Remove { .. } + }) + )); + } + + #[test] + fn validates_and_normalizes_inputs() { + assert!(validate_title("title").is_ok()); + assert!(validate_title(" \t\n").is_err()); + assert!(validate_link("https://example.com/path").is_ok()); + assert!(validate_link("http://example.com").is_ok()); + assert!(validate_link("relative/path").is_err()); + assert!(validate_link("ftp://example.com").is_err()); + assert!(validate_link("https://user@example.com").is_err()); + assert!(validate_link("https://@example.com").is_err()); + assert!(validate_link(" https://example.com").is_err()); + assert!(validate_link("https://example.com/path@user").is_ok()); + assert_eq!(normalize_emoji("books").unwrap(), ":books:"); + assert_eq!(normalize_emoji(":books:").unwrap(), ":books:"); + assert!(normalize_emoji("").is_err()); + assert!(normalize_emoji(":books").is_err()); + assert!(normalize_emoji("bad emoji").is_err()); + } + + #[test] + fn escapes_all_tsv_controls() { + assert_eq!(escape_tsv("a\tb\r\nc"), "a\\tb\\r\\nc"); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 4a28920..b91d9ee 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,6 +4,7 @@ pub mod api; pub mod auth; +pub mod bookmarks; pub mod channels; pub mod completions; pub mod emoji; @@ -19,6 +20,7 @@ pub mod users; pub use api::ApiCmd; pub use auth::AuthCmd; +pub use bookmarks::BookmarksCmd; pub use channels::ChannelsCmd; pub use completions::{generate_completions, CompletionsArgs}; pub use emoji::EmojiCmd; diff --git a/src/cli/root.rs b/src/cli/root.rs index 4067f3d..20edc14 100644 --- a/src/cli/root.rs +++ b/src/cli/root.rs @@ -6,6 +6,7 @@ use clap::{Parser, Subcommand}; use super::api::ApiCmd; use super::auth::AuthCmd; +use super::bookmarks::BookmarksCmd; use super::channels::ChannelsCmd; use super::completions::CompletionsArgs; use super::emoji::EmojiCmd; @@ -83,6 +84,9 @@ pub enum Commands { /// Custom emoji operations Emoji(EmojiCmd), + /// Channel bookmark operations + Bookmarks(BookmarksCmd), + /// User status/presence #[command(alias = "s")] Status(StatusCmd), diff --git a/src/main.rs b/src/main.rs index 83ec730..d9fbc5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,8 @@ use clap::Parser; use tracing_subscriber::EnvFilter; use slack_cli::cli::{ - api, auth, channels, emoji, files, generate_completions, messages, pins, reactions, reminders, - status, users, Cli, Commands, + api, auth, bookmarks, channels, emoji, files, generate_completions, messages, pins, 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::Bookmarks(cmd) => { + bookmarks::run( + cmd, + cli.plain, + cli.workspace.as_deref(), + cli.token.as_deref(), + ) + .await + } Commands::Status(cmd) => { status::run( cmd, diff --git a/tests/cli_bookmarks_ops.rs b/tests/cli_bookmarks_ops.rs new file mode 100644 index 0000000..8a6933c --- /dev/null +++ b/tests/cli_bookmarks_ops.rs @@ -0,0 +1,439 @@ +use std::path::Path; + +use assert_cmd::cargo::cargo_bin_cmd; +use clap::{CommandFactory, Parser}; +use mockito::{Matcher, ServerGuard}; +use predicates::prelude::*; +use serde_json::Value; +use slack_cli::cli::{Cli, Commands}; +use tempfile::TempDir; + +const TOKEN: &str = "xoxp-test-token-12345678901234"; +const STORED_TOKEN: &str = "xoxp-workspace-token-1234567890"; + +async fn mock_server() -> ServerGuard { + mockito::Server::new_async().await +} + +fn isolated_command(server: &ServerGuard, store_path: &Path) -> assert_cmd::Command { + let mut cmd = cargo_bin_cmd!("slack"); + cmd.env("SLACK_API_BASE_URL", server.url()) + .env("SLACK_TOKEN_STORE_PATH", store_path) + .env_remove("SLACK_TOKEN") + .env_remove("SLACK_WORKSPACE") + .env_remove("SLACK_PLAIN"); + cmd +} + +fn command(server: &ServerGuard, temp: &TempDir) -> assert_cmd::Command { + let mut cmd = isolated_command(server, &temp.path().join("no-tokens.json")); + cmd.env("SLACK_TOKEN", TOKEN); + cmd +} + +fn write_workspace_store(temp: &TempDir) -> std::path::PathBuf { + let path = temp.path().join("tokens.json"); + let data = serde_json::json!({ + "tokens": { + "T12345678": { + "token_type": "user_o_auth", + "access_token": STORED_TOKEN, + "team_id": "T12345678", + "team_name": "Workspace One", + "team_domain": "workspace-one", + "user_id": "U12345678", + "created_at": "2024-01-01T00:00:00Z", + "scopes": [] + } + }, + "default": "T12345678", + "workspaces": ["T12345678"] + }); + std::fs::write(&path, data.to_string()).unwrap(); + path +} + +#[test] +fn root_routes_bookmarks_renders_help_and_accepts_global_flags() { + Cli::command().debug_assert(); + + let cli = Cli::try_parse_from([ + "slack", + "bookmarks", + "list", + "C12345678", + "--plain", + "--workspace", + "workspace-one", + "--token", + TOKEN, + ]) + .unwrap(); + assert!(matches!(cli.command, Commands::Bookmarks(_))); + assert!(cli.plain); + assert_eq!(cli.workspace.as_deref(), Some("workspace-one")); + assert_eq!(cli.token.as_deref(), Some(TOKEN)); + + let help = Cli::command().render_help().to_string(); + assert!(help.contains("bookmarks")); + let bookmarks_help = Cli::try_parse_from(["slack", "bookmarks", "--help"]).unwrap_err(); + assert_eq!(bookmarks_help.kind(), clap::error::ErrorKind::DisplayHelp); + let bookmarks_help = bookmarks_help.to_string(); + assert!(bookmarks_help.contains("list")); + assert!(bookmarks_help.contains("add")); + assert!(bookmarks_help.contains("remove")); + + let add_help = Cli::try_parse_from(["slack", "bookmarks", "add", "--help"]).unwrap_err(); + assert_eq!(add_help.kind(), clap::error::ErrorKind::DisplayHelp); + assert!(add_help.to_string().contains("--emoji")); +} + +#[test] +fn bookmarks_required_operands_are_enforced() { + assert!(Cli::try_parse_from(["slack", "bookmarks", "list"]).is_err()); + assert!(Cli::try_parse_from(["slack", "bookmarks", "add", "C12345678", "Docs"]).is_err()); + assert!(Cli::try_parse_from(["slack", "bookmarks", "remove", "C12345678"]).is_err()); +} + +#[tokio::test] +async fn bookmarks_list_posts_exact_form_resolves_channel_and_preserves_json_metadata_order() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let channels = server + .mock("POST", "/conversations.list") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("limit".into(), "200".into()), + Matcher::UrlEncoded("exclude_archived".into(), "false".into()), + Matcher::UrlEncoded( + "types".into(), + "public_channel,private_channel,mpim,im".into(), + ), + ])) + .with_body(r#"{"ok":true,"channels":[{"id":"C87654321","name":"general"}],"response_metadata":{"next_cursor":""}}"#) + .create_async() + .await; + let list = server + .mock("POST", "/bookmarks.list") + .match_body(Matcher::Exact("channel_id=C87654321".to_string())) + .with_body( + r#"{"ok":true,"bookmarks":[ + {"id":"BkFIRST123","title":"First","link":"https://example.com/one","emoji":":one:","type":"link","date_created":10}, + {"id":"BkSECOND12","title":"Second","link":"https://example.com/two","icon_url":"https://example.com/icon.png","future":{"kept":true}} + ]}"#, + ) + .create_async() + .await; + + let output = command(&server, &temp) + .args(["bookmarks", "list", "#general"]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["channel"], "C87654321"); + let bookmarks = json["bookmarks"].as_array().unwrap(); + assert_eq!(bookmarks.len(), 2); + assert_eq!(bookmarks[0]["id"], "BkFIRST123"); + assert_eq!(bookmarks[0]["type"], "link"); + assert_eq!(bookmarks[0]["date_created"], 10); + assert_eq!(bookmarks[1]["id"], "BkSECOND12"); + assert_eq!(bookmarks[1]["emoji"], Value::Null); + assert_eq!(bookmarks[1]["future"]["kept"], true); + channels.assert_async().await; + list.assert_async().await; +} + +#[tokio::test] +async fn bookmarks_list_plain_escapes_every_column_and_empty_list_has_no_rows() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let populated = server + .mock("POST", "/bookmarks.list") + .match_body(Matcher::Exact("channel_id=D12345678".to_string())) + .with_body( + "{\"ok\":true,\"bookmarks\":[{\"id\":\"Bk\\t1\",\"title\":\"Line\\nTitle\",\"link\":\"https://example.com/a\\rb\",\"emoji\":\":book\\tmark:\"},{\"id\":\"Bk2\",\"title\":\"No emoji\",\"link\":\"https://example.com\"}]}" + ) + .create_async() + .await; + + command(&server, &temp) + .args(["--plain", "bookmarks", "list", "D12345678"]) + .assert() + .success() + .stdout("Bk\\t1\tLine\\nTitle\thttps://example.com/a\\rb\t:book\\tmark:\nBk2\tNo emoji\thttps://example.com\t\n"); + populated.assert_async().await; + + let empty = server + .mock("POST", "/bookmarks.list") + .match_body(Matcher::Exact("channel_id=C12345678".to_string())) + .with_body(r#"{"ok":true,"bookmarks":[]}"#) + .create_async() + .await; + command(&server, &temp) + .args(["bookmarks", "list", "C12345678", "--plain"]) + .assert() + .success() + .stdout(""); + empty.assert_async().await; +} + +#[tokio::test] +async fn bookmarks_add_omits_absent_emoji_and_outputs_returned_bookmark() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let add = server + .mock("POST", "/bookmarks.add") + .match_header("authorization", format!("Bearer {}", TOKEN).as_str()) + .match_body(Matcher::Exact( + "channel_id=C12345678&link=https%3A%2F%2Fexample.com%2Fdocs%3Fa%3D1%26b%3D2&title=Team+Docs&type=link" + .to_string(), + )) + .with_body(r#"{"ok":true,"bookmark":{"id":"Bk12345678","title":"Team Docs","link":"https://example.com/docs?a=1&b=2","type":"link","channel_id":"C12345678","date_updated":20}}"#) + .create_async() + .await; + + let output = isolated_command(&server, &temp.path().join("no-tokens.json")) + .args([ + "bookmarks", + "add", + "C12345678", + "Team Docs", + "https://example.com/docs?a=1&b=2", + "--token", + TOKEN, + ]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["ok"], true); + assert_eq!(json["channel"], "C12345678"); + assert_eq!(json["bookmark"]["id"], "Bk12345678"); + assert_eq!(json["bookmark"]["channel_id"], "C12345678"); + assert_eq!(json["bookmark"]["date_updated"], 20); + assert_eq!(json["bookmark"]["emoji"], Value::Null); + add.assert_async().await; +} + +#[tokio::test] +async fn bookmarks_add_normalizes_emoji_and_plain_outputs_returned_id() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let add = server + .mock("POST", "/bookmarks.add") + .match_body(Matcher::Exact( + "channel_id=G12345678&emoji=%3Abooks%3A&link=http%3A%2F%2Fexample.com%2Fdocs&title=Docs&type=link" + .to_string(), + )) + .with_body( + "{\"ok\":true,\"bookmark\":{\"id\":\"Bk\\t123\\r\\n\",\"title\":\"Docs\",\"link\":\"http://example.com/docs\",\"emoji\":\":books:\"}}", + ) + .create_async() + .await; + + command(&server, &temp) + .args([ + "bookmarks", + "add", + "G12345678", + "Docs", + "http://example.com/docs", + "--emoji", + "books", + "--plain", + ]) + .assert() + .success() + .stdout("Bk\\t123\\r\\n\n"); + add.assert_async().await; +} + +#[tokio::test] +async fn bookmarks_remove_posts_exact_form_and_supports_json_and_plain() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let remove_json = server + .mock("POST", "/bookmarks.remove") + .match_body(Matcher::Exact( + "bookmark_id=Bk12345678&channel_id=C12345678".to_string(), + )) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + let output = command(&server, &temp) + .args(["bookmarks", "remove", "C12345678", "Bk12345678"]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::<Value>(&output.stdout).unwrap(), + serde_json::json!({ + "ok": true, + "channel": "C12345678", + "bookmark_id": "Bk12345678" + }) + ); + remove_json.assert_async().await; + + let remove_plain = server + .mock("POST", "/bookmarks.remove") + .match_body(Matcher::Exact( + "bookmark_id=Bk%09123&channel_id=G12345678".to_string(), + )) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + command(&server, &temp) + .args(["bookmarks", "remove", "G12345678", "Bk\t123", "--plain"]) + .assert() + .success() + .stdout("Bk\\t123\n"); + remove_plain.assert_async().await; +} + +#[tokio::test] +async fn invalid_bookmark_inputs_fail_with_usage_before_network_io() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let no_io = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + for args in [ + vec![ + "bookmarks", + "add", + "not-a-channel-id", + " ", + "https://example.com", + ], + vec![ + "bookmarks", + "add", + "not-a-channel-id", + "Docs", + "relative/path", + ], + vec![ + "bookmarks", + "add", + "not-a-channel-id", + "Docs", + "ftp://example.com/docs", + ], + vec![ + "bookmarks", + "add", + "not-a-channel-id", + "Docs", + "https://user:pass@example.com/docs", + ], + vec![ + "bookmarks", + "add", + "not-a-channel-id", + "Docs", + "https://example.com", + "--emoji", + "", + ], + vec![ + "bookmarks", + "add", + "not-a-channel-id", + "Docs", + "https://example.com", + "--emoji", + ":bad", + ], + vec!["bookmarks", "remove", "not-a-channel-id", " "], + ] { + command(&server, &temp) + .args(args) + .assert() + .code(2) + .stdout(predicate::str::contains("usage_error")); + } + no_io.assert_async().await; +} + +#[tokio::test] +async fn bookmark_missing_scope_not_found_and_api_errors_propagate() { + for (args, endpoint, error) in [ + ( + vec!["bookmarks", "list", "C12345678"], + "/bookmarks.list", + "missing_scope", + ), + ( + vec![ + "bookmarks", + "add", + "C12345678", + "Docs", + "https://example.com", + ], + "/bookmarks.add", + "restricted_action", + ), + ( + vec!["bookmarks", "remove", "C12345678", "BkMISSING1"], + "/bookmarks.remove", + "bookmark_not_found", + ), + ] { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let api = server + .mock("POST", endpoint) + .with_body(format!(r#"{{"ok":false,"error":"{}"}}"#, error)) + .create_async() + .await; + command(&server, &temp) + .args(args) + .assert() + .failure() + .stdout(predicate::str::contains(error)); + api.assert_async().await; + } +} + +#[tokio::test] +async fn bookmarks_use_workspace_auth_and_missing_auth_performs_no_api_io() { + let mut server = mock_server().await; + let temp = TempDir::new().unwrap(); + let store = write_workspace_store(&temp); + let list = server + .mock("POST", "/bookmarks.list") + .match_header("authorization", format!("Bearer {}", STORED_TOKEN).as_str()) + .match_body(Matcher::Exact("channel_id=C12345678".to_string())) + .with_body(r#"{"ok":true,"bookmarks":[]}"#) + .create_async() + .await; + isolated_command(&server, &store) + .args([ + "bookmarks", + "list", + "C12345678", + "--workspace", + "workspace-one", + ]) + .assert() + .success(); + list.assert_async().await; + + let no_io = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + isolated_command(&server, &temp.path().join("missing-store.json")) + .args(["bookmarks", "list", "C12345678"]) + .assert() + .failure() + .stdout(predicate::str::contains("auth_required")); + no_io.assert_async().await; +} From 4883ddc0bb1d01fe10119477df83ade2e689a347 Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Wed, 9 Sep 2026 23:06:23 +1000 Subject: [PATCH 04/22] feat(channels): Channel members, lifecycle management, and unread overview --- CHANGELOG.md | 3 + README.md | 57 ++++ skills/slack/CHANNELS.md | 65 ++++- skills/slack/SKILL.md | 15 +- src/api/channel_ops.rs | 259 ++++++++++++++++++ src/api/mod.rs | 1 + src/cli/channels.rs | 557 ++++++++++++++++++++++++++++++++++++++- tests/cli_channel_ops.rs | 554 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 1508 insertions(+), 3 deletions(-) create mode 100644 src/api/channel_ops.rs create mode 100644 tests/cli_channel_ops.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0338e4e..d7d7336 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Channels**: list and resolve channel members, create and manage channel + lifecycle and membership, and show a capability-dependent Web API unread + overview with unavailable-count reporting. - **Identity and user groups**: send direct messages with `messages send @user`, look up users by email, and list user groups or their members with optional bulk user-name resolution. diff --git a/README.md b/README.md index 6c58721..5579380 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,63 @@ slack channels dms slack channels export --output channels.csv ``` +#### Channel members + +```bash +# Member IDs, preserving Slack's order +slack channels members "#general" + +# Resolve IDs to usernames (display name, then ID, are fallbacks) +slack --plain channels members "#general" --resolve +``` + +Member listing requires access to the conversation and its applicable read +scope. `--resolve` additionally requires `users:read` and loads the workspace +user directory once. IDs Slack omits from that directory remain visible with +the ID as their name. + +#### Channel lifecycle + +```bash +slack channels create project-room +slack channels create leadership --private +slack channels join "#project-room" +slack channels invite "#project-room" @alice U123456789 +slack channels set-topic "#project-room" "Quarterly launch" +slack channels set-purpose "#project-room" "Coordinate the launch" +slack channels rename "#project-room" launch-room +slack channels leave "#launch-room" +slack channels archive "#launch-room" +slack channels unarchive "#launch-room" +``` + +Names and IDs are accepted for channel operands, including archived channel +names. Invitees are resolved to user IDs and deduplicated before one invite; +if any user cannot be resolved, nobody is invited. These operations require +the applicable Slack channel-management, join, and invite scopes and, where +Slack requires it, membership or administrator permission. Archiving is +destructive to normal channel use until an authorized user unarchives it; +the CLI does not ask for confirmation. + +Pass an empty string to `set-topic` or `set-purpose` to clear it. JSON +mutations return `{"ok":true,"channel":...}`; `--plain` prints the channel ID. + +#### Unread overview + +```bash +slack channels unread +slack --plain channels unread +``` + +This command uses the Web API only. It checks joined public/private channels, +DMs, and group DMs and includes positive counts when `conversations.info` +exposes `unread_count_display` or `unread_count`. Slack omits these fields for +some workspaces and token types, so this is a capability-dependent overview, +not a guaranteed complete unread view. JSON lists omitted-count conversation +IDs in `unavailable_channels`; if no eligible conversation exposes any count, +the command fails with `unread_unavailable`. Applicable conversation read +scopes are required. + ### Messages (`slack messages` or `slack m`) ```bash diff --git a/skills/slack/CHANNELS.md b/skills/slack/CHANNELS.md index a624df8..98b976a 100644 --- a/skills/slack/CHANNELS.md +++ b/skills/slack/CHANNELS.md @@ -1,6 +1,6 @@ # slack channels -Channel listing, info, and export. Alias: `slack c` +Channel listing, membership, lifecycle management, unread overview, and export. Alias: `slack c` ## List channels @@ -33,6 +33,69 @@ slack channels info C1234567890 # by channel ID slack channels dms # list all DM conversations ``` +## Channel members + +```bash +slack channels members "#general" # one user ID per line with --plain +slack channels members "#general" --resolve # resolve to id + user_name +``` + +The channel operand may be a name or ID. Member pages are fetched +automatically and duplicate IDs are removed without changing order. +`--resolve` loads `users.list` once; it prefers username, then display name, +then the ID for unresolved or unnamed users. The token needs access to the +conversation and its applicable read scope; resolution also needs +`users:read`. + +## Channel lifecycle + +```bash +slack channels create project-room +slack channels create leadership --private +slack channels join "#project-room" +slack channels invite "#project-room" @alice U123456789 +slack channels set-topic "#project-room" "Quarterly launch" +slack channels set-purpose "#project-room" "Launch coordination" +slack channels rename "#project-room" launch-room +slack channels leave "#launch-room" +slack channels archive "#launch-room" +slack channels unarchive "#launch-room" +``` + +All channel operands resolve names or IDs, including archived names. Invite +operands resolve to user IDs and are deduplicated in argument order before a +single API call. Resolution is all-or-nothing: one unknown user prevents the +invite entirely. Slack enforces the applicable management/join/invite scopes, +conversation membership, admin permissions, naming rules, and text limits. +Archiving interrupts normal channel use and has no confirmation prompt. + +An empty topic or purpose clears it: + +```bash +slack channels set-topic "#project-room" "" +slack channels set-purpose "#project-room" "" +``` + +Mutation JSON is `{"ok":true,"channel":...}`. With `--plain`, every mutation +prints only its channel ID. + +## Unread overview + +```bash +slack channels unread +slack --plain channels unread +``` + +Unread overview uses only Web API calls. It checks joined public/private +channels and all listed DMs/group DMs, preferring `unread_count_display` over +`unread_count`, and shows only positive counts. Slack does not expose these +fields to every workspace or token. The result is therefore capability +dependent, not guaranteed complete; JSON reports missing-count IDs in +`unavailable_channels`, while plain mode emits one warning. If no eligible +conversation exposes count information, the command returns +`unread_unavailable`. Applicable conversation read scopes are required; there +is no browser/Edge fallback. + ## Export ```bash diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md index 2856a9e..5c6a132 100644 --- a/skills/slack/SKILL.md +++ b/skills/slack/SKILL.md @@ -167,13 +167,26 @@ slack messages search "deploy failed" --in-channel "#ops" slack messages search "from:@alice budget" ``` -### List channels +### List and manage channels ```bash slack channels list slack channels list --types public_channel,private_channel,im,mpim slack channels list --sort-popularity --exclude-archived +slack channels members "#general" --resolve +slack channels create project-room --private +slack channels invite "#project-room" @alice U123456789 +slack channels set-topic "#project-room" "Launch coordination" +slack channels archive "#old-project" +slack channels unread ``` +Channel and invite operands resolve names to IDs; invitations fail without +mutating if any user cannot be resolved. Management requires the applicable +Slack scopes and permissions, and archive has no confirmation. Unread counts +are Web-API-only and capability dependent: inspect `unavailable_channels` and +do not treat the overview as complete when Slack omits count fields. See +[CHANNELS.md](CHANNELS.md). + ### Look up users ```bash slack users me # current authenticated user diff --git a/src/api/channel_ops.rs b/src/api/channel_ops.rs new file mode 100644 index 0000000..90e3fd5 --- /dev/null +++ b/src/api/channel_ops.rs @@ -0,0 +1,259 @@ +//! Slack Web API operations for channel membership and lifecycle management. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +use crate::error::{Result, SlackError}; +use crate::models::Channel; + +use super::client::SlackClient; +use super::types::ResponseMetadata; + +#[derive(Debug, Serialize)] +struct ChannelParams<'a> { + channel: &'a str, +} + +#[derive(Debug, Serialize)] +struct CreateParams<'a> { + name: &'a str, + is_private: bool, +} + +#[derive(Debug, Serialize)] +struct InviteParams<'a> { + channel: &'a str, + users: &'a str, +} + +#[derive(Debug, Serialize)] +struct TextParams<'a> { + channel: &'a str, + #[serde(flatten)] + text: TextField<'a>, +} + +#[derive(Debug, Serialize)] +enum TextField<'a> { + #[serde(rename = "topic")] + Topic(&'a str), + #[serde(rename = "purpose")] + Purpose(&'a str), + #[serde(rename = "name")] + Name(&'a str), +} + +#[derive(Debug, Serialize)] +struct MembersParams<'a> { + channel: &'a str, + limit: u32, + #[serde(skip_serializing_if = "Option::is_none")] + cursor: Option<&'a str>, +} + +/// Response returned by channel mutations that include a channel object. +#[derive(Debug, Deserialize)] +pub struct ChannelMutationResponse { + /// The created or updated channel. + pub channel: Channel, +} + +#[derive(Debug, Deserialize)] +struct EmptyResponse {} + +/// One page from `conversations.members`. +#[derive(Debug, Deserialize)] +pub struct ConversationsMembersResponse { + /// User IDs in Slack's page order. + #[serde(default)] + pub members: Vec<String>, + /// Cursor metadata for the next page. + #[serde(default)] + pub response_metadata: Option<ResponseMetadata>, +} + +/// Unread-count fields returned inside `conversations.info`. +#[derive(Debug, Deserialize)] +pub struct UnreadCounts { + /// Slack's display-oriented unread count, when exposed to the token. + #[serde(default)] + pub unread_count_display: Option<u64>, + /// Slack's raw unread count, when exposed to the token. + #[serde(default)] + pub unread_count: Option<u64>, +} + +/// Dedicated response wrapper for capability-dependent unread fields. +#[derive(Debug, Deserialize)] +pub struct ConversationsUnreadInfoResponse { + /// Unread fields for the requested conversation. + pub channel: UnreadCounts, +} + +impl SlackClient { + /// Fetch one page of channel member IDs. + pub async fn conversations_members( + &self, + channel: &str, + cursor: Option<&str>, + ) -> Result<ConversationsMembersResponse> { + self.request( + "conversations.members", + &MembersParams { + channel, + limit: 200, + cursor, + }, + ) + .await + } + + /// Fetch all channel member IDs, preserving order and removing duplicates. + pub async fn conversations_members_all(&self, channel: &str) -> Result<Vec<String>> { + let mut members = Vec::new(); + let mut member_ids = HashSet::new(); + let mut seen_cursors = HashSet::new(); + let mut cursor: Option<String> = None; + + loop { + let response = self + .conversations_members(channel, cursor.as_deref()) + .await?; + for member in response.members { + if member_ids.insert(member.clone()) { + members.push(member); + } + } + + let next = response + .response_metadata + .and_then(|metadata| metadata.next_cursor) + .filter(|value| !value.is_empty()); + match next { + Some(next_cursor) => { + if !seen_cursors.insert(next_cursor.clone()) { + return Err(SlackError::Api { + error: "pagination_cursor_loop".to_string(), + detail: Some( + "conversations.members returned a repeated pagination cursor" + .to_string(), + ), + }); + } + cursor = Some(next_cursor); + } + None => break, + } + } + + Ok(members) + } + + /// Create a public or private channel. + pub async fn conversations_create( + &self, + name: &str, + is_private: bool, + ) -> Result<ChannelMutationResponse> { + self.request("conversations.create", &CreateParams { name, is_private }) + .await + } + + /// Join a public channel. + pub async fn conversations_join(&self, channel: &str) -> Result<ChannelMutationResponse> { + self.request("conversations.join", &ChannelParams { channel }) + .await + } + + /// Leave a conversation. + pub async fn conversations_leave(&self, channel: &str) -> Result<()> { + let _: EmptyResponse = self + .request("conversations.leave", &ChannelParams { channel }) + .await?; + Ok(()) + } + + /// Archive a conversation. + pub async fn conversations_archive(&self, channel: &str) -> Result<()> { + let _: EmptyResponse = self + .request("conversations.archive", &ChannelParams { channel }) + .await?; + Ok(()) + } + + /// Restore an archived conversation. + pub async fn conversations_unarchive(&self, channel: &str) -> Result<()> { + let _: EmptyResponse = self + .request("conversations.unarchive", &ChannelParams { channel }) + .await?; + Ok(()) + } + + /// Invite a comma-separated set of user IDs to a conversation. + pub async fn conversations_invite( + &self, + channel: &str, + users: &str, + ) -> Result<ChannelMutationResponse> { + self.request("conversations.invite", &InviteParams { channel, users }) + .await + } + + /// Set or clear a channel topic. + pub async fn conversations_set_topic( + &self, + channel: &str, + topic: &str, + ) -> Result<ChannelMutationResponse> { + self.request( + "conversations.setTopic", + &TextParams { + channel, + text: TextField::Topic(topic), + }, + ) + .await + } + + /// Set or clear a channel purpose. + pub async fn conversations_set_purpose( + &self, + channel: &str, + purpose: &str, + ) -> Result<ChannelMutationResponse> { + self.request( + "conversations.setPurpose", + &TextParams { + channel, + text: TextField::Purpose(purpose), + }, + ) + .await + } + + /// Rename a channel. + pub async fn conversations_rename( + &self, + channel: &str, + name: &str, + ) -> Result<ChannelMutationResponse> { + self.request( + "conversations.rename", + &TextParams { + channel, + text: TextField::Name(name), + }, + ) + .await + } + + /// Fetch unread-count fields without deserializing through the shared channel model. + pub async fn conversations_info_unread( + &self, + channel: &str, + ) -> Result<ConversationsUnreadInfoResponse> { + self.request("conversations.info", &ChannelParams { channel }) + .await + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index de57ae4..aaf0d93 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -8,6 +8,7 @@ //! - Name resolution helpers pub mod bookmark_ops; +pub mod channel_ops; mod client; pub mod edge; pub mod identity_ops; diff --git a/src/cli/channels.rs b/src/cli/channels.rs index b5452a9..5537d64 100644 --- a/src/cli/channels.rs +++ b/src/cli/channels.rs @@ -1,8 +1,11 @@ //! Channels CLI commands for Slack CLI //! -//! Handles channel operations: list, info, dms, export. +//! Handles channel operations: listing, membership, lifecycle, and unread counts. + +use std::collections::{HashMap, HashSet}; use clap::{Args, Subcommand}; +use serde::Serialize; /// Channel operations commands #[derive(Args, Debug)] @@ -51,6 +54,90 @@ pub enum ChannelsCommands { include_mpim: bool, }, + /// List members of a channel + Members { + /// Channel name or ID + channel: String, + + /// Resolve member IDs to user names + #[arg(long)] + resolve: bool, + }, + + /// Create a channel + Create { + /// Channel name + name: String, + + /// Create a private channel + #[arg(long)] + private: bool, + }, + + /// Join a channel + Join { + /// Channel name or ID + channel: String, + }, + + /// Leave a channel + Leave { + /// Channel name or ID + channel: String, + }, + + /// Archive a channel + Archive { + /// Channel name or ID + channel: String, + }, + + /// Restore an archived channel + Unarchive { + /// Channel name or ID + channel: String, + }, + + /// Invite one or more users to a channel + Invite { + /// Channel name or ID + channel: String, + + /// User names or IDs + #[arg(required = true, num_args = 1..)] + users: Vec<String>, + }, + + /// Set or clear a channel topic + SetTopic { + /// Channel name or ID + channel: String, + + /// New topic; pass an empty string to clear it + text: String, + }, + + /// Set or clear a channel purpose + SetPurpose { + /// Channel name or ID + channel: String, + + /// New purpose; pass an empty string to clear it + text: String, + }, + + /// Rename a channel + Rename { + /// Channel name or ID + channel: String, + + /// New channel name + new_name: String, + }, + + /// Show channels with unread messages when Slack exposes counts + Unread, + /// Export all channels to CSV Export { /// Output file (stdout if not specified) @@ -107,6 +194,90 @@ pub async fn run( list_dms(&client, *include_mpim, output_mode).await?; } + ChannelsCommands::Members { channel, resolve } => { + list_members(&client, channel, *resolve, output_mode).await?; + } + + ChannelsCommands::Create { name, private } => { + create_channel(&client, name, *private, output_mode).await?; + } + + ChannelsCommands::Join { channel } => { + channel_mutation(&client, ChannelMutation::Join, channel, None, output_mode).await?; + } + + ChannelsCommands::Leave { channel } => { + channel_mutation(&client, ChannelMutation::Leave, channel, None, output_mode).await?; + } + + ChannelsCommands::Archive { channel } => { + channel_mutation( + &client, + ChannelMutation::Archive, + channel, + None, + output_mode, + ) + .await?; + } + + ChannelsCommands::Unarchive { channel } => { + channel_mutation( + &client, + ChannelMutation::Unarchive, + channel, + None, + output_mode, + ) + .await?; + } + + ChannelsCommands::Invite { channel, users } => { + invite_users(&client, channel, users, output_mode).await?; + } + + ChannelsCommands::SetTopic { channel, text } => { + channel_mutation( + &client, + ChannelMutation::SetTopic, + channel, + Some(text), + output_mode, + ) + .await?; + } + + ChannelsCommands::SetPurpose { channel, text } => { + channel_mutation( + &client, + ChannelMutation::SetPurpose, + channel, + Some(text), + output_mode, + ) + .await?; + } + + ChannelsCommands::Rename { channel, new_name } => { + if new_name.is_empty() { + return Err(crate::error::SlackError::Usage( + "channel name cannot be empty".to_string(), + )); + } + channel_mutation( + &client, + ChannelMutation::Rename, + channel, + Some(new_name), + output_mode, + ) + .await?; + } + + ChannelsCommands::Unread => { + unread_channels(&client, output_mode).await?; + } + ChannelsCommands::Export { output, types } => { export_channels(&client, types, output.as_deref()).await?; } @@ -305,6 +476,314 @@ async fn list_dms( Ok(()) } +#[derive(Serialize)] +struct ResolvedMember { + id: String, + user_name: String, +} + +#[derive(Serialize)] +struct UnreadChannel { + id: String, + name: Option<String>, + is_im: bool, + is_mpim: bool, + user: Option<String>, + unread_count: u64, +} + +#[derive(Clone, Copy)] +enum ChannelMutation { + Join, + Leave, + Archive, + Unarchive, + SetTopic, + SetPurpose, + Rename, +} + +fn escape_tsv(value: &str) -> String { + value + .replace('\t', "\\t") + .replace('\n', "\\n") + .replace('\r', "\\r") +} + +fn member_user_name(user: &crate::models::User) -> String { + user.name + .as_deref() + .filter(|name| !name.is_empty()) + .or_else(|| { + user.profile + .as_ref() + .and_then(|profile| profile.display_name.as_deref()) + .filter(|name| !name.is_empty()) + }) + .unwrap_or(&user.id) + .to_string() +} + +async fn list_members( + client: &crate::api::SlackClient, + channel: &str, + resolve: bool, + output_mode: crate::output::OutputMode, +) -> crate::error::Result<()> { + let channel_id = client.resolve_channel(channel).await?; + let members = client.conversations_members_all(&channel_id).await?; + + if resolve { + let users = client.users_list_all().await?; + let names: HashMap<String, String> = users + .iter() + .map(|user| (user.id.clone(), member_user_name(user))) + .collect(); + let resolved: Vec<ResolvedMember> = members + .iter() + .map(|id| ResolvedMember { + id: id.clone(), + user_name: names.get(id).cloned().unwrap_or_else(|| id.clone()), + }) + .collect(); + + if output_mode == crate::output::OutputMode::Plain { + for member in &resolved { + println!("{}\t{}", member.id, escape_tsv(&member.user_name)); + } + } else { + crate::output::write_json(&serde_json::json!({ + "channel": channel_id, + "members": resolved, + }))?; + } + } else if output_mode == crate::output::OutputMode::Plain { + for member in &members { + println!("{}", member); + } + } else { + crate::output::write_json(&serde_json::json!({ + "channel": channel_id, + "members": members, + }))?; + } + + Ok(()) +} + +async fn create_channel( + client: &crate::api::SlackClient, + name: &str, + private: bool, + output_mode: crate::output::OutputMode, +) -> crate::error::Result<()> { + if name.is_empty() { + return Err(crate::error::SlackError::Usage( + "channel name cannot be empty".to_string(), + )); + } + let response = client.conversations_create(name, private).await?; + write_channel_mutation(response.channel, output_mode) +} + +fn write_channel_mutation( + channel: crate::models::Channel, + output_mode: crate::output::OutputMode, +) -> crate::error::Result<()> { + if output_mode == crate::output::OutputMode::Plain { + println!("{}", channel.id); + } else { + crate::output::write_json(&serde_json::json!({ + "ok": true, + "channel": channel, + }))?; + } + Ok(()) +} + +fn write_id_mutation( + channel_id: &str, + output_mode: crate::output::OutputMode, +) -> crate::error::Result<()> { + if output_mode == crate::output::OutputMode::Plain { + println!("{}", channel_id); + } else { + crate::output::write_json(&serde_json::json!({ + "ok": true, + "channel": channel_id, + }))?; + } + Ok(()) +} + +async fn channel_mutation( + client: &crate::api::SlackClient, + mutation: ChannelMutation, + channel: &str, + value: Option<&str>, + output_mode: crate::output::OutputMode, +) -> crate::error::Result<()> { + let channel_id = client.resolve_channel(channel).await?; + match mutation { + ChannelMutation::Join => { + let response = client.conversations_join(&channel_id).await?; + write_channel_mutation(response.channel, output_mode) + } + ChannelMutation::Leave => { + client.conversations_leave(&channel_id).await?; + write_id_mutation(&channel_id, output_mode) + } + ChannelMutation::Archive => { + client.conversations_archive(&channel_id).await?; + write_id_mutation(&channel_id, output_mode) + } + ChannelMutation::Unarchive => { + client.conversations_unarchive(&channel_id).await?; + write_id_mutation(&channel_id, output_mode) + } + ChannelMutation::SetTopic => { + let response = client + .conversations_set_topic(&channel_id, value.unwrap_or("")) + .await?; + write_channel_mutation(response.channel, output_mode) + } + ChannelMutation::SetPurpose => { + let response = client + .conversations_set_purpose(&channel_id, value.unwrap_or("")) + .await?; + write_channel_mutation(response.channel, output_mode) + } + ChannelMutation::Rename => { + let response = client + .conversations_rename(&channel_id, value.unwrap_or("")) + .await?; + write_channel_mutation(response.channel, output_mode) + } + } +} + +async fn invite_users( + client: &crate::api::SlackClient, + channel: &str, + users: &[String], + output_mode: crate::output::OutputMode, +) -> crate::error::Result<()> { + let channel_id = client.resolve_channel(channel).await?; + let mut user_ids = Vec::new(); + let mut seen = HashSet::new(); + for user in users { + let user_id = client.resolve_user(user).await?; + if seen.insert(user_id.clone()) { + user_ids.push(user_id); + } + } + + let response = client + .conversations_invite(&channel_id, &user_ids.join(",")) + .await?; + write_channel_mutation(response.channel, output_mode) +} + +async fn unread_channels( + client: &crate::api::SlackClient, + output_mode: crate::output::OutputMode, +) -> crate::error::Result<()> { + let channels = client + .conversations_list_all(Some("public_channel,private_channel,mpim,im"), true) + .await?; + let eligible: Vec<_> = channels + .into_iter() + .filter(|channel| { + !channel.is_archived && (channel.is_im || channel.is_mpim || channel.is_member) + }) + .collect(); + + if eligible.is_empty() { + if output_mode == crate::output::OutputMode::Plain { + return Ok(()); + } + crate::output::write_json(&serde_json::json!({ + "channels": [], + "unavailable_channels": [], + }))?; + return Ok(()); + } + + let mut unread = Vec::new(); + let mut unavailable = Vec::new(); + let mut known_counts = 0usize; + for channel in eligible { + let response = client.conversations_info_unread(&channel.id).await?; + let count = response + .channel + .unread_count_display + .or(response.channel.unread_count); + match count { + Some(count) => { + known_counts += 1; + if count > 0 { + unread.push(UnreadChannel { + id: channel.id, + name: channel.name, + is_im: channel.is_im, + is_mpim: channel.is_mpim, + user: channel.user, + unread_count: count, + }); + } + } + None => unavailable.push(channel.id), + } + } + + if known_counts == 0 { + return Err(crate::error::SlackError::Api { + error: "unread_unavailable".to_string(), + detail: Some( + "this workspace/token does not expose unread counts through the Web API" + .to_string(), + ), + }); + } + + unread.sort_by(|left, right| { + right + .unread_count + .cmp(&left.unread_count) + .then_with(|| left.id.cmp(&right.id)) + }); + + if output_mode == crate::output::OutputMode::Plain { + for channel in &unread { + let label = channel + .name + .as_deref() + .filter(|name| !name.is_empty()) + .or(channel.user.as_deref().filter(|user| !user.is_empty())) + .unwrap_or(&channel.id); + println!( + "{}\t{}\t{}", + channel.id, + escape_tsv(label), + channel.unread_count + ); + } + if !unavailable.is_empty() { + eprintln!( + "warning: unread counts unavailable for {} channel(s)", + unavailable.len() + ); + } + } else { + crate::output::write_json(&serde_json::json!({ + "channels": unread, + "unavailable_channels": unavailable, + }))?; + } + + Ok(()) +} + /// Export channels to CSV async fn export_channels( client: &crate::api::SlackClient, @@ -589,6 +1068,82 @@ mod tests { } } + #[test] + fn test_parse_channels_members_and_create() { + let unresolved = Cli::try_parse_from(["slack", "channels", "members", "general"]).unwrap(); + let crate::cli::Commands::Channels(unresolved) = unresolved.command else { + panic!("Expected Channels command"); + }; + assert!(matches!( + unresolved.command, + ChannelsCommands::Members { resolve: false, .. } + )); + + let cli = + Cli::try_parse_from(["slack", "channels", "members", "general", "--resolve"]).unwrap(); + let crate::cli::Commands::Channels(cmd) = cli.command else { + panic!("Expected Channels command"); + }; + assert!(matches!( + cmd.command, + ChannelsCommands::Members { channel, resolve } + if channel == "general" && resolve + )); + + let public = Cli::try_parse_from(["slack", "channels", "create", "public-room"]).unwrap(); + let crate::cli::Commands::Channels(public) = public.command else { + panic!("Expected Channels command"); + }; + assert!(matches!( + public.command, + ChannelsCommands::Create { private: false, .. } + )); + + let cli = Cli::try_parse_from(["slack", "channels", "create", "private-room", "--private"]) + .unwrap(); + let crate::cli::Commands::Channels(cmd) = cli.command else { + panic!("Expected Channels command"); + }; + assert!(matches!( + cmd.command, + ChannelsCommands::Create { name, private } + if name == "private-room" && private + )); + } + + #[test] + fn test_parse_channels_lifecycle_variants() { + for subcommand in ["join", "leave", "archive", "unarchive"] { + Cli::try_parse_from(["slack", "channels", subcommand, "C123456789"]).unwrap(); + } + Cli::try_parse_from(["slack", "channels", "set-topic", "general", "topic"]).unwrap(); + Cli::try_parse_from(["slack", "channels", "set-purpose", "general", "purpose"]).unwrap(); + Cli::try_parse_from(["slack", "channels", "rename", "general", "new-name"]).unwrap(); + Cli::try_parse_from(["slack", "channels", "unread"]).unwrap(); + } + + #[test] + fn test_parse_channels_invite_requires_users() { + let cli = Cli::try_parse_from([ + "slack", + "channels", + "invite", + "general", + "alice", + "U123456789", + ]) + .unwrap(); + let crate::cli::Commands::Channels(cmd) = cli.command else { + panic!("Expected Channels command"); + }; + assert!(matches!( + cmd.command, + ChannelsCommands::Invite { channel, users } + if channel == "general" && users == ["alice", "U123456789"] + )); + assert!(Cli::try_parse_from(["slack", "channels", "invite", "general"]).is_err()); + } + #[test] fn test_parse_channels_alias() { let cli = Cli::try_parse_from(["slack", "c", "list"]).unwrap(); diff --git a/tests/cli_channel_ops.rs b/tests/cli_channel_ops.rs new file mode 100644 index 0000000..a3cda5b --- /dev/null +++ b/tests/cli_channel_ops.rs @@ -0,0 +1,554 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use assert_cmd::Command; +use mockito::{Matcher, ServerGuard}; +use serde_json::{json, Value}; +use tempfile::TempDir; + +const TOKEN: &str = "xoxp-test-token-123456789"; +const CHANNEL: &str = "C123456789"; + +fn command(server: &ServerGuard, temp: &TempDir) -> Command { + let mut cmd = cargo_bin_cmd!("slack"); + cmd.env("SLACK_API_BASE_URL", server.url()) + .env("SLACK_TOKEN_STORE_PATH", temp.path().join("tokens.json")) + .env("SLACK_TOKEN", TOKEN) + .env_remove("SLACK_WORKSPACE") + .env_remove("SLACK_PLAIN"); + cmd +} + +fn body(fields: &[(&str, &str)]) -> Matcher { + Matcher::AllOf( + fields + .iter() + .map(|(key, value)| Matcher::UrlEncoded((*key).into(), (*value).into())) + .collect(), + ) +} + +fn run_json(server: &ServerGuard, temp: &TempDir, args: &[&str]) -> Value { + let output = command(server, temp).args(args).output().unwrap(); + assert!( + output.status.success(), + "stderr={} stdout={}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + serde_json::from_slice(&output.stdout).unwrap() +} + +async fn channel_response_mock( + server: &mut ServerGuard, + method: &str, + fields: &[(&str, &str)], + id: &str, +) -> mockito::Mock { + server + .mock("POST", format!("/{method}").as_str()) + .match_body(body(fields)) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({"ok": true, "channel": {"id": id}}).to_string()) + .create_async() + .await +} + +#[tokio::test] +async fn lifecycle_endpoints_send_exact_fields_and_return_channels() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + + let create = channel_response_mock( + &mut server, + "conversations.create", + &[("name", "secret-room"), ("is_private", "true")], + "G111111111", + ) + .await; + let join = channel_response_mock( + &mut server, + "conversations.join", + &[("channel", CHANNEL)], + CHANNEL, + ) + .await; + let leave = server + .mock("POST", "/conversations.leave") + .match_body(body(&[("channel", CHANNEL)])) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + let archive = server + .mock("POST", "/conversations.archive") + .match_body(body(&[("channel", CHANNEL)])) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + let unarchive = server + .mock("POST", "/conversations.unarchive") + .match_body(body(&[("channel", CHANNEL)])) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + let topic = channel_response_mock( + &mut server, + "conversations.setTopic", + &[("channel", CHANNEL), ("topic", "")], + CHANNEL, + ) + .await; + let purpose = channel_response_mock( + &mut server, + "conversations.setPurpose", + &[("channel", CHANNEL), ("purpose", "Team purpose")], + CHANNEL, + ) + .await; + let rename = channel_response_mock( + &mut server, + "conversations.rename", + &[("channel", CHANNEL), ("name", "new-name")], + CHANNEL, + ) + .await; + + let created = run_json( + &server, + &temp, + &["channels", "create", "secret-room", "--private"], + ); + assert_eq!(created["ok"], true); + assert_eq!(created["channel"]["id"], "G111111111"); + let joined = run_json(&server, &temp, &["channels", "join", CHANNEL]); + assert_eq!(joined["ok"], true); + assert_eq!(joined["channel"]["id"], CHANNEL); + for subcommand in ["leave", "archive", "unarchive"] { + assert_eq!( + run_json(&server, &temp, &["channels", subcommand, CHANNEL]), + json!({"ok": true, "channel": CHANNEL}) + ); + } + for args in [ + vec!["channels", "set-topic", CHANNEL, ""], + vec!["channels", "set-purpose", CHANNEL, "Team purpose"], + vec!["channels", "rename", CHANNEL, "new-name"], + ] { + let output = run_json(&server, &temp, &args); + assert_eq!(output["ok"], true); + assert_eq!(output["channel"]["id"], CHANNEL); + } + + for mock in [ + create, join, leave, archive, unarchive, topic, purpose, rename, + ] { + mock.assert_async().await; + } +} + +#[tokio::test] +async fn public_create_sends_false_and_plain_prints_returned_id() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let create = channel_response_mock( + &mut server, + "conversations.create", + &[("name", "public-room"), ("is_private", "false")], + CHANNEL, + ) + .await; + command(&server, &temp) + .args(["--plain", "channels", "create", "public-room"]) + .assert() + .success() + .stdout(format!("{CHANNEL}\n")); + create.assert_async().await; +} + +#[tokio::test] +async fn archived_name_is_resolved_before_rename_and_plain_prints_id() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/conversations.list") + .match_body(body(&[ + ("limit", "200"), + ("exclude_archived", "false"), + ("types", "public_channel,private_channel,mpim,im"), + ])) + .with_body(format!( + r#"{{"ok":true,"channels":[{{"id":"{CHANNEL}","name":"old-room","is_archived":true}}],"response_metadata":{{"next_cursor":""}}}}"# + )) + .create_async() + .await; + let rename = channel_response_mock( + &mut server, + "conversations.rename", + &[("channel", CHANNEL), ("name", "restored-room")], + CHANNEL, + ) + .await; + + command(&server, &temp) + .args(["--plain", "channels", "rename", "old-room", "restored-room"]) + .assert() + .success() + .stdout(format!("{CHANNEL}\n")); + list.assert_async().await; + rename.assert_async().await; +} + +#[tokio::test] +async fn invite_resolves_and_deduplicates_every_user() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let users = server + .mock("POST", "/users.list") + .match_body(body(&[("limit", "200")])) + .with_body( + r#"{"ok":true,"members":[{"id":"U111111111","name":"alice"},{"id":"U222222222","name":"bob"}],"response_metadata":{"next_cursor":""}}"#, + ) + .expect(3) + .create_async() + .await; + let invite = channel_response_mock( + &mut server, + "conversations.invite", + &[("channel", CHANNEL), ("users", "U111111111,U222222222")], + CHANNEL, + ) + .await; + + let output = run_json( + &server, + &temp, + &["channels", "invite", CHANNEL, "alice", "bob", "alice"], + ); + assert_eq!(output["ok"], true); + assert_eq!(output["channel"]["id"], CHANNEL); + users.assert_async().await; + invite.assert_async().await; +} + +#[tokio::test] +async fn invite_resolution_failure_does_not_mutate() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let users = server + .mock("POST", "/users.list") + .match_body(body(&[("limit", "200")])) + .with_body(r#"{"ok":true,"members":[],"response_metadata":{"next_cursor":""}}"#) + .create_async() + .await; + let invite = server + .mock("POST", "/conversations.invite") + .expect(0) + .create_async() + .await; + + command(&server, &temp) + .args(["channels", "invite", CHANNEL, "U111111111", "missing-user"]) + .assert() + .failure() + .stdout(predicates::str::contains("user_not_found")); + users.assert_async().await; + invite.assert_async().await; +} + +#[tokio::test] +async fn empty_create_and_rename_names_fail_without_http_requests() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let any = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + for args in [ + vec!["channels", "create", ""], + vec!["channels", "rename", CHANNEL, ""], + ] { + command(&server, &temp) + .args(args) + .assert() + .code(2) + .stdout(predicates::str::contains("usage_error")); + } + any.assert_async().await; +} + +#[tokio::test] +async fn members_paginates_deduplicates_and_loads_one_user_directory() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let members_one = server + .mock("POST", "/conversations.members") + .match_body(body(&[("channel", CHANNEL), ("limit", "200")])) + .with_body(r#"{"ok":true,"members":["U111111111","U222222222"],"response_metadata":{"next_cursor":"members-next"}}"#) + .create_async() + .await; + let members_two = server + .mock("POST", "/conversations.members") + .match_body(body(&[ + ("channel", CHANNEL), + ("limit", "200"), + ("cursor", "members-next"), + ])) + .with_body(r#"{"ok":true,"members":["U222222222","U333333333"],"response_metadata":{"next_cursor":""}}"#) + .create_async() + .await; + let users_one = server + .mock("POST", "/users.list") + .match_body(body(&[("limit", "200")])) + .with_body(r#"{"ok":true,"members":[{"id":"U111111111","name":"alice","profile":{"display_name":"Alias"}}],"response_metadata":{"next_cursor":"users-next"}}"#) + .create_async() + .await; + let users_two = server + .mock("POST", "/users.list") + .match_body(body(&[("limit", "200"), ("cursor", "users-next")])) + .with_body(r#"{"ok":true,"members":[{"id":"U222222222","profile":{"display_name":"Bob"}}],"response_metadata":{"next_cursor":""}}"#) + .create_async() + .await; + + let output = run_json( + &server, + &temp, + &["channels", "members", CHANNEL, "--resolve"], + ); + assert_eq!(output["channel"], CHANNEL); + assert_eq!( + output["members"], + json!([ + {"id":"U111111111","user_name":"alice"}, + {"id":"U222222222","user_name":"Bob"}, + {"id":"U333333333","user_name":"U333333333"} + ]) + ); + for mock in [members_one, members_two, users_one, users_two] { + mock.assert_async().await; + } +} + +#[tokio::test] +async fn members_plain_and_empty_json_outputs_are_machine_readable() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let members = server + .mock("POST", "/conversations.members") + .match_body(body(&[("channel", CHANNEL), ("limit", "200")])) + .with_body(r#"{"ok":true,"members":["U111111111"],"response_metadata":{}}"#) + .create_async() + .await; + command(&server, &temp) + .args(["--plain", "channels", "members", CHANNEL]) + .assert() + .success() + .stdout("U111111111\n"); + members.assert_async().await; + + let empty = server + .mock("POST", "/conversations.members") + .match_body(body(&[("channel", CHANNEL), ("limit", "200")])) + .with_body(r#"{"ok":true,"members":[],"response_metadata":{"next_cursor":""}}"#) + .create_async() + .await; + assert_eq!( + run_json(&server, &temp, &["channels", "members", CHANNEL]), + json!({"channel": CHANNEL, "members": []}) + ); + empty.assert_async().await; +} + +#[tokio::test] +async fn members_rejects_repeated_cursor() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let first = server + .mock("POST", "/conversations.members") + .match_body(body(&[("channel", CHANNEL), ("limit", "200")])) + .with_body(r#"{"ok":true,"members":[],"response_metadata":{"next_cursor":"again"}}"#) + .create_async() + .await; + let second = server + .mock("POST", "/conversations.members") + .match_body(body(&[ + ("channel", CHANNEL), + ("limit", "200"), + ("cursor", "again"), + ])) + .with_body(r#"{"ok":true,"members":[],"response_metadata":{"next_cursor":"again"}}"#) + .create_async() + .await; + command(&server, &temp) + .args(["channels", "members", CHANNEL]) + .assert() + .failure() + .stdout(predicates::str::contains("pagination_cursor_loop")); + first.assert_async().await; + second.assert_async().await; +} + +#[tokio::test] +async fn unread_paginates_filters_and_sorts_capability_dependent_counts() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let list_one = server + .mock("POST", "/conversations.list") + .match_body(body(&[ + ("limit", "200"), + ("exclude_archived", "true"), + ("types", "public_channel,private_channel,mpim,im"), + ])) + .with_body(r#"{"ok":true,"channels":[{"id":"C111111111","name":"alpha","is_channel":true,"is_member":true},{"id":"C999999999","name":"outside","is_channel":true,"is_member":false}],"response_metadata":{"next_cursor":"next-page"}}"#) + .create_async() + .await; + let list_two = server + .mock("POST", "/conversations.list") + .match_body(body(&[ + ("limit", "200"), + ("exclude_archived", "true"), + ("types", "public_channel,private_channel,mpim,im"), + ("cursor", "next-page"), + ])) + .with_body(r#"{"ok":true,"channels":[{"id":"D222222222","is_im":true,"user":"U222222222"},{"id":"G333333333","name":"group","is_mpim":true}],"response_metadata":{"next_cursor":""}}"#) + .create_async() + .await; + let alpha = server + .mock("POST", "/conversations.info") + .match_body(body(&[("channel", "C111111111")])) + .with_body(r#"{"ok":true,"channel":{"unread_count_display":2,"unread_count":9}}"#) + .create_async() + .await; + let dm = server + .mock("POST", "/conversations.info") + .match_body(body(&[("channel", "D222222222")])) + .with_body(r#"{"ok":true,"channel":{"unread_count":4}}"#) + .create_async() + .await; + let mpim = server + .mock("POST", "/conversations.info") + .match_body(body(&[("channel", "G333333333")])) + .with_body(r#"{"ok":true,"channel":{}}"#) + .create_async() + .await; + + let output = run_json(&server, &temp, &["channels", "unread"]); + assert_eq!(output["unavailable_channels"], json!(["G333333333"])); + assert_eq!(output["channels"][0]["id"], "D222222222"); + assert_eq!(output["channels"][0]["unread_count"], 4); + assert_eq!(output["channels"][1]["id"], "C111111111"); + assert_eq!(output["channels"][1]["unread_count"], 2); + assert!(output["channels"] + .as_array() + .unwrap() + .iter() + .all(|item| item["id"] != "C999999999")); + for mock in [list_one, list_two, alpha, dm, mpim] { + mock.assert_async().await; + } +} + +#[tokio::test] +async fn unread_zero_is_known_and_empty_eligible_set_succeeds() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/conversations.list") + .match_body(body(&[("exclude_archived", "true")])) + .with_body(r#"{"ok":true,"channels":[{"id":"C111111111","is_member":true}],"response_metadata":{}}"#) + .create_async() + .await; + let info = server + .mock("POST", "/conversations.info") + .match_body(body(&[("channel", "C111111111")])) + .with_body(r#"{"ok":true,"channel":{"unread_count_display":0}}"#) + .create_async() + .await; + assert_eq!( + run_json(&server, &temp, &["channels", "unread"]), + json!({"channels": [], "unavailable_channels": []}) + ); + list.assert_async().await; + info.assert_async().await; + + let empty = server + .mock("POST", "/conversations.list") + .match_body(body(&[("exclude_archived", "true")])) + .with_body(r#"{"ok":true,"channels":[{"id":"C999999999","is_member":false}],"response_metadata":{}}"#) + .create_async() + .await; + assert_eq!( + run_json(&server, &temp, &["channels", "unread"]), + json!({"channels": [], "unavailable_channels": []}) + ); + empty.assert_async().await; +} + +#[tokio::test] +async fn unread_plain_warns_once_for_missing_counts() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/conversations.list") + .with_body(r#"{"ok":true,"channels":[{"id":"D111111111","is_im":true,"user":"user\tname"},{"id":"G222222222","is_mpim":true}],"response_metadata":{}}"#) + .create_async() + .await; + let known = server + .mock("POST", "/conversations.info") + .match_body(body(&[("channel", "D111111111")])) + .with_body(r#"{"ok":true,"channel":{"unread_count":3}}"#) + .create_async() + .await; + let missing = server + .mock("POST", "/conversations.info") + .match_body(body(&[("channel", "G222222222")])) + .with_body(r#"{"ok":true,"channel":{}}"#) + .create_async() + .await; + command(&server, &temp) + .args(["--plain", "channels", "unread"]) + .assert() + .success() + .stdout("D111111111\tuser\\tname\t3\n") + .stderr("warning: unread counts unavailable for 1 channel(s)\n"); + for mock in [list, known, missing] { + mock.assert_async().await; + } +} + +#[tokio::test] +async fn unread_all_unavailable_and_api_failures_propagate() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/conversations.list") + .with_body( + r#"{"ok":true,"channels":[{"id":"D111111111","is_im":true}],"response_metadata":{}}"#, + ) + .create_async() + .await; + let missing = server + .mock("POST", "/conversations.info") + .with_body(r#"{"ok":true,"channel":{}}"#) + .create_async() + .await; + command(&server, &temp) + .args(["channels", "unread"]) + .assert() + .failure() + .stdout(predicates::str::contains("unread_unavailable")) + .stdout(predicates::str::contains("does not expose unread counts")); + list.assert_async().await; + missing.assert_async().await; + + let list_error = server + .mock("POST", "/conversations.list") + .with_body(r#"{"ok":false,"error":"missing_scope"}"#) + .create_async() + .await; + command(&server, &temp) + .args(["channels", "unread"]) + .assert() + .failure() + .stdout(predicates::str::contains("missing_scope")); + list_error.assert_async().await; +} From c401863147861f9483d7ba3ecce79b0b05dc9976 Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Wed, 9 Sep 2026 23:25:09 +1000 Subject: [PATCH 05/22] feat(messages-read): Time-bounded paginated reading and resolved message output --- CHANGELOG.md | 3 + README.md | 23 ++ skills/slack/MESSAGES.md | 42 ++- skills/slack/SKILL.md | 8 + src/cli/messages.rs | 536 +++++++++++++++++++++++++++-------- src/cli/messages/read_ops.rs | 308 ++++++++++++++++++++ tests/cli_messages_read.rs | 470 ++++++++++++++++++++++++++++++ 7 files changed, 1266 insertions(+), 124 deletions(-) create mode 100644 src/cli/messages/read_ops.rs create mode 100644 tests/cli_messages_read.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d7336..09128fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Message reading**: read exclusive date/timestamp-bounded channel history, + collect all history pages, resolve authors and mentions from one paginated + user-directory traversal, and select search result sort order. - **Channels**: list and resolve channel members, create and manage channel lifecycle and membership, and show a capability-dependent Web API unread overview with unavailable-count reporting. diff --git a/README.md b/README.md index 5579380..6bccac0 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,29 @@ slack messages search "in:#general project" --count 50 slack messages get "C123456789:1234567890.123456" ``` +#### Bounded and resolved reading + +```bash +# Exclusive UTC bounds: dates, RFC3339 instants, or Slack timestamps +slack messages list "#general" --since 2026-01-01 --until 2026-02-01 + +# Fetch complete bounded history; numeric --limit is ignored with --all +slack messages list "#general" --since 1735689600.000001 --all + +# Resolve authors and <@user> mentions in any read command +slack messages list "#general" --resolve-users +slack messages thread "#general" 1234567890.123456 --resolve-users +slack messages search "incident" --sort score --sort-dir desc --resolve-users +``` + +`--since` and `--until` are exclusive UTC bounds. A duration `--limit` still +contributes an oldest bound, and the later of it and `--since` is used. +`--all` conflicts with `--cursor`, reads pages of 200 in Slack response order, +and ignores a numeric `--limit`. User resolution loads the complete paginated +workspace directory once per nonempty command. JSON keeps `user` and adds +`user_name`; explicitly resolved `--plain` output uses the name in its second +(author) column and rewrites known mentions while preserving unknown markup. + #### Message formatting `messages send` treats input as **standard Markdown** by default (`--format diff --git a/skills/slack/MESSAGES.md b/skills/slack/MESSAGES.md index 4b2c00e..c087408 100644 --- a/skills/slack/MESSAGES.md +++ b/skills/slack/MESSAGES.md @@ -22,8 +22,23 @@ slack messages list "#general" --include-activity # Paginate slack messages list "#general" --cursor <cursor_from_response> + +# Exclusive UTC bounds (date, RFC3339, or Slack timestamp) +slack messages list "#general" --since 2026-01-01 --until 2026-02-01 +slack messages list "#general" --since 2026-01-01T09:30:00-05:00 +slack messages list "#general" --since 1767225600.000001 + +# Fetch every page and optionally resolve authors and mentions +slack messages list "#general" --all --resolve-users ``` +`--since` and `--until` are exclusive UTC bounds. When a duration-style +`--limit` and `--since` are both present, the later oldest bound wins. +Without `--all`, `--limit` keeps its existing page-size behavior and bounds +are sent with any cursor. `--all` conflicts with `--cursor`, fetches pages of +200 in Slack response order, and ignores a numeric `--limit`; activity +messages are still filtered unless `--include-activity` is set. + ## Get a single message The most reliable way to fetch a specific message — accepts a Slack permalink URL @@ -107,6 +122,12 @@ slack messages search "decision" --threads-only # Pagination slack messages search "query" --count 50 --page 2 + +# Sort by relevance, oldest score first +slack messages search "query" --sort score --sort-dir asc + +# Defaults are newest timestamp first +slack messages search "query" --sort timestamp --sort-dir desc ``` ## Output @@ -120,13 +141,22 @@ slack --plain messages search "hello" ## Resolving user IDs in output -Message JSON includes `user` as a Slack user ID (e.g. `U090BKEQXMH`). Resolve it -to a display name with the CLI: +List, thread, and search can resolve message authors and user mentions: ```bash -slack users info U090BKEQXMH # full user record (JSON) -slack --plain users info U090BKEQXMH # TSV +slack messages list "#general" --resolve-users +slack messages thread C1234567890 1234567890.123456 --resolve-users +slack messages search "review" --resolve-users ``` -Note: with browser tokens, `real_name` may be empty — prefer the profile's -`display_name`, falling back to `real_name`. +For each nonempty invocation, `--resolve-users` traverses the complete +paginated `users.list` directory exactly once, not once per message. It prefers +a nonempty username, then the user's display name, and finally the ID. Known +`<@U…>` and `<@U…|label>` mentions become `@name`; unknown mention tokens and +unrelated mrkdwn remain unchanged. A directory API error fails the command. + +Resolved JSON preserves the original `user` ID and adds `user_name` (`null` +when the message has no user, with the ID as fallback for an unknown user). +Without the flag, JSON is unchanged. Plain output always has four TSV columns +(timestamp, author, channel, text); its author column changes from ID to name +only when `--resolve-users` is explicitly requested. diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md index 5c6a132..9e35917 100644 --- a/skills/slack/SKILL.md +++ b/skills/slack/SKILL.md @@ -167,6 +167,14 @@ slack messages search "deploy failed" --in-channel "#ops" slack messages search "from:@alice budget" ``` +Use exclusive UTC `--since`/`--until` bounds on `messages list`; each accepts a +`YYYY-MM-DD` date, RFC3339 instant, or Slack decimal timestamp. `--all` fetches +all pages in response order, conflicts with `--cursor`, and ignores a numeric +`--limit` (its requests use 200-message pages). Add `--resolve-users` to list, +thread, or search to load the complete paginated user directory once and show +resolved authors and mentions. Search ordering is selectable with `--sort +score|timestamp` and `--sort-dir asc|desc`. + ### List and manage channels ```bash slack channels list diff --git a/src/cli/messages.rs b/src/cli/messages.rs index 62cb595..92ce38a 100644 --- a/src/cli/messages.rs +++ b/src/cli/messages.rs @@ -14,6 +14,8 @@ use crate::models::Message; use crate::output::{write_json, write_messages_plain, MessagePlain, OutputMode}; use crate::utils::{parse_time_limit, TimeLimit}; +mod read_ops; + /// Message operations commands #[derive(Args, Debug)] pub struct MessagesCmd { @@ -38,8 +40,24 @@ pub enum MessagesCommands { include_activity: bool, /// Pagination cursor for next page - #[arg(long)] + #[arg(long, conflicts_with = "all")] cursor: Option<String>, + + /// Read messages newer than this exclusive UTC bound + #[arg(long)] + since: Option<String>, + + /// Read messages older than this exclusive UTC bound + #[arg(long)] + until: Option<String>, + + /// Fetch all pages (the numeric --limit is ignored) + #[arg(long)] + all: bool, + + /// Resolve author IDs and user mentions with one paginated user-directory load + #[arg(long)] + resolve_users: bool, }, /// Show thread replies @@ -61,6 +79,10 @@ pub enum MessagesCommands { /// Pagination cursor for next page #[arg(long)] cursor: Option<String>, + + /// Resolve author IDs and user mentions with one paginated user-directory load + #[arg(long)] + resolve_users: bool, }, /// Send a message @@ -130,6 +152,18 @@ pub enum MessagesCommands { /// Page number (1-indexed) #[arg(long, default_value = "1")] page: u32, + + /// Result ordering field + #[arg(long, value_enum, default_value = "timestamp")] + sort: SearchSort, + + /// Result ordering direction + #[arg(long, value_enum, default_value = "desc")] + sort_dir: SearchSortDirection, + + /// Resolve author IDs and user mentions with one paginated user-directory load + #[arg(long)] + resolve_users: bool, }, /// Get a single message by URL or channel:timestamp @@ -139,6 +173,44 @@ pub enum MessagesCommands { }, } +/// Search result ordering fields. +#[derive(Debug, Clone, Copy, ValueEnum, Default)] +pub enum SearchSort { + /// Order by Slack relevance score. + Score, + /// Order by message timestamp. + #[default] + Timestamp, +} + +impl SearchSort { + fn as_str(self) -> &'static str { + match self { + Self::Score => "score", + Self::Timestamp => "timestamp", + } + } +} + +/// Search result ordering directions. +#[derive(Debug, Clone, Copy, ValueEnum, Default)] +pub enum SearchSortDirection { + /// Oldest or lowest-score results first. + Asc, + /// Newest or highest-score results first. + #[default] + Desc, +} + +impl SearchSortDirection { + fn as_str(self) -> &'static str { + match self { + Self::Asc => "asc", + Self::Desc => "desc", + } + } +} + /// Message format options #[derive(Debug, Clone, Copy, ValueEnum, Default)] pub enum MessageFormat { @@ -168,16 +240,21 @@ pub async fn run( limit, include_activity, cursor, + since, + until, + all, + resolve_users, } => { - list_messages( - &client, - channel, + let options = ListOptions { limit, - *include_activity, - cursor.as_deref(), - output_mode, - ) - .await?; + include_activity: *include_activity, + cursor: cursor.as_deref(), + since: since.as_deref(), + until: until.as_deref(), + all: *all, + resolve_users: *resolve_users, + }; + list_messages(&client, channel, options, output_mode).await?; } MessagesCommands::Thread { @@ -186,17 +263,15 @@ pub async fn run( limit, include_activity, cursor, + resolve_users, } => { - thread_replies( - &client, - channel, - thread_ts, + let options = ThreadOptions { limit, - *include_activity, - cursor.as_deref(), - output_mode, - ) - .await?; + include_activity: *include_activity, + cursor: cursor.as_deref(), + resolve_users: *resolve_users, + }; + thread_replies(&client, channel, thread_ts, options, output_mode).await?; } MessagesCommands::Send { @@ -231,6 +306,9 @@ pub async fn run( threads_only, count, page, + sort, + sort_dir, + resolve_users, } => { let query_params = SearchQueryParams { query, @@ -246,6 +324,9 @@ pub async fn run( query_params, count: *count, page: *page, + sort: *sort, + sort_dir: *sort_dir, + resolve_users: *resolve_users, }; search_messages(&client, search_params, output_mode).await?; } @@ -258,112 +339,135 @@ pub async fn run( Ok(()) } -/// List messages in a channel +/// Options for a channel history read. +struct ListOptions<'a> { + limit: &'a str, + include_activity: bool, + cursor: Option<&'a str>, + since: Option<&'a str>, + until: Option<&'a str>, + all: bool, + resolve_users: bool, +} + +/// List messages in a channel. async fn list_messages( client: &SlackClient, channel: &str, - limit_str: &str, - include_activity: bool, - cursor: Option<&str>, + options: ListOptions<'_>, output_mode: OutputMode, ) -> Result<()> { - // Resolve channel name to ID + let time_limit = parse_time_limit(options.limit)?; + let (oldest, latest) = read_ops::list_bounds(&time_limit, options.since, options.until)?; let channel_id = client.resolve_channel(channel).await?; - // Parse the limit - let time_limit = parse_time_limit(limit_str)?; - - let mut params = ConversationsHistoryParams::new(&channel_id); - - match &time_limit { - TimeLimit::Count(count) => { - params = params.with_limit(*count); + let (messages, has_more, response_metadata) = if options.all { + let messages = client + .conversations_history_all(&channel_id, oldest.as_deref(), latest.as_deref()) + .await?; + (messages, false, None) + } else { + let mut params = ConversationsHistoryParams::new(&channel_id); + params = match &time_limit { + TimeLimit::Count(count) => params.with_limit(*count), + TimeLimit::Timestamp(_) => params.with_limit(100), + }; + if let Some(oldest) = &oldest { + params = params.with_oldest(oldest); } - TimeLimit::Timestamp(ts) => { - params = params.with_oldest(ts); - // When using timestamp, get up to 100 messages per page - params = params.with_limit(100); + if let Some(latest) = &latest { + params = params.with_latest(latest); + } + if let Some(cursor) = options.cursor { + params = params.with_cursor(cursor); } - } - - if let Some(c) = cursor { - params = params.with_cursor(c); - } - - let response = client.conversations_history(params).await?; - // Filter out activity messages if not requested - let messages: Vec<Message> = if include_activity { - response.messages - } else { - response - .messages - .into_iter() - .filter(|m| !is_activity_message(m)) - .collect() + let response = client.conversations_history(params).await?; + ( + response.messages, + response.has_more, + response.response_metadata, + ) }; + let messages = filter_activity(messages, options.include_activity); + let directory = load_user_directory(client, options.resolve_users, messages.is_empty()).await?; output_messages( &messages, &channel_id, output_mode, - response.has_more, - response.response_metadata, - )?; + has_more, + response_metadata, + directory.as_ref(), + ) +} - Ok(()) +/// Options for reading one page of thread replies. +struct ThreadOptions<'a> { + limit: &'a str, + include_activity: bool, + cursor: Option<&'a str>, + resolve_users: bool, } -/// Show thread replies +/// Show thread replies. async fn thread_replies( client: &SlackClient, channel: &str, thread_ts: &str, - limit_str: &str, - include_activity: bool, - cursor: Option<&str>, + options: ThreadOptions<'_>, output_mode: OutputMode, ) -> Result<()> { + let time_limit = parse_time_limit(options.limit)?; let channel_id = client.resolve_channel(channel).await?; - let time_limit = parse_time_limit(limit_str)?; - let mut params = ConversationsRepliesParams::new(&channel_id, thread_ts); - match &time_limit { - TimeLimit::Count(count) => { - params = params.with_limit(*count); - } - TimeLimit::Timestamp(_ts) => { - // Note: conversations.replies doesn't support oldest/latest, so we use limit - params = params.with_limit(100); - } - } + params = match &time_limit { + TimeLimit::Count(count) => params.with_limit(*count), + // conversations.replies does not support duration limits; preserve the + // existing page-size behavior. + TimeLimit::Timestamp(_) => params.with_limit(100), + }; - if let Some(c) = cursor { - params = params.with_cursor(c); + if let Some(cursor) = options.cursor { + params = params.with_cursor(cursor); } let response = client.conversations_replies(params).await?; - - let messages: Vec<Message> = if include_activity { - response.messages - } else { - response - .messages - .into_iter() - .filter(|m| !is_activity_message(m)) - .collect() - }; - + let messages = filter_activity(response.messages, options.include_activity); + let directory = load_user_directory(client, options.resolve_users, messages.is_empty()).await?; output_messages( &messages, &channel_id, output_mode, response.has_more, response.response_metadata, - )?; + directory.as_ref(), + ) +} - Ok(()) +fn filter_activity(messages: Vec<Message>, include_activity: bool) -> Vec<Message> { + if include_activity { + messages + } else { + messages + .into_iter() + .filter(|message| !is_activity_message(message)) + .collect() + } +} + +async fn load_user_directory( + client: &SlackClient, + resolve_users: bool, + messages_empty: bool, +) -> Result<Option<read_ops::UserDirectory>> { + if !resolve_users || messages_empty { + return Ok(None); + } + Ok(Some(read_ops::UserDirectory::from_users( + client.users_list_all().await?, + ))) } /// Send a message @@ -525,6 +629,9 @@ struct SearchParams<'a> { query_params: SearchQueryParams<'a>, count: u32, page: u32, + sort: SearchSort, + sort_dir: SearchSortDirection, + resolve_users: bool, } /// Search messages @@ -544,32 +651,22 @@ async fn search_messages( let api_params = SearchMessagesParams::new(&full_query) .with_count(params.count) .with_page(params.page) - .with_sort("timestamp", "desc"); + .with_sort(params.sort.as_str(), params.sort_dir.as_str()); let response = client.search_messages(api_params).await?; - - if output_mode == OutputMode::Plain { - let plain_messages: Vec<MessagePlain> = response - .messages - .matches - .iter() - .map(|m| MessagePlain { - timestamp: &m.ts, - user_id: m.user.as_deref().unwrap_or(""), - channel: m.channel.as_ref().map(|c| c.id.as_str()).unwrap_or(""), - text: m.text.as_deref().unwrap_or(""), - }) - .collect(); - write_messages_plain(&plain_messages)?; - } else { - write_json(&serde_json::json!({ - "total": response.messages.total, - "pagination": response.messages.pagination, - "messages": response.messages.matches, - }))?; - } - - Ok(()) + let directory = load_user_directory( + client, + params.resolve_users, + response.messages.matches.is_empty(), + ) + .await?; + output_search_messages( + &response.messages.matches, + response.messages.total, + response.messages.pagination, + output_mode, + directory.as_ref(), + ) } /// Get a single message by URL or channel:timestamp @@ -737,27 +834,107 @@ fn output_messages( output_mode: OutputMode, has_more: bool, response_metadata: Option<crate::api::ResponseMetadata>, + directory: Option<&read_ops::UserDirectory>, ) -> Result<()> { - if output_mode == OutputMode::Plain { + if let Some(directory) = directory { + let resolved = directory.resolve_texts(messages); + if output_mode == OutputMode::Plain { + write_plain_messages(&resolved, channel_id, directory) + } else { + write_json(&serde_json::json!({ + "messages": read_ops::resolved_views(&resolved, directory), + "has_more": has_more, + "response_metadata": response_metadata, + })) + } + } else if output_mode == OutputMode::Plain { let plain_messages: Vec<MessagePlain> = messages .iter() - .map(|m| MessagePlain { - timestamp: &m.ts, - user_id: m.user.as_deref().unwrap_or(""), + .map(|message| MessagePlain { + timestamp: &message.ts, + user_id: message.user.as_deref().unwrap_or(""), channel: channel_id, - text: m.text.as_deref().unwrap_or(""), + text: message.text.as_deref().unwrap_or(""), }) .collect(); - write_messages_plain(&plain_messages)?; + write_messages_plain(&plain_messages) } else { write_json(&serde_json::json!({ "messages": messages, "has_more": has_more, "response_metadata": response_metadata, - }))?; + })) } +} - Ok(()) +fn write_plain_messages( + messages: &[Message], + default_channel: &str, + directory: &read_ops::UserDirectory, +) -> Result<()> { + // The shared TSV writer handles tabs and line feeds. Normalize carriage + // returns here as well so resolved output remains exactly four columns. + let texts: Vec<String> = messages + .iter() + .map(|message| message.text.as_deref().unwrap_or("").replace('\r', "\\r")) + .collect(); + let plain_messages: Vec<MessagePlain> = messages + .iter() + .zip(&texts) + .map(|(message, text)| MessagePlain { + timestamp: &message.ts, + user_id: directory.name_for(message.user.as_deref()).unwrap_or(""), + channel: message + .channel + .as_ref() + .map(|channel| channel.id.as_str()) + .unwrap_or(default_channel), + text, + }) + .collect(); + write_messages_plain(&plain_messages) +} + +fn output_search_messages( + messages: &[Message], + total: u32, + pagination: Option<crate::api::SearchPagination>, + output_mode: OutputMode, + directory: Option<&read_ops::UserDirectory>, +) -> Result<()> { + if let Some(directory) = directory { + let resolved = directory.resolve_texts(messages); + if output_mode == OutputMode::Plain { + write_plain_messages(&resolved, "", directory) + } else { + write_json(&serde_json::json!({ + "total": total, + "pagination": pagination, + "messages": read_ops::resolved_views(&resolved, directory), + })) + } + } else if output_mode == OutputMode::Plain { + let plain_messages: Vec<MessagePlain> = messages + .iter() + .map(|message| MessagePlain { + timestamp: &message.ts, + user_id: message.user.as_deref().unwrap_or(""), + channel: message + .channel + .as_ref() + .map(|channel| channel.id.as_str()) + .unwrap_or(""), + text: message.text.as_deref().unwrap_or(""), + }) + .collect(); + write_messages_plain(&plain_messages) + } else { + write_json(&serde_json::json!({ + "total": total, + "pagination": pagination, + "messages": messages, + })) + } } #[cfg(test)] @@ -775,12 +952,20 @@ mod tests { limit, include_activity, cursor, + since, + until, + all, + resolve_users, } = cmd.command { assert_eq!(channel, "general"); assert_eq!(limit, "50"); assert!(!include_activity); assert!(cursor.is_none()); + assert!(since.is_none()); + assert!(until.is_none()); + assert!(!all); + assert!(!resolve_users); } else { panic!("Expected List command"); } @@ -789,6 +974,48 @@ mod tests { } } + #[test] + fn test_parse_messages_list_read_options() { + let cli = Cli::try_parse_from([ + "slack", + "messages", + "list", + "general", + "--since", + "2025-01-01", + "--until", + "2025-02-01", + "--all", + "--resolve-users", + ]) + .unwrap(); + if let crate::cli::Commands::Messages(cmd) = cli.command { + if let MessagesCommands::List { + since, + until, + all, + resolve_users, + .. + } = cmd.command + { + assert_eq!(since.as_deref(), Some("2025-01-01")); + assert_eq!(until.as_deref(), Some("2025-02-01")); + assert!(all); + assert!(resolve_users); + } else { + panic!("Expected List command"); + } + } + } + + #[test] + fn test_parse_messages_list_all_conflicts_with_cursor() { + assert!(Cli::try_parse_from([ + "slack", "messages", "list", "general", "--all", "--cursor", "next" + ]) + .is_err()); + } + #[test] fn test_parse_messages_list_with_limit() { let cli = @@ -865,6 +1092,26 @@ mod tests { } } + #[test] + fn test_parse_messages_thread_resolve_users() { + let cli = Cli::try_parse_from([ + "slack", + "messages", + "thread", + "general", + "1234567890.123456", + "--resolve-users", + ]) + .unwrap(); + if let crate::cli::Commands::Messages(cmd) = cli.command { + if let MessagesCommands::Thread { resolve_users, .. } = cmd.command { + assert!(resolve_users); + } else { + panic!("Expected Thread command"); + } + } + } + #[test] fn test_parse_messages_send_with_text() { let cli = @@ -984,12 +1231,21 @@ mod tests { let cli = Cli::try_parse_from(["slack", "messages", "search", "hello world"]).unwrap(); if let crate::cli::Commands::Messages(cmd) = cli.command { if let MessagesCommands::Search { - query, count, page, .. + query, + count, + page, + sort, + sort_dir, + resolve_users, + .. } = cmd.command { assert_eq!(query, "hello world"); assert_eq!(count, 20); assert_eq!(page, 1); + assert!(matches!(sort, SearchSort::Timestamp)); + assert!(matches!(sort_dir, SearchSortDirection::Desc)); + assert!(!resolve_users); } else { panic!("Expected Search command"); } @@ -998,6 +1254,50 @@ mod tests { } } + #[test] + fn test_parse_messages_search_sort_and_resolution() { + let cli = Cli::try_parse_from([ + "slack", + "messages", + "search", + "hello", + "--sort", + "score", + "--sort-dir", + "asc", + "--resolve-users", + ]) + .unwrap(); + if let crate::cli::Commands::Messages(cmd) = cli.command { + if let MessagesCommands::Search { + sort, + sort_dir, + resolve_users, + .. + } = cmd.command + { + assert!(matches!(sort, SearchSort::Score)); + assert!(matches!(sort_dir, SearchSortDirection::Asc)); + assert!(resolve_users); + } else { + panic!("Expected Search command"); + } + } + assert!( + Cli::try_parse_from(["slack", "messages", "search", "hello", "--sort", "newest"]) + .is_err() + ); + assert!(Cli::try_parse_from([ + "slack", + "messages", + "search", + "hello", + "--sort-dir", + "sideways" + ]) + .is_err()); + } + #[test] fn test_parse_messages_search_with_filters() { let cli = Cli::try_parse_from([ diff --git a/src/cli/messages/read_ops.rs b/src/cli/messages/read_ops.rs new file mode 100644 index 0000000..3e99977 --- /dev/null +++ b/src/cli/messages/read_ops.rs @@ -0,0 +1,308 @@ +//! Helpers for bounded message reads and optional user-name resolution. + +use std::collections::HashMap; + +use chrono::{DateTime, NaiveDate, Utc}; +use serde::Serialize; + +use crate::error::{Result, SlackError}; +use crate::models::{Message, User}; +use crate::utils::TimeLimit; + +/// A normalized Slack timestamp and its exact microsecond value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct Bound { + pub(super) timestamp: String, + micros: i128, +} + +/// Parse a date, RFC3339 instant, or Slack decimal timestamp. +pub(super) fn parse_bound(input: &str) -> Result<Bound> { + let input = input.trim(); + if input.is_empty() { + return invalid_bound(input); + } + + if let Ok(date) = NaiveDate::parse_from_str(input, "%Y-%m-%d") { + let datetime = date + .and_hms_opt(0, 0, 0) + .expect("midnight is always a valid time") + .and_utc(); + return from_datetime(datetime, input); + } + + if let Ok(datetime) = DateTime::parse_from_rfc3339(input) { + return from_datetime(datetime.with_timezone(&Utc), input); + } + + parse_decimal_bound(input) +} + +fn from_datetime(datetime: DateTime<Utc>, input: &str) -> Result<Bound> { + let seconds = datetime.timestamp(); + if seconds < 0 { + return invalid_bound(input); + } + let subsec_micros = datetime.timestamp_subsec_micros(); + Ok(Bound { + timestamp: format!("{}.{:06}", seconds, subsec_micros), + micros: i128::from(seconds) * 1_000_000 + i128::from(subsec_micros), + }) +} + +fn parse_decimal_bound(input: &str) -> Result<Bound> { + let mut pieces = input.split('.'); + let seconds = pieces.next().unwrap_or_default(); + let fraction = pieces.next(); + if pieces.next().is_some() + || seconds.is_empty() + || !seconds.bytes().all(|byte| byte.is_ascii_digit()) + { + return invalid_bound(input); + } + + let micros = match fraction { + Some(value) + if !value.is_empty() + && value.len() <= 6 + && value.bytes().all(|byte| byte.is_ascii_digit()) => + { + let mut padded = value.to_string(); + while padded.len() < 6 { + padded.push('0'); + } + padded.parse::<u32>().map_err(|_| usage_for_bound(input))? + } + Some(_) => return invalid_bound(input), + None => 0, + }; + + let seconds = seconds.parse::<u64>().map_err(|_| usage_for_bound(input))?; + let exact = i128::from(seconds) * 1_000_000 + i128::from(micros); + Ok(Bound { + timestamp: format!("{}.{:06}", seconds, micros), + micros: exact, + }) +} + +fn usage_for_bound(input: &str) -> SlackError { + SlackError::Usage(format!( + "Invalid time bound '{input}'; expected YYYY-MM-DD, RFC3339, or a nonnegative Slack timestamp" + )) +} + +fn invalid_bound<T>(input: &str) -> Result<T> { + Err(usage_for_bound(input)) +} + +/// Combine explicit bounds with a duration-style `--limit` oldest bound. +pub(super) fn list_bounds( + limit: &TimeLimit, + since: Option<&str>, + until: Option<&str>, +) -> Result<(Option<String>, Option<String>)> { + let duration_oldest = match limit { + TimeLimit::Timestamp(timestamp) => Some(parse_bound(timestamp)?), + TimeLimit::Count(_) => None, + }; + let explicit_oldest = since.map(parse_bound).transpose()?; + let latest = until.map(parse_bound).transpose()?; + + let oldest = match (duration_oldest, explicit_oldest) { + (Some(duration), Some(explicit)) => Some(if duration.micros >= explicit.micros { + duration + } else { + explicit + }), + (Some(duration), None) => Some(duration), + (None, Some(explicit)) => Some(explicit), + (None, None) => None, + }; + + if let (Some(oldest), Some(latest)) = (&oldest, &latest) { + if oldest.micros >= latest.micros { + return Err(SlackError::Usage( + "The oldest message bound must be earlier than the latest bound".to_string(), + )); + } + } + + Ok(( + oldest.map(|bound| bound.timestamp), + latest.map(|bound| bound.timestamp), + )) +} + +/// Workspace user names keyed by Slack user ID. +#[derive(Debug, Default)] +pub(super) struct UserDirectory { + names: HashMap<String, String>, +} + +impl UserDirectory { + pub(super) fn from_users(users: Vec<User>) -> Self { + let names = users + .into_iter() + .map(|user| { + let id = user.id.clone(); + let name = user + .name + .as_deref() + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| { + let display_name = user.display_name(); + if display_name.is_empty() { + id.clone() + } else { + display_name + } + }); + (id, name) + }) + .collect(); + Self { names } + } + + /// Return the resolved name, retaining an unknown ID as its own fallback. + pub(super) fn name_for<'a>(&'a self, user_id: Option<&'a str>) -> Option<&'a str> { + user_id.map(|id| self.names.get(id).map(String::as_str).unwrap_or(id)) + } + + pub(super) fn replace_mentions(&self, text: &str) -> String { + let mut output = String::with_capacity(text.len()); + let mut remaining = text; + + while let Some(start) = remaining.find("<@") { + output.push_str(&remaining[..start]); + let token = &remaining[start..]; + let Some(end) = token.find('>') else { + output.push_str(token); + return output; + }; + + let complete = &token[..=end]; + let inner = &token[2..end]; + let id = inner.split('|').next().unwrap_or_default(); + if id.starts_with('U') && id.len() > 1 { + if let Some(name) = self.names.get(id) { + output.push('@'); + output.push_str(name); + } else { + output.push_str(complete); + } + } else { + output.push_str(complete); + } + remaining = &token[end + 1..]; + } + + output.push_str(remaining); + output + } + + pub(super) fn resolve_texts(&self, messages: &[Message]) -> Vec<Message> { + messages + .iter() + .cloned() + .map(|mut message| { + if let Some(text) = message.text.take() { + message.text = Some(self.replace_mentions(&text)); + } + message + }) + .collect() + } +} + +/// JSON message view that preserves the API model and adds the requested name. +#[derive(Serialize)] +pub(super) struct ResolvedMessageView<'a> { + #[serde(flatten)] + pub(super) message: &'a Message, + pub(super) user_name: Option<&'a str>, +} + +pub(super) fn resolved_views<'a>( + messages: &'a [Message], + directory: &'a UserDirectory, +) -> Vec<ResolvedMessageView<'a>> { + messages + .iter() + .map(|message| ResolvedMessageView { + message, + user_name: directory.name_for(message.user.as_deref()), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::UserProfile; + + #[test] + fn parses_supported_bounds_without_losing_microseconds() { + assert_eq!( + parse_bound("2025-01-01").unwrap().timestamp, + "1735689600.000000" + ); + assert_eq!( + parse_bound("2025-01-01T01:02:03.123456+01:00") + .unwrap() + .timestamp, + "1735689723.123456" + ); + assert_eq!( + parse_bound("1735689600.000001").unwrap().timestamp, + "1735689600.000001" + ); + assert_eq!(parse_bound("1.2").unwrap().timestamp, "1.200000"); + } + + #[test] + fn rejects_invalid_bounds() { + for input in ["", "-1", "1.", ".1", "1.1234567", "not-a-date"] { + assert!(parse_bound(input).is_err(), "accepted {input}"); + } + } + + #[test] + fn validates_and_intersects_bounds() { + let duration = TimeLimit::Timestamp("20.000000".to_string()); + assert_eq!( + list_bounds(&duration, Some("10"), Some("30")).unwrap(), + (Some("20.000000".to_string()), Some("30.000000".to_string())) + ); + assert!(list_bounds(&duration, Some("30"), Some("30")).is_err()); + } + + #[test] + fn resolves_preferred_names_and_mentions() { + let users = vec![ + User { + id: "U123456789".to_string(), + name: Some("alice".to_string()), + ..Default::default() + }, + User { + id: "U987654321".to_string(), + profile: Some(UserProfile { + display_name: Some("Bob B".to_string()), + ..Default::default() + }), + ..Default::default() + }, + ]; + let directory = UserDirectory::from_users(users); + assert_eq!(directory.name_for(Some("U123456789")), Some("alice")); + assert_eq!(directory.name_for(Some("U000000000")), Some("U000000000")); + assert_eq!(directory.name_for(None), None); + assert_eq!( + directory.replace_mentions( + "Hi <@U123456789> and <@U987654321|old>; <@U000000000> <#C123|general>" + ), + "Hi @alice and @Bob B; <@U000000000> <#C123|general>" + ); + } +} diff --git a/tests/cli_messages_read.rs b/tests/cli_messages_read.rs new file mode 100644 index 0000000..b5819bc --- /dev/null +++ b/tests/cli_messages_read.rs @@ -0,0 +1,470 @@ +//! End-to-end coverage for bounded and user-resolved message reads. + +use std::path::Path; + +use assert_cmd::cargo::cargo_bin_cmd; +use assert_cmd::Command; +use mockito::{Matcher, ServerGuard}; +use tempfile::TempDir; + +const TOKEN: &str = "xoxp-read-test-token-1234567890"; +const CHANNEL: &str = "C123456789"; + +fn slack_cmd(server: &ServerGuard, store: &Path) -> Command { + let mut command = cargo_bin_cmd!("slack"); + command + .env("SLACK_API_BASE_URL", server.url()) + .env("SLACK_TOKEN_STORE_PATH", store) + .env("SLACK_TOKEN", TOKEN) + .env_remove("SLACK_WORKSPACE") + .env_remove("SLACK_PLAIN"); + command +} + +fn parse_json(output: &std::process::Output) -> serde_json::Value { + assert!( + output.status.success(), + "command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).expect("valid JSON output") +} + +fn form(fields: &[(&str, &str)]) -> Matcher { + Matcher::AllOf( + fields + .iter() + .map(|(key, value)| Matcher::UrlEncoded((*key).into(), (*value).into())) + .collect(), + ) +} + +#[tokio::test] +async fn list_passes_exclusive_normalized_bounds_and_cursor() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let history = server + .mock("POST", "/conversations.history") + .match_body(form(&[ + ("channel", CHANNEL), + ("cursor", "next-page"), + ("limit", "50"), + ("oldest", "1735689600.000000"), + ("latest", "1735776123.123456"), + ])) + .expect(1) + .with_status(200) + .with_body( + r#"{"ok":true,"messages":[{"type":"message","user":"U111111111","text":"hello","ts":"1735700000.000001"}],"has_more":true,"response_metadata":{"next_cursor":"after"}}"#, + ) + .create_async() + .await; + + let output = slack_cmd(&server, &temp.path().join("tokens.json")) + .args([ + "messages", + "list", + CHANNEL, + "--since", + "2025-01-01", + "--until", + "2025-01-02T00:02:03.123456Z", + "--cursor", + "next-page", + ]) + .output() + .unwrap(); + let json = parse_json(&output); + assert_eq!(json["has_more"], true); + assert_eq!(json["response_metadata"]["next_cursor"], "after"); + assert_eq!(json["messages"][0]["user"], "U111111111"); + assert!(json["messages"][0].get("user_name").is_none()); + history.assert_async().await; +} + +#[tokio::test] +async fn list_duration_and_since_use_the_later_oldest_bound() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let history = server + .mock("POST", "/conversations.history") + .match_body(form(&[ + ("channel", CHANNEL), + ("limit", "100"), + ("oldest", "9999999999.000001"), + ("latest", "9999999999.000002"), + ])) + .expect(1) + .with_status(200) + .with_body(r#"{"ok":true,"messages":[],"has_more":false}"#) + .create_async() + .await; + + let output = slack_cmd(&server, &temp.path().join("tokens.json")) + .args([ + "messages", + "list", + CHANNEL, + "--limit", + "7d", + "--since", + "9999999999.000001", + "--until", + "9999999999.000002", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + history.assert_async().await; +} + +#[tokio::test] +async fn invalid_or_reversed_bounds_fail_before_api_io() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let no_history = server + .mock("POST", "/conversations.history") + .expect(0) + .create_async() + .await; + + for args in [ + vec!["--since", "1.1234567"], + vec!["--since", "2", "--until", "2"], + vec!["--since", "3", "--until", "2"], + ] { + let mut command = slack_cmd(&server, &temp.path().join("tokens.json")); + command.args(["messages", "list", CHANNEL]); + let output = command.args(args).output().unwrap(); + assert_eq!(output.status.code(), Some(2)); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["code"], "usage_error"); + } + no_history.assert_async().await; +} + +#[tokio::test] +async fn list_all_collects_pages_ignores_numeric_limit_and_filters_activity() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let first = server + .mock("POST", "/conversations.history") + .match_body(form(&[ + ("channel", CHANNEL), + ("limit", "200"), + ("oldest", "10.000000"), + ("latest", "20.000000"), + ])) + .expect(1) + .with_status(200) + .with_body( + r#"{"ok":true,"messages":[{"user":"U111111111","text":"new","ts":"19.0"}],"has_more":true,"response_metadata":{"next_cursor":"page-2"}}"#, + ) + .create_async() + .await; + let second = server + .mock("POST", "/conversations.history") + .match_body(form(&[ + ("channel", CHANNEL), + ("cursor", "page-2"), + ("limit", "200"), + ("oldest", "10.000000"), + ("latest", "20.000000"), + ])) + .expect(1) + .with_status(200) + .with_body( + r#"{"ok":true,"messages":[{"subtype":"channel_join","text":"joined","ts":"18.0"},{"user":"U222222222","text":"old","ts":"17.0"}],"has_more":true,"response_metadata":{"next_cursor":""}}"#, + ) + .create_async() + .await; + + let output = slack_cmd(&server, &temp.path().join("tokens.json")) + .args([ + "messages", "list", CHANNEL, "--limit", "1", "--since", "10", "--until", "20", "--all", + ]) + .output() + .unwrap(); + let json = parse_json(&output); + assert_eq!(json["messages"].as_array().unwrap().len(), 2); + assert_eq!(json["messages"][0]["text"], "new"); + assert_eq!(json["messages"][1]["text"], "old"); + assert_eq!(json["has_more"], false); + assert!(json["response_metadata"].is_null()); + first.assert_async().await; + second.assert_async().await; +} + +#[tokio::test] +async fn list_resolves_users_with_one_complete_directory_traversal() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let history = server + .mock("POST", "/conversations.history") + .match_body(form(&[("channel", CHANNEL), ("limit", "50")])) + .expect(1) + .with_status(200) + .with_body( + r##"{"ok":true,"messages":[{"user":"U111111111","text":"Hi <@U222222222|legacy> <@U999999999> <#C222222222|elsewhere>","ts":"2.0"},{"user":"U999999999","text":"unknown author","ts":"1.0"},{"text":"system","ts":"0.5"}],"has_more":false}"##, + ) + .create_async() + .await; + let users_one = server + .mock("POST", "/users.list") + .match_body(form(&[("limit", "200")])) + .expect(1) + .with_status(200) + .with_body( + r#"{"ok":true,"members":[{"id":"U111111111","name":"alice"}],"response_metadata":{"next_cursor":"users-2"}}"#, + ) + .create_async() + .await; + let users_two = server + .mock("POST", "/users.list") + .match_body(form(&[("cursor", "users-2"), ("limit", "200")])) + .expect(1) + .with_status(200) + .with_body( + r#"{"ok":true,"members":[{"id":"U222222222","name":"","profile":{"display_name":"Bob"}}],"response_metadata":{"next_cursor":""}}"#, + ) + .create_async() + .await; + + let output = slack_cmd(&server, &temp.path().join("tokens.json")) + .args(["messages", "list", CHANNEL, "--resolve-users"]) + .output() + .unwrap(); + let json = parse_json(&output); + assert_eq!(json["messages"][0]["user"], "U111111111"); + assert_eq!(json["messages"][0]["user_name"], "alice"); + assert_eq!( + json["messages"][0]["text"], + "Hi @Bob <@U999999999> <#C222222222|elsewhere>" + ); + assert_eq!(json["messages"][1]["user_name"], "U999999999"); + assert!(json["messages"][2]["user_name"].is_null()); + history.assert_async().await; + users_one.assert_async().await; + users_two.assert_async().await; +} + +#[tokio::test] +async fn directory_is_not_loaded_without_flag_or_for_empty_results() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let history = server + .mock("POST", "/conversations.history") + .expect(2) + .with_status(200) + .with_body(r#"{"ok":true,"messages":[],"has_more":false}"#) + .create_async() + .await; + let no_users = server + .mock("POST", "/users.list") + .expect(0) + .create_async() + .await; + + let normal = slack_cmd(&server, &temp.path().join("tokens.json")) + .args(["messages", "list", CHANNEL]) + .output() + .unwrap(); + assert!(normal.status.success()); + let resolved_empty = slack_cmd(&server, &temp.path().join("tokens.json")) + .args(["messages", "list", CHANNEL, "--resolve-users"]) + .output() + .unwrap(); + assert!(resolved_empty.status.success()); + history.assert_async().await; + no_users.assert_async().await; +} + +#[tokio::test] +async fn thread_resolved_plain_is_four_column_escaped_tsv() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let replies = server + .mock("POST", "/conversations.replies") + .match_body(form(&[ + ("channel", CHANNEL), + ("ts", "1.000001"), + ("limit", "100"), + ])) + .expect(1) + .with_status(200) + .with_body( + r#"{"ok":true,"messages":[{"user":"U111111111","text":"hi\t<@U111111111>\nnext\rrow","ts":"1.000002"}],"has_more":false}"#, + ) + .create_async() + .await; + let users = server + .mock("POST", "/users.list") + .expect(1) + .with_status(200) + .with_body( + r#"{"ok":true,"members":[{"id":"U111111111","name":"alice"}],"response_metadata":{"next_cursor":""}}"#, + ) + .create_async() + .await; + + let output = slack_cmd(&server, &temp.path().join("tokens.json")) + .args([ + "--plain", + "messages", + "thread", + CHANNEL, + "1.000001", + "--resolve-users", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + "1.000002\talice\tC123456789\thi\\t@alice\\nnext\\rrow\n" + ); + replies.assert_async().await; + users.assert_async().await; +} + +#[tokio::test] +async fn search_forwards_sort_and_resolves_json_once() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let search = server + .mock("POST", "/search.messages") + .match_body(form(&[ + ("query", "deploy in:ops"), + ("sort", "score"), + ("sort_dir", "asc"), + ("count", "20"), + ("page", "1"), + ])) + .expect(1) + .with_status(200) + .with_body( + r#"{"ok":true,"messages":{"total":1,"pagination":{"total_count":1,"page":1,"per_page":20,"page_count":1,"first":1,"last":1},"matches":[{"user":"U111111111","text":"ask <@U111111111>","ts":"3.0","channel":{"id":"C999999999","name":"ops"}}]}}"#, + ) + .create_async() + .await; + let users = server + .mock("POST", "/users.list") + .expect(1) + .with_status(200) + .with_body( + r#"{"ok":true,"members":[{"id":"U111111111","name":"alice"}],"response_metadata":{"next_cursor":null}}"#, + ) + .create_async() + .await; + + let output = slack_cmd(&server, &temp.path().join("tokens.json")) + .args([ + "messages", + "search", + "deploy", + "--in-channel", + "#ops", + "--sort", + "score", + "--sort-dir", + "asc", + "--resolve-users", + ]) + .output() + .unwrap(); + let json = parse_json(&output); + assert_eq!(json["total"], 1); + assert_eq!(json["pagination"]["page"], 1); + assert_eq!(json["messages"][0]["user_name"], "alice"); + assert_eq!(json["messages"][0]["text"], "ask @alice"); + search.assert_async().await; + users.assert_async().await; +} + +#[tokio::test] +async fn search_defaults_and_plain_resolution_keep_channel_position() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let search = server + .mock("POST", "/search.messages") + .match_body(form(&[ + ("query", "hello"), + ("sort", "timestamp"), + ("sort_dir", "desc"), + ("count", "20"), + ("page", "1"), + ])) + .expect(1) + .with_status(200) + .with_body( + r#"{"ok":true,"messages":{"total":1,"pagination":null,"matches":[{"user":"U111111111","text":"hello","ts":"3.0","channel":"C999999999"}]}}"#, + ) + .create_async() + .await; + let users = server + .mock("POST", "/users.list") + .expect(1) + .with_status(200) + .with_body(r#"{"ok":true,"members":[{"id":"U111111111","name":"alice"}]}"#) + .create_async() + .await; + + let output = slack_cmd(&server, &temp.path().join("tokens.json")) + .args(["--plain", "messages", "search", "hello", "--resolve-users"]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + String::from_utf8(output.stdout).unwrap(), + "3.0\talice\tC999999999\thello\n" + ); + search.assert_async().await; + users.assert_async().await; +} + +#[tokio::test] +async fn user_directory_api_failure_fails_the_read() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let history = server + .mock("POST", "/conversations.history") + .expect(1) + .with_status(200) + .with_body(r#"{"ok":true,"messages":[{"user":"U111111111","ts":"1.0"}]}"#) + .create_async() + .await; + let users = server + .mock("POST", "/users.list") + .expect(1) + .with_status(200) + .with_body(r#"{"ok":false,"error":"missing_scope"}"#) + .create_async() + .await; + + let output = slack_cmd(&server, &temp.path().join("tokens.json")) + .args(["messages", "list", CHANNEL, "--resolve-users"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["code"], "api_error"); + history.assert_async().await; + users.assert_async().await; +} + +#[tokio::test] +async fn all_and_cursor_conflict_fails_without_api_io() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let no_history = server + .mock("POST", "/conversations.history") + .expect(0) + .create_async() + .await; + let output = slack_cmd(&server, &temp.path().join("tokens.json")) + .args(["messages", "list", CHANNEL, "--all", "--cursor", "next"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(2)); + no_history.assert_async().await; +} From f8bbea56bb6714c290a3b267fa86644b016821ae Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Wed, 9 Sep 2026 23:50:44 +1000 Subject: [PATCH 06/22] feat(messages-write): Message mutations, enriched permalinks, and scheduled sending --- CHANGELOG.md | 6 + README.md | 56 ++++ skills/slack/MESSAGES.md | 44 +++ skills/slack/SKILL.md | 18 ++ src/api/chat_ops.rs | 207 ++++++++++++ src/api/mod.rs | 1 + src/cli/messages.rs | 456 +++++++++++++++++++++------ src/cli/messages/write_ops.rs | 256 +++++++++++++++ src/cli/reminders.rs | 95 +++--- tests/cli_messages_write.rs | 571 ++++++++++++++++++++++++++++++++++ 10 files changed, 1578 insertions(+), 132 deletions(-) create mode 100644 src/api/chat_ops.rs create mode 100644 src/cli/messages/write_ops.rs create mode 100644 tests/cli_messages_write.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 09128fe..0a1bbd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Message mutations and permalinks**: edit and delete messages, mark channels + read, retrieve strict permalinks, and best-effort enrich successful send/get + JSON while preserving plain output. +- **Advanced and scheduled sending**: send Block Kit payloads and broadcast + thread replies, schedule messages up to 120 days ahead, and list or delete + queued messages. - **Message reading**: read exclusive date/timestamp-bounded channel history, collect all history pages, resolve authors and mentions from one paginated user-directory traversal, and select search result sort order. diff --git a/README.md b/README.md index 6bccac0..6bc2eb1 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,62 @@ Inline code `` `…` ``, fenced code blocks ```` ``` ````, and existing mrkdwn spans (`<@U…>` mentions, `<url|text>` links) are passed through untouched. Use `--format plain` to send text verbatim with mrkdwn parsing disabled. +Immediate send JSON adds a top-level `"permalink"`, and `messages get` fills +the message object's existing `"permalink"` field, for example: + +```json +{"ok":true,"channel":"C123456789","ts":"1234567890.123456","message":{"ts":"1234567890.123456"},"permalink":"https://workspace.slack.com/archives/C123456789/p1234567890123456"} +{"ts":"1234567890.123456","text":"hello","permalink":"https://workspace.slack.com/archives/C123456789/p1234567890123456"} +``` + +Permalink lookup is best-effort for these two commands: a successful send/read +remains successful with `permalink: null` and a warning on stderr if enrichment +fails. Their existing `--plain` output is unchanged and makes no permalink +request. + +#### Message operations + +```bash +slack messages edit "#general:1234567890.123456" "Updated **text**" +slack messages edit "https://workspace.slack.com/archives/C123456789/p1234567890123456" "literal *text*" --format plain +slack messages delete "#general:1234567890.123456" +slack messages permalink "#general:1234567890.123456" +slack messages mark "#general" 1234567890.123456 +``` + +Identifiers are parsed locally as `channel:timestamp` or Slack permalinks; +permalink URLs are never fetched. Explicit `messages permalink` errors are +strictly propagated. Mutation JSON reports `ok`, channel, and message IDs; +`--plain` prints only the timestamp (or URL for `permalink`). Delete does not +prompt for confirmation, and Slack remains authoritative for ownership and +permissions. + +#### Advanced and scheduled sending + +```bash +# Broadcast a thread reply (requires --thread-ts) +slack messages send "#general" "Visible reply" --thread-ts 1234567890.123456 --broadcast + +# Block Kit from a file, with optional fallback text +slack messages send "#general" "Fallback text" --blocks blocks.json +cat blocks.json | slack messages send "#general" --blocks - + +# Natural times use the machine's local timezone +slack messages send "#general" "Daily summary" --schedule "tomorrow at 9am" +slack messages scheduled list +slack messages scheduled delete "#general" Q123456789 +``` + +`--blocks` must contain a nonempty JSON array; block strings are not Markdown +converted. Block-only sends are allowed. `--blocks -` owns stdin and conflicts +with `--stdin`. Scheduling accepts natural expressions, Unix timestamps, +RFC3339, and existing reminder date forms, must be in the future and no more +than 120 days away, and supports threads, formats, and blocks. `--schedule` +conflicts with `--mark-read` and `--broadcast`. Scheduled sends do not post, +mark read, or fetch a permalink; JSON reports `permalink: null`, while plain +send output remains one scheduled message ID. Scheduled list plain output is +`id<TAB>channel_id<TAB>post_at<TAB>text`. + ### Users (`slack users` or `slack u`) ```bash diff --git a/skills/slack/MESSAGES.md b/skills/slack/MESSAGES.md index c087408..f121586 100644 --- a/skills/slack/MESSAGES.md +++ b/skills/slack/MESSAGES.md @@ -160,3 +160,47 @@ when the message has no user, with the ID as fallback for an unknown user). Without the flag, JSON is unchanged. Plain output always has four TSV columns (timestamp, author, channel, text); its author column changes from ID to name only when `--resolve-users` is explicitly requested. + +## Advanced sending + +```bash +# Broadcast requires a thread parent +slack messages send "#general" "Reply" --thread-ts 1234567890.123456 --broadcast + +# A nonempty Block Kit JSON array, with or without fallback text +slack messages send "#general" "Fallback" --blocks blocks.json +cat blocks.json | slack messages send "#general" --blocks - + +# Schedule using local natural time, Unix time, RFC3339, or a date form +slack messages send "#general" "Report" --schedule "tomorrow 9am" +``` + +Markdown conversion applies only to fallback text, never strings inside +blocks. `--blocks -` owns stdin and conflicts with `--stdin`. Scheduling allows +`--thread-ts`, `--format`, and `--blocks`, but conflicts with `--mark-read` and +`--broadcast`; it must be future and at most 120 days away. Natural expressions +use the machine's local timezone. Scheduled sends never call the immediate +post, mark-read, or permalink endpoints. + +## Mutate messages and manage scheduled messages + +```bash +slack messages edit "#general:1234567890.123456" "Updated **text**" +slack messages delete "https://workspace.slack.com/archives/C123456789/p1234567890123456" +slack messages permalink "#general:1234567890.123456" +slack messages mark "#general" 1234567890.123456 +slack messages scheduled list +slack messages scheduled delete "#general" Q123456789 +``` + +Edit, delete, and permalink accept `channel:timestamp` or a locally parsed +Slack permalink; URLs are never fetched. Explicit permalink errors fail the +command. Slack enforces message ownership, scheduling limits, and permissions, +and delete does not prompt. + +Immediate send JSON has a top-level `permalink`; get JSON populates the +message's `permalink`. If best-effort enrichment fails, they warn on stderr and +emit `null` without failing the successful operation. Plain send/get output is +unchanged and does not request enrichment. Scheduled send JSON always has +`permalink: null`; plain prints only its scheduled ID. Scheduled list plain +output is `id<TAB>channel_id<TAB>post_at<TAB>text`. diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md index 9e35917..d69301c 100644 --- a/skills/slack/SKILL.md +++ b/skills/slack/SKILL.md @@ -148,6 +148,24 @@ Use `--format plain` to send text verbatim (no conversion, mrkdwn parsing off). slack messages send "#general" "Deploy **failed** on [prod](https://ci/123) — see logs" ``` +**Advanced sending and mutations:** + +```bash +slack messages send "#general" "Fallback" --blocks blocks.json +slack messages send "#general" "Reply" --thread-ts 1234567890.123456 --broadcast +slack messages send "#general" "Tomorrow" --schedule "tomorrow at 9am" +slack messages edit "#general:1234567890.123456" "Corrected text" +slack messages permalink "#general:1234567890.123456" +slack messages scheduled list +``` + +Natural schedule expressions use local time and must be future times within +120 days. `--schedule` conflicts with `--mark-read` and `--broadcast`. +`--blocks -` reads the JSON array from stdin and cannot be combined with +`--stdin`. Immediate JSON send/get permalink enrichment is best-effort and may +produce `permalink: null`; plain output stays stable and skips enrichment. See +[MESSAGES.md](MESSAGES.md). + ### Read messages ```bash # Last 50 messages in a channel diff --git a/src/api/chat_ops.rs b/src/api/chat_ops.rs new file mode 100644 index 0000000..d049f4a --- /dev/null +++ b/src/api/chat_ops.rs @@ -0,0 +1,207 @@ +//! Slack Web API methods for message mutations and scheduled messages. + +use serde::{Deserialize, Serialize}; + +use crate::error::Result; + +use super::{ResponseMetadata, SlackClient}; + +/// Parameters for `chat.scheduleMessage`. +#[derive(Debug, Serialize)] +pub struct ChatScheduleMessageParams { + pub channel: String, + pub post_at: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_ts: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub blocks: Option<serde_json::Value>, + #[serde(skip_serializing_if = "Option::is_none")] + pub mrkdwn: Option<bool>, +} + +impl ChatScheduleMessageParams { + /// Create scheduling parameters for a channel and Unix timestamp. + pub fn new(channel: impl Into<String>, post_at: i64) -> Self { + Self { + channel: channel.into(), + post_at, + text: None, + thread_ts: None, + blocks: None, + mrkdwn: None, + } + } + + /// Set fallback/message text. + pub fn with_text(mut self, text: impl Into<String>) -> Self { + self.text = Some(text.into()); + self + } + + /// Schedule a thread reply. + pub fn in_thread(mut self, thread_ts: impl Into<String>) -> Self { + self.thread_ts = Some(thread_ts.into()); + self + } + + /// Set Block Kit blocks. + pub fn with_blocks(mut self, blocks: serde_json::Value) -> Self { + self.blocks = Some(blocks); + self + } +} + +/// Response from `chat.getPermalink`. +#[derive(Debug, Deserialize)] +pub struct ChatPermalinkResponse { + pub channel: String, + pub permalink: String, +} + +/// Response from `chat.scheduleMessage`. +#[derive(Debug, Deserialize)] +pub struct ChatScheduleMessageResponse { + pub channel: String, + pub scheduled_message_id: String, + pub post_at: i64, +} + +/// A queued Slack message returned by `chat.scheduledMessages.list`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScheduledMessage { + pub id: String, + pub channel_id: String, + pub post_at: i64, + #[serde(default)] + pub text: String, + #[serde(default)] + pub date_created: Option<i64>, +} + +/// One page from `chat.scheduledMessages.list`. +#[derive(Debug, Deserialize)] +pub struct ScheduledMessagesResponse { + #[serde(default)] + pub scheduled_messages: Vec<ScheduledMessage>, + #[serde(default)] + pub response_metadata: Option<ResponseMetadata>, +} + +#[derive(Serialize)] +struct MessageTarget<'a> { + channel: &'a str, + ts: &'a str, +} + +impl SlackClient { + /// Update a message with `chat.update`. + pub async fn chat_update( + &self, + channel: &str, + ts: &str, + text: &str, + mrkdwn: bool, + ) -> Result<()> { + #[derive(Serialize)] + struct Params<'a> { + channel: &'a str, + ts: &'a str, + text: &'a str, + mrkdwn: bool, + } + + let _: serde_json::Value = self + .request( + "chat.update", + &Params { + channel, + ts, + text, + mrkdwn, + }, + ) + .await?; + Ok(()) + } + + /// Delete a message with `chat.delete`. + pub async fn chat_delete(&self, channel: &str, ts: &str) -> Result<()> { + let _: serde_json::Value = self + .request("chat.delete", &MessageTarget { channel, ts }) + .await?; + Ok(()) + } + + /// Get a message permalink with `chat.getPermalink`. + pub async fn chat_get_permalink( + &self, + channel: &str, + message_ts: &str, + ) -> Result<ChatPermalinkResponse> { + #[derive(Serialize)] + struct Params<'a> { + channel: &'a str, + message_ts: &'a str, + } + + self.request( + "chat.getPermalink", + &Params { + channel, + message_ts, + }, + ) + .await + } + + /// Schedule a message with `chat.scheduleMessage`. + pub async fn chat_schedule_message( + &self, + params: ChatScheduleMessageParams, + ) -> Result<ChatScheduleMessageResponse> { + self.request("chat.scheduleMessage", ¶ms).await + } + + /// List one page of scheduled messages. + pub async fn chat_scheduled_messages_list( + &self, + limit: u32, + cursor: Option<&str>, + ) -> Result<ScheduledMessagesResponse> { + #[derive(Serialize)] + struct Params<'a> { + limit: u32, + #[serde(skip_serializing_if = "Option::is_none")] + cursor: Option<&'a str>, + } + + self.request("chat.scheduledMessages.list", &Params { limit, cursor }) + .await + } + + /// Delete a queued message with `chat.deleteScheduledMessage`. + pub async fn chat_delete_scheduled_message( + &self, + channel: &str, + scheduled_message_id: &str, + ) -> Result<()> { + #[derive(Serialize)] + struct Params<'a> { + channel: &'a str, + scheduled_message_id: &'a str, + } + + let _: serde_json::Value = self + .request( + "chat.deleteScheduledMessage", + &Params { + channel, + scheduled_message_id, + }, + ) + .await?; + Ok(()) + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index aaf0d93..b0b754e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -9,6 +9,7 @@ pub mod bookmark_ops; pub mod channel_ops; +pub mod chat_ops; mod client; pub mod edge; pub mod identity_ops; diff --git a/src/cli/messages.rs b/src/cli/messages.rs index 92ce38a..0bfb2a8 100644 --- a/src/cli/messages.rs +++ b/src/cli/messages.rs @@ -15,6 +15,7 @@ use crate::output::{write_json, write_messages_plain, MessagePlain, OutputMode}; use crate::utils::{parse_time_limit, TimeLimit}; mod read_ops; +mod write_ops; /// Message operations commands #[derive(Args, Debug)] @@ -90,7 +91,7 @@ pub enum MessagesCommands { /// Channel name or ID channel: String, - /// Message text (optional if using --stdin) + /// Message text (optional if using --stdin or --blocks) text: Option<String>, /// Read message from stdin @@ -110,6 +111,55 @@ pub enum MessagesCommands { /// Mark channel as read after sending (sets read marker to the sent message) #[arg(long)] mark_read: bool, + + /// Broadcast a thread reply to the channel + #[arg(long)] + broadcast: bool, + + /// Read a Block Kit JSON array from a file, or from stdin with `-` + #[arg(long, value_name = "FILE.JSON|-", conflicts_with_all = ["stdin"])] + blocks: Option<String>, + + /// Schedule delivery using a Unix timestamp, RFC3339, or natural expression + #[arg(long, value_name = "WHEN")] + schedule: Option<String>, + }, + + /// Edit an existing message + Edit { + /// Message identifier: permalink URL or "channel:timestamp" format + message: String, + /// Replacement message text + text: String, + /// Replacement text format + #[arg(long, value_enum, default_value = "markdown")] + format: MessageFormat, + }, + + /// Delete an existing message + Delete { + /// Message identifier: permalink URL or "channel:timestamp" format + message: String, + }, + + /// Get a permalink for an existing message + Permalink { + /// Message identifier: permalink URL or "channel:timestamp" format + message: String, + }, + + /// Mark a channel read through a timestamp + Mark { + /// Channel name or ID + channel: String, + /// Message timestamp + ts: String, + }, + + /// Manage scheduled messages + Scheduled { + #[command(subcommand)] + command: ScheduledCommands, }, /// Search messages @@ -173,6 +223,20 @@ pub enum MessagesCommands { }, } +/// Scheduled-message subcommands. +#[derive(Subcommand, Debug)] +pub enum ScheduledCommands { + /// List all scheduled messages + List, + /// Delete a scheduled message + Delete { + /// Channel name or ID + channel: String, + /// Scheduled message ID + scheduled_message_id: String, + }, +} + /// Search result ordering fields. #[derive(Debug, Clone, Copy, ValueEnum, Default)] pub enum SearchSort { @@ -281,20 +345,60 @@ pub async fn run( thread_ts, format, mark_read, + broadcast, + blocks, + schedule, } => { - send_message( - &client, - channel, - text.clone(), - *stdin, - thread_ts.as_deref(), - *format, - *mark_read, - output_mode, - ) - .await?; + let options = SendOptions { + from_stdin: *stdin, + thread_ts: thread_ts.as_deref(), + format: *format, + mark_read: *mark_read, + broadcast: *broadcast, + blocks: blocks.as_deref(), + schedule: schedule.as_deref(), + }; + send_message(&client, channel, text.clone(), options, output_mode).await?; + } + + MessagesCommands::Edit { + message, + text, + format, + } => { + write_ops::edit_message(&client, message, text, *format, output_mode).await?; } + MessagesCommands::Delete { message } => { + write_ops::delete_message(&client, message, output_mode).await?; + } + + MessagesCommands::Permalink { message } => { + write_ops::permalink_message(&client, message, output_mode).await?; + } + + MessagesCommands::Mark { channel, ts } => { + write_ops::mark_message(&client, channel, ts, output_mode).await?; + } + + MessagesCommands::Scheduled { command } => match command { + ScheduledCommands::List => { + write_ops::list_scheduled_messages(&client, output_mode).await?; + } + ScheduledCommands::Delete { + channel, + scheduled_message_id, + } => { + write_ops::delete_scheduled_message( + &client, + channel, + scheduled_message_id, + output_mode, + ) + .await?; + } + }, + MessagesCommands::Search { query, in_channel, @@ -470,88 +574,162 @@ async fn load_user_directory( ))) } -/// Send a message -/// -/// If `mark_read` is true, marks the channel as read after sending. -#[allow(clippy::too_many_arguments)] +/// Options for sending immediately or scheduling a message. +struct SendOptions<'a> { + from_stdin: bool, + thread_ts: Option<&'a str>, + format: MessageFormat, + mark_read: bool, + broadcast: bool, + blocks: Option<&'a str>, + schedule: Option<&'a str>, +} + +/// Send a message immediately or schedule it for later. async fn send_message( client: &SlackClient, channel: &str, text: Option<String>, - from_stdin: bool, - thread_ts: Option<&str>, - format: MessageFormat, - mark_read: bool, + options: SendOptions<'_>, output_mode: OutputMode, ) -> Result<()> { - let channel_id = client.resolve_channel(channel).await?; + if options.broadcast && options.thread_ts.is_none() { + return Err(SlackError::Usage( + "--broadcast requires --thread-ts".to_string(), + )); + } + if options.schedule.is_some() && options.mark_read { + return Err(SlackError::Usage( + "--schedule cannot be used with --mark-read".to_string(), + )); + } + if options.schedule.is_some() && options.broadcast { + return Err(SlackError::Usage( + "--schedule cannot be used with --broadcast".to_string(), + )); + } + if options.from_stdin && options.blocks == Some("-") { + return Err(SlackError::Usage( + "--blocks - cannot be used with --stdin".to_string(), + )); + } - // Get message text - let message_text = if from_stdin { - read_stdin().await? + let message_text = if options.from_stdin { + Some(read_stdin().await?) } else { - text.ok_or_else(|| { - SlackError::Usage("Message text required. Provide TEXT or use --stdin".to_string()) - })? + text + }; + let blocks = match options.blocks { + Some(path) => Some(read_blocks(path).await?), + None => None, }; - if message_text.trim().is_empty() { + if message_text.is_none() && blocks.is_none() { + return Err(SlackError::Usage( + "Message text or --blocks is required. Provide TEXT or use --stdin".to_string(), + )); + } + if message_text + .as_deref() + .map(|value| value.trim().is_empty()) + .unwrap_or(false) + && blocks.is_none() + { return Err(SlackError::Usage( "Message text cannot be empty".to_string(), )); } - // Convert the message text according to the requested format. - let outgoing_text = match format { - // Standard Markdown -> Slack mrkdwn (**bold** -> *bold*, - // [t](url) -> <url|t>, bullets -> •, etc.). Slack parses the `text` - // field as mrkdwn by default, so without this common Markdown renders - // as literal characters. See issue #1. + let outgoing_text = message_text.map(|message_text| match options.format { MessageFormat::Markdown => crate::utils::markdown_to_mrkdwn(&message_text), - // Plain: send as-is and disable mrkdwn parsing below. - MessageFormat::Plain => message_text.clone(), - }; - - let mut params = ChatPostMessageParams::new(&channel_id).with_text(&outgoing_text); + MessageFormat::Plain => message_text, + }); + + if let Some(when) = options.schedule { + let post_at = crate::cli::reminders::parse_when(when)?; + let schedule_options = write_ops::ScheduleOptions { + text: outgoing_text.as_deref(), + thread_ts: options.thread_ts, + format: options.format, + blocks, + }; + return write_ops::schedule_message( + client, + channel, + post_at, + schedule_options, + output_mode, + ) + .await; + } - if let Some(ts) = thread_ts { + let channel_id = client.resolve_channel(channel).await?; + let mut params = ChatPostMessageParams::new(&channel_id); + if let Some(text) = &outgoing_text { + params = params.with_text(text); + } + if let Some(ts) = options.thread_ts { params = params.in_thread(ts); } - - // Set markdown based on format - match format { - MessageFormat::Markdown => { - // mrkdwn parsing is enabled by default in Slack; the text has - // already been converted from Markdown above. - } - MessageFormat::Plain => { - params.mrkdwn = Some(false); - } + if options.broadcast { + params = params.reply_broadcast(true); + } + if let Some(blocks) = blocks { + params = params.with_blocks(blocks); + } + if matches!(options.format, MessageFormat::Plain) { + params.mrkdwn = Some(false); } let response = client.chat_post_message(params).await?; - - // Mark channel as read if requested - if mark_read { + if options.mark_read { let mark_params = ConversationsMarkParams::new(&channel_id, &response.ts); client.conversations_mark(mark_params).await?; } if output_mode == OutputMode::Plain { - // Just output the timestamp println!("{}", response.ts); } else { + let permalink = write_ops::best_effort_permalink( + client, + &response.channel, + &response.ts, + response.message.permalink.as_deref(), + ) + .await; write_json(&serde_json::json!({ "ok": true, "channel": response.channel, "ts": response.ts, "message": response.message, + "permalink": permalink, }))?; } Ok(()) } +async fn read_blocks(path: &str) -> Result<serde_json::Value> { + let content = if path == "-" { + read_stdin().await? + } else { + std::fs::read_to_string(path).map_err(|error| { + SlackError::Usage(format!("Could not read blocks JSON '{}': {}", path, error)) + })? + }; + let value: serde_json::Value = serde_json::from_str(&content) + .map_err(|error| SlackError::Usage(format!("Invalid blocks JSON: {}", error)))?; + match value.as_array() { + Some(items) if !items.is_empty() => Ok(value), + Some(_) => Err(SlackError::Usage( + "Blocks JSON array cannot be empty".to_string(), + )), + None => Err(SlackError::Usage( + "Blocks JSON must be a non-empty array".to_string(), + )), + } +} + /// Read message content from stdin async fn read_stdin() -> Result<String> { let stdin = io::stdin(); @@ -689,7 +867,7 @@ async fn get_message( let response = client.conversations_history(params).await?; - let message = response + let mut message = response .messages .into_iter() .next() @@ -698,6 +876,16 @@ async fn get_message( detail: Some(format!("Message {} not found in channel", ts)), })?; + if output_mode != OutputMode::Plain { + message.permalink = write_ops::best_effort_permalink( + client, + &resolved_channel, + &message.ts, + message.permalink.as_deref(), + ) + .await; + } + if output_mode == OutputMode::Plain { println!("ts\t{}", message.ts); if let Some(user) = &message.user { @@ -720,24 +908,20 @@ async fn get_message( } /// Parse a message identifier (URL or channel:timestamp format) -fn parse_message_identifier(identifier: &str) -> Result<(String, String)> { - // Check if it's a Slack permalink URL +pub(super) fn parse_message_identifier(identifier: &str) -> Result<(String, String)> { if identifier.starts_with("https://") || identifier.starts_with("http://") { return parse_slack_permalink(identifier); } - // Check for channel:timestamp format if let Some(pos) = identifier.rfind(':') { let channel = &identifier[..pos]; let ts = &identifier[pos + 1..]; - - if channel.is_empty() || ts.is_empty() { - return Err(SlackError::Usage( - "Invalid format. Use 'channel:timestamp' or Slack permalink URL".to_string(), - )); + if !channel.is_empty() && valid_slack_timestamp(ts) { + return Ok((channel.to_string(), ts.to_string())); } - - return Ok((channel.to_string(), ts.to_string())); + return Err(SlackError::Usage( + "Invalid format. Use 'channel:timestamp' or Slack permalink URL".to_string(), + )); } Err(SlackError::Usage( @@ -745,6 +929,19 @@ fn parse_message_identifier(identifier: &str) -> Result<(String, String)> { )) } +fn valid_slack_timestamp(ts: &str) -> bool { + let mut parts = ts.split('.'); + matches!( + (parts.next(), parts.next(), parts.next()), + (Some(seconds), Some(micros), None) + if !seconds.is_empty() + && !micros.is_empty() + && seconds.bytes().all(|byte| byte.is_ascii_digit()) + && micros.len() <= 6 + && micros.bytes().all(|byte| byte.is_ascii_digit()) + ) +} + /// Parse a Slack permalink URL to extract channel and timestamp /// /// Formats: @@ -762,7 +959,7 @@ fn parse_slack_permalink(url: &str) -> Result<(String, String)> { .unwrap_or_default(); // Expected format: /archives/{channel_id}/p{timestamp} - if path_segments.len() < 3 || path_segments[0] != "archives" { + if path_segments.len() != 3 || path_segments[0] != "archives" || path_segments[1].is_empty() { return Err(SlackError::Usage( "Invalid Slack permalink format. Expected: https://workspace.slack.com/archives/CHANNEL/pTIMESTAMP".to_string(), )); @@ -770,34 +967,21 @@ fn parse_slack_permalink(url: &str) -> Result<(String, String)> { let channel_id = path_segments[1].to_string(); let p_timestamp = path_segments[2]; - - // The timestamp in URLs is formatted as p{seconds}{microseconds} without the dot - // We need to convert p1234567890123456 to 1234567890.123456 - if !p_timestamp.starts_with('p') || p_timestamp.len() < 11 { + let ts_digits = p_timestamp + .strip_prefix('p') + .ok_or_else(|| SlackError::Usage("Invalid timestamp in permalink".to_string()))?; + if !(10..=16).contains(&ts_digits.len()) || !ts_digits.bytes().all(|byte| byte.is_ascii_digit()) + { return Err(SlackError::Usage( "Invalid timestamp in permalink".to_string(), )); } - let ts_digits = &p_timestamp[1..]; // Remove 'p' prefix - - // Split into seconds (10 digits) and microseconds (remaining) - if ts_digits.len() >= 10 { - let seconds = &ts_digits[..10]; - let micros = if ts_digits.len() > 10 { - &ts_digits[10..] - } else { - "000000" - }; - // Pad micros to 6 digits - let micros_padded = format!("{:0<6}", micros); - let ts = format!("{}.{}", seconds, micros_padded); - return Ok((channel_id, ts)); - } - - Err(SlackError::Usage( - "Invalid timestamp format in permalink".to_string(), - )) + // ASCII validation above makes these byte offsets safe. + let seconds = &ts_digits[..10]; + let micros = &ts_digits[10..]; + let micros_padded = format!("{:0<6}", micros); + Ok((channel_id, format!("{}.{}", seconds, micros_padded))) } /// Check if a message is an activity message (join/leave/topic change, etc.) @@ -1460,6 +1644,100 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_parse_messages_write_commands_and_send_flags() { + let cli = Cli::try_parse_from([ + "slack", + "messages", + "send", + "C123456789", + "fallback", + "--thread-ts", + "1234567890.123456", + "--broadcast", + "--blocks", + "blocks.json", + "--schedule", + "in 1h", + "--format", + "plain", + ]) + .unwrap(); + match cli.command { + crate::cli::Commands::Messages(MessagesCmd { + command: + MessagesCommands::Send { + broadcast, + blocks, + schedule, + format, + .. + }, + }) => { + assert!(broadcast); + assert_eq!(blocks.as_deref(), Some("blocks.json")); + assert_eq!(schedule.as_deref(), Some("in 1h")); + assert!(matches!(format, MessageFormat::Plain)); + } + _ => panic!("Expected Send command"), + } + + for args in [ + vec![ + "slack", + "messages", + "edit", + "C123456789:1234567890.123456", + "new", + ], + vec![ + "slack", + "messages", + "delete", + "C123456789:1234567890.123456", + ], + vec![ + "slack", + "messages", + "permalink", + "C123456789:1234567890.123456", + ], + vec![ + "slack", + "messages", + "mark", + "C123456789", + "1234567890.123456", + ], + vec!["slack", "messages", "scheduled", "list"], + vec![ + "slack", + "messages", + "scheduled", + "delete", + "C123456789", + "Q123", + ], + ] { + assert!(Cli::try_parse_from(args).is_ok()); + } + } + + #[test] + fn test_parse_message_identifier_rejects_malformed_unicode_timestamp() { + for identifier in [ + "C123456789:123.456", + "C123456789:1234567890.1234567", + "C123456789:1234", + "https://workspace.slack.com/archives/C123456789/p1234567890💥", + ] { + assert!( + parse_message_identifier(identifier).is_err(), + "{identifier}" + ); + } + } + #[test] fn test_is_activity_message() { let mut msg = Message { diff --git a/src/cli/messages/write_ops.rs b/src/cli/messages/write_ops.rs new file mode 100644 index 0000000..3f38337 --- /dev/null +++ b/src/cli/messages/write_ops.rs @@ -0,0 +1,256 @@ +//! Handlers for message mutations and scheduled messages. + +use std::collections::HashSet; + +use crate::api::chat_ops::{ChatScheduleMessageParams, ScheduledMessage}; +use crate::api::{ConversationsMarkParams, SlackClient}; +use crate::error::{Result, SlackError}; +use crate::output::{write_json, OutputMode}; + +use super::{parse_message_identifier, MessageFormat}; + +pub(super) async fn edit_message( + client: &SlackClient, + identifier: &str, + text: &str, + format: MessageFormat, + output_mode: OutputMode, +) -> Result<()> { + if text.trim().is_empty() { + return Err(SlackError::Usage( + "Message text cannot be empty".to_string(), + )); + } + let (channel, ts) = parse_message_identifier(identifier)?; + let channel = client.resolve_channel(&channel).await?; + let (text, mrkdwn) = match format { + MessageFormat::Markdown => (crate::utils::markdown_to_mrkdwn(text), true), + MessageFormat::Plain => (text.to_string(), false), + }; + client.chat_update(&channel, &ts, &text, mrkdwn).await?; + + if output_mode == OutputMode::Plain { + println!("{}", ts); + } else { + write_json(&serde_json::json!({ + "ok": true, + "channel": channel, + "ts": ts, + "text": text, + }))?; + } + Ok(()) +} + +pub(super) async fn delete_message( + client: &SlackClient, + identifier: &str, + output_mode: OutputMode, +) -> Result<()> { + let (channel, ts) = parse_message_identifier(identifier)?; + let channel = client.resolve_channel(&channel).await?; + client.chat_delete(&channel, &ts).await?; + + if output_mode == OutputMode::Plain { + println!("{}", ts); + } else { + write_json(&serde_json::json!({"ok": true, "channel": channel, "ts": ts}))?; + } + Ok(()) +} + +pub(super) async fn permalink_message( + client: &SlackClient, + identifier: &str, + output_mode: OutputMode, +) -> Result<()> { + let (channel, ts) = parse_message_identifier(identifier)?; + let channel = client.resolve_channel(&channel).await?; + let response = client.chat_get_permalink(&channel, &ts).await?; + + if output_mode == OutputMode::Plain { + println!("{}", response.permalink); + } else { + write_json(&serde_json::json!({ + "ok": true, + "channel": channel, + "ts": ts, + "permalink": response.permalink, + }))?; + } + Ok(()) +} + +pub(super) async fn mark_message( + client: &SlackClient, + channel: &str, + ts: &str, + output_mode: OutputMode, +) -> Result<()> { + let channel = client.resolve_channel(channel).await?; + client + .conversations_mark(ConversationsMarkParams::new(&channel, ts)) + .await?; + + if output_mode == OutputMode::Plain { + println!("{}", ts); + } else { + write_json(&serde_json::json!({"ok": true, "channel": channel, "ts": ts}))?; + } + Ok(()) +} + +pub(super) struct ScheduleOptions<'a> { + pub text: Option<&'a str>, + pub thread_ts: Option<&'a str>, + pub format: MessageFormat, + pub blocks: Option<serde_json::Value>, +} + +pub(super) async fn schedule_message( + client: &SlackClient, + channel: &str, + post_at: i64, + options: ScheduleOptions<'_>, + output_mode: OutputMode, +) -> Result<()> { + let now = chrono::Utc::now().timestamp(); + let latest = now + 120 * 24 * 60 * 60; + if post_at <= now { + return Err(SlackError::Usage( + "Scheduled time must be in the future".to_string(), + )); + } + if post_at > latest { + return Err(SlackError::Usage( + "Scheduled time must be no more than 120 days ahead".to_string(), + )); + } + + let channel = client.resolve_channel(channel).await?; + let mut params = ChatScheduleMessageParams::new(&channel, post_at); + if let Some(text) = options.text { + params = params.with_text(text); + } + if let Some(thread_ts) = options.thread_ts { + params = params.in_thread(thread_ts); + } + if let Some(blocks) = options.blocks { + params = params.with_blocks(blocks); + } + if matches!(options.format, MessageFormat::Plain) { + params.mrkdwn = Some(false); + } + + let response = client.chat_schedule_message(params).await?; + if output_mode == OutputMode::Plain { + println!("{}", response.scheduled_message_id); + } else { + write_json(&serde_json::json!({ + "ok": true, + "channel": response.channel, + "scheduled_message_id": response.scheduled_message_id, + "post_at": response.post_at, + "text": options.text, + "permalink": null, + }))?; + } + Ok(()) +} + +pub(super) async fn list_scheduled_messages( + client: &SlackClient, + output_mode: OutputMode, +) -> Result<()> { + let mut messages = Vec::new(); + let mut cursor: Option<String> = None; + let mut seen = HashSet::new(); + + loop { + let response = client + .chat_scheduled_messages_list(100, cursor.as_deref()) + .await?; + messages.extend(response.scheduled_messages); + let next = response + .response_metadata + .and_then(|metadata| metadata.next_cursor) + .filter(|value| !value.is_empty()); + match next { + Some(next) if !seen.insert(next.clone()) => { + return Err(SlackError::Api { + error: "repeated_cursor".to_string(), + detail: Some( + "chat.scheduledMessages.list returned a repeated cursor".to_string(), + ), + }); + } + Some(next) => cursor = Some(next), + None => break, + } + } + + if output_mode == OutputMode::Plain { + for message in &messages { + print_scheduled_message(message); + } + } else { + write_json(&serde_json::json!({"scheduled_messages": messages}))?; + } + Ok(()) +} + +fn print_scheduled_message(message: &ScheduledMessage) { + let text = message + .text + .replace('\t', "\\t") + .replace('\n', "\\n") + .replace('\r', "\\r"); + println!( + "{}\t{}\t{}\t{}", + message.id, message.channel_id, message.post_at, text + ); +} + +pub(super) async fn delete_scheduled_message( + client: &SlackClient, + channel: &str, + scheduled_message_id: &str, + output_mode: OutputMode, +) -> Result<()> { + let channel = client.resolve_channel(channel).await?; + client + .chat_delete_scheduled_message(&channel, scheduled_message_id) + .await?; + if output_mode == OutputMode::Plain { + println!("{}", scheduled_message_id); + } else { + write_json(&serde_json::json!({ + "ok": true, + "channel": channel, + "scheduled_message_id": scheduled_message_id, + }))?; + } + Ok(()) +} + +pub(super) async fn best_effort_permalink( + client: &SlackClient, + channel: &str, + ts: &str, + existing: Option<&str>, +) -> Option<String> { + if let Some(permalink) = existing.filter(|value| !value.is_empty()) { + return Some(permalink.to_string()); + } + match client.chat_get_permalink(channel, ts).await { + Ok(response) if !response.permalink.is_empty() => Some(response.permalink), + Ok(_) => { + eprintln!("warning: could not enrich message permalink"); + None + } + Err(error) => { + eprintln!("warning: could not enrich message permalink: {}", error); + None + } + } +} diff --git a/src/cli/reminders.rs b/src/cli/reminders.rs index f976d1d..f0b95ad 100644 --- a/src/cli/reminders.rs +++ b/src/cli/reminders.rs @@ -126,10 +126,10 @@ fn get_token( } /// Parse natural time expression into Unix timestamp -fn parse_when(when: &str) -> crate::error::Result<i64> { +pub(crate) fn parse_when(when: &str) -> crate::error::Result<i64> { use chrono::{Duration, Local, NaiveTime}; - let when_lower = when.to_lowercase(); + let when_lower = when.trim().to_lowercase(); let now = Local::now(); // Handle "in X" format @@ -137,55 +137,44 @@ fn parse_when(when: &str) -> crate::error::Result<i64> { return parse_relative_duration(duration_str); } - // Handle "tomorrow" variants - if when_lower.starts_with("tomorrow") { + // Handle "tomorrow", "tomorrow 9am", and "tomorrow at 9am". + if let Some(suffix) = when_lower + .strip_prefix("tomorrow") + .filter(|suffix| suffix.is_empty() || suffix.starts_with(char::is_whitespace)) + { let tomorrow = now.date_naive() + Duration::days(1); - - // Check for "tomorrow at HH:MM" - if when_lower.contains(" at ") { - let time_part = when_lower.split(" at ").nth(1).unwrap_or("9:00"); - let time = parse_time(time_part)?; - let datetime = tomorrow.and_time(time); - return datetime - .and_local_timezone(Local) - .single() - .map(|dt| dt.timestamp()) - .ok_or_else(|| crate::error::SlackError::Usage("Invalid timezone".into())); - } - - // Default to 9am tomorrow - let time = NaiveTime::from_hms_opt(9, 0, 0).ok_or_else(|| { - crate::error::SlackError::Other("Failed to create default time 9:00".into()) - })?; - let datetime = tomorrow.and_time(time); - return datetime + let suffix = suffix.trim(); + let time = if suffix.is_empty() { + NaiveTime::from_hms_opt(9, 0, 0).ok_or_else(|| { + crate::error::SlackError::Other("Failed to create default time 9:00".into()) + })? + } else { + parse_time(suffix.strip_prefix("at ").unwrap_or(suffix))? + }; + return tomorrow + .and_time(time) .and_local_timezone(Local) .single() .map(|dt| dt.timestamp()) .ok_or_else(|| crate::error::SlackError::Usage("Invalid timezone".into())); } - // Handle "today at HH:MM" - if when_lower.starts_with("today") { + // Handle "today", "today 9am", and "today at 9am". + if let Some(suffix) = when_lower + .strip_prefix("today") + .filter(|suffix| suffix.is_empty() || suffix.starts_with(char::is_whitespace)) + { let today = now.date_naive(); - - if when_lower.contains(" at ") { - let time_part = when_lower.split(" at ").nth(1).unwrap_or("17:00"); - let time = parse_time(time_part)?; - let datetime = today.and_time(time); - return datetime - .and_local_timezone(Local) - .single() - .map(|dt| dt.timestamp()) - .ok_or_else(|| crate::error::SlackError::Usage("Invalid timezone".into())); - } - - // Default to 5pm today - let time = NaiveTime::from_hms_opt(17, 0, 0).ok_or_else(|| { - crate::error::SlackError::Other("Failed to create default time 17:00".into()) - })?; - let datetime = today.and_time(time); - return datetime + let suffix = suffix.trim(); + let time = if suffix.is_empty() { + NaiveTime::from_hms_opt(17, 0, 0).ok_or_else(|| { + crate::error::SlackError::Other("Failed to create default time 17:00".into()) + })? + } else { + parse_time(suffix.strip_prefix("at ").unwrap_or(suffix))? + }; + return today + .and_time(time) .and_local_timezone(Local) .single() .map(|dt| dt.timestamp()) @@ -514,6 +503,26 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_parse_when_day_suffix_forms_are_strict() { + assert!(parse_when("tomorrow 9am").is_ok()); + assert!(parse_when("tomorrow at 9am").is_ok()); + assert!(parse_when("today 14:30").is_ok()); + assert!(parse_when("today at 2pm").is_ok()); + assert!(parse_when("tomorrow sometime later").is_err()); + assert!(parse_when("tomorrow9am").is_err()); + assert!(parse_when("today at 9am trailing").is_err()); + } + + #[test] + fn test_parse_when_timestamp_and_rfc3339_are_deterministic() { + assert_eq!(parse_when("1893456000").unwrap(), 1_893_456_000); + assert_eq!( + parse_when("2030-01-01T00:00:00+00:00").unwrap(), + 1_893_456_000 + ); + } + #[test] fn test_parse_time_am() { let result = parse_time("9am"); diff --git a/tests/cli_messages_write.rs b/tests/cli_messages_write.rs new file mode 100644 index 0000000..a7d4228 --- /dev/null +++ b/tests/cli_messages_write.rs @@ -0,0 +1,571 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use mockito::{Matcher, ServerGuard}; +use serde_json::Value; +use tempfile::TempDir; + +const TOKEN: &str = "xoxp-test-token-123456789"; +const CHANNEL: &str = "C123456789"; +const TS: &str = "1234567890.123456"; + +fn command(server: &ServerGuard, temp: &TempDir) -> assert_cmd::Command { + let mut cmd = cargo_bin_cmd!("slack"); + cmd.env("SLACK_API_BASE_URL", server.url()) + .env("SLACK_TOKEN_STORE_PATH", temp.path().join("tokens.json")) + .env("SLACK_TOKEN", TOKEN) + .env_remove("SLACK_WORKSPACE") + .env_remove("SLACK_PLAIN"); + cmd +} + +fn post_response(permalink: Option<&str>) -> String { + serde_json::json!({ + "ok": true, + "channel": CHANNEL, + "ts": TS, + "message": { + "type": "message", + "ts": TS, + "text": "sent", + "permalink": permalink, + } + }) + .to_string() +} + +#[tokio::test] +async fn edit_markdown_permalink_identifier_and_plain_edit() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let edit = server + .mock("POST", "/chat.update") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), CHANNEL.into()), + Matcher::UrlEncoded("ts".into(), TS.into()), + Matcher::UrlEncoded("text".into(), "*updated*".into()), + Matcher::UrlEncoded("mrkdwn".into(), "true".into()), + ])) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + let output = command(&server, &temp) + .args([ + "messages", + "edit", + "https://workspace.slack.com/archives/C123456789/p1234567890123456", + "**updated**", + ]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["channel"], CHANNEL); + assert_eq!(json["ts"], TS); + assert_eq!(json["text"], "*updated*"); + edit.assert_async().await; + + let plain_edit = server + .mock("POST", "/chat.update") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), CHANNEL.into()), + Matcher::UrlEncoded("ts".into(), TS.into()), + Matcher::UrlEncoded("text".into(), "literal *text*".into()), + Matcher::UrlEncoded("mrkdwn".into(), "false".into()), + ])) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + command(&server, &temp) + .args([ + "--plain", + "messages", + "edit", + &format!("{CHANNEL}:{TS}"), + "literal *text*", + "--format", + "plain", + ]) + .assert() + .success() + .stdout(format!("{TS}\n")); + plain_edit.assert_async().await; +} + +#[tokio::test] +async fn delete_permalink_and_mark_use_expected_forms() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let delete = server + .mock("POST", "/chat.delete") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), CHANNEL.into()), + Matcher::UrlEncoded("ts".into(), TS.into()), + ])) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + let output = command(&server, &temp) + .args(["messages", "delete", &format!("{CHANNEL}:{TS}")]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::<Value>(&output.stdout).unwrap()["ts"], + TS + ); + delete.assert_async().await; + + let permalink = server + .mock("POST", "/chat.getPermalink") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), CHANNEL.into()), + Matcher::UrlEncoded("message_ts".into(), TS.into()), + ])) + .with_body( + r#"{"ok":true,"channel":"C123456789","permalink":"https://workspace.slack.com/archives/C123456789/p1234567890123456"}"#, + ) + .create_async() + .await; + let output = command(&server, &temp) + .args(["messages", "permalink", &format!("{CHANNEL}:{TS}")]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::<Value>(&output.stdout).unwrap()["permalink"], + "https://workspace.slack.com/archives/C123456789/p1234567890123456" + ); + permalink.assert_async().await; + + let permalink_plain = server + .mock("POST", "/chat.getPermalink") + .with_body(r#"{"ok":true,"channel":"C123456789","permalink":"https://example.test/plain"}"#) + .create_async() + .await; + command(&server, &temp) + .args([ + "--plain", + "messages", + "permalink", + &format!("{CHANNEL}:{TS}"), + ]) + .assert() + .success() + .stdout("https://example.test/plain\n"); + permalink_plain.assert_async().await; + + let mark = server + .mock("POST", "/conversations.mark") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), CHANNEL.into()), + Matcher::UrlEncoded("ts".into(), TS.into()), + ])) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + let output = command(&server, &temp) + .args(["messages", "mark", CHANNEL, TS]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::<Value>(&output.stdout).unwrap()["ok"], + true + ); + mark.assert_async().await; +} + +#[tokio::test] +async fn explicit_permalink_propagates_api_error() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let mock = server + .mock("POST", "/chat.getPermalink") + .with_body(r#"{"ok":false,"error":"message_not_found"}"#) + .create_async() + .await; + command(&server, &temp) + .args(["messages", "permalink", &format!("{CHANNEL}:{TS}")]) + .assert() + .failure(); + mock.assert_async().await; +} + +#[tokio::test] +async fn send_broadcast_blocks_and_plain_fields() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let blocks_path = temp.path().join("blocks.json"); + std::fs::write( + &blocks_path, + r#"[{"type":"section","text":{"type":"mrkdwn","text":"**unchanged**"}}]"#, + ) + .unwrap(); + let blocks = r#"[{"text":{"text":"**unchanged**","type":"mrkdwn"},"type":"section"}]"#; + let no_permalink = server + .mock("POST", "/chat.getPermalink") + .expect(0) + .create_async() + .await; + let post = server + .mock("POST", "/chat.postMessage") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), CHANNEL.into()), + Matcher::UrlEncoded("text".into(), "fallback *bold*".into()), + Matcher::UrlEncoded("thread_ts".into(), TS.into()), + Matcher::UrlEncoded("reply_broadcast".into(), "true".into()), + Matcher::UrlEncoded("blocks".into(), blocks.into()), + ])) + .with_body(post_response(Some("https://example.test/message"))) + .create_async() + .await; + let output = command(&server, &temp) + .args([ + "messages", + "send", + CHANNEL, + "fallback **bold**", + "--thread-ts", + TS, + "--broadcast", + "--blocks", + blocks_path.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let json: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["permalink"], "https://example.test/message"); + post.assert_async().await; + no_permalink.assert_async().await; +} + +#[tokio::test] +async fn blocks_from_stdin_allow_block_only_send_and_plain_output_is_stable() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let post = server + .mock("POST", "/chat.postMessage") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), CHANNEL.into()), + Matcher::UrlEncoded("blocks".into(), r#"[{"type":"divider"}]"#.into()), + Matcher::UrlEncoded("mrkdwn".into(), "false".into()), + ])) + .with_body(post_response(None)) + .create_async() + .await; + command(&server, &temp) + .args([ + "--plain", "messages", "send", CHANNEL, "--blocks", "-", "--format", "plain", + ]) + .write_stdin(r#"[{"type":"divider"}]"#) + .assert() + .success() + .stdout(format!("{TS}\n")); + post.assert_async().await; +} + +#[tokio::test] +async fn invalid_blocks_broadcast_and_stdin_conflict_do_no_requests() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let no_post = server + .mock("POST", "/chat.postMessage") + .expect(0) + .create_async() + .await; + for (name, body) in [ + ("malformed.json", "{"), + ("object.json", "{}"), + ("empty.json", "[]"), + ] { + let bad = temp.path().join(name); + std::fs::write(&bad, body).unwrap(); + command(&server, &temp) + .args([ + "messages", + "send", + CHANNEL, + "--blocks", + bad.to_str().unwrap(), + ]) + .assert() + .code(2); + } + command(&server, &temp) + .args(["messages", "send", CHANNEL, "hello", "--broadcast"]) + .assert() + .code(2); + command(&server, &temp) + .args(["messages", "send", CHANNEL, "--stdin", "--blocks", "-"]) + .assert() + .code(2); + no_post.assert_async().await; +} + +#[tokio::test] +async fn immediate_send_enrichment_failure_is_best_effort() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let post = server + .mock("POST", "/chat.postMessage") + .with_body(post_response(None)) + .create_async() + .await; + let permalink = server + .mock("POST", "/chat.getPermalink") + .with_body(r#"{"ok":false,"error":"missing_scope"}"#) + .create_async() + .await; + let output = command(&server, &temp) + .args(["messages", "send", CHANNEL, "hello"]) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("warning")); + assert!(serde_json::from_slice::<Value>(&output.stdout).unwrap()["permalink"].is_null()); + post.assert_async().await; + permalink.assert_async().await; +} + +#[tokio::test] +async fn get_enriches_json_but_plain_does_not_request_permalink() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let history = server + .mock("POST", "/conversations.history") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), CHANNEL.into()), + Matcher::UrlEncoded("oldest".into(), TS.into()), + Matcher::UrlEncoded("latest".into(), TS.into()), + Matcher::UrlEncoded("inclusive".into(), "true".into()), + ])) + .with_body(format!( + r#"{{"ok":true,"messages":[{{"ts":"{TS}","text":"hello"}}]}}"# + )) + .create_async() + .await; + let permalink = server + .mock("POST", "/chat.getPermalink") + .with_body(format!( + r#"{{"ok":true,"channel":"{CHANNEL}","permalink":"https://example.test/get"}}"# + )) + .create_async() + .await; + let output = command(&server, &temp) + .args(["messages", "get", &format!("{CHANNEL}:{TS}")]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::<Value>(&output.stdout).unwrap()["permalink"], + "https://example.test/get" + ); + history.assert_async().await; + permalink.assert_async().await; + + let plain_history = server + .mock("POST", "/conversations.history") + .with_body(format!( + r#"{{"ok":true,"messages":[{{"ts":"{TS}","text":"hello"}}]}}"# + )) + .create_async() + .await; + let no_plain_permalink = server + .mock("POST", "/chat.getPermalink") + .expect(0) + .create_async() + .await; + command(&server, &temp) + .args(["--plain", "messages", "get", &format!("{CHANNEL}:{TS}")]) + .assert() + .success() + .stdout(format!("ts\t{TS}\ntext\thello\n")); + plain_history.assert_async().await; + no_plain_permalink.assert_async().await; +} + +#[tokio::test] +async fn schedule_routes_only_to_schedule_endpoint() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let post_at = chrono::Utc::now().timestamp() + 3600; + let post_at_string = post_at.to_string(); + let schedule = server + .mock("POST", "/chat.scheduleMessage") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), CHANNEL.into()), + Matcher::UrlEncoded("post_at".into(), post_at_string.clone()), + Matcher::UrlEncoded("text".into(), "scheduled *text*".into()), + Matcher::UrlEncoded("thread_ts".into(), TS.into()), + ])) + .with_body(format!(r#"{{"ok":true,"channel":"{CHANNEL}","scheduled_message_id":"Q123","post_at":{post_at}}}"#)) + .create_async() + .await; + let output = command(&server, &temp) + .args([ + "messages", + "send", + CHANNEL, + "scheduled **text**", + "--thread-ts", + TS, + "--schedule", + &post_at_string, + ]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["scheduled_message_id"], "Q123"); + assert!(json["permalink"].is_null()); + schedule.assert_async().await; +} + +#[tokio::test] +async fn scheduling_rejects_incompatible_and_invalid_times_without_requests() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let none = server + .mock("POST", "/chat.scheduleMessage") + .expect(0) + .create_async() + .await; + for args in [ + vec!["messages", "send", CHANNEL, "x", "--schedule", "1"], + vec!["messages", "send", CHANNEL, "x", "--schedule", "in 121d"], + vec![ + "messages", + "send", + CHANNEL, + "x", + "--schedule", + "in 1h", + "--mark-read", + ], + vec![ + "messages", + "send", + CHANNEL, + "x", + "--schedule", + "in 1h", + "--broadcast", + "--thread-ts", + TS, + ], + ] { + command(&server, &temp).args(args).assert().code(2); + } + none.assert_async().await; +} + +#[tokio::test] +async fn scheduled_list_paginates_and_escapes_plain_text() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let first = server + .mock("POST", "/chat.scheduledMessages.list") + .match_body(Matcher::Exact("limit=100".to_string())) + .with_body(format!(r#"{{"ok":true,"scheduled_messages":[{{"id":"Q1","channel_id":"{CHANNEL}","post_at":1893456000,"text":"one"}}],"response_metadata":{{"next_cursor":"next"}}}}"#)) + .create_async() + .await; + let second = server + .mock("POST", "/chat.scheduledMessages.list") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("limit".into(), "100".into()), + Matcher::UrlEncoded("cursor".into(), "next".into()), + ])) + .with_body(format!(r#"{{"ok":true,"scheduled_messages":[{{"id":"Q2","channel_id":"{CHANNEL}","post_at":1893457000,"text":"two\tlines\n"}}],"response_metadata":{{"next_cursor":""}}}}"#)) + .create_async() + .await; + command(&server, &temp) + .args(["--plain", "messages", "scheduled", "list"]) + .assert() + .success() + .stdout(format!( + "Q1\t{CHANNEL}\t1893456000\tone\nQ2\t{CHANNEL}\t1893457000\ttwo\\tlines\\n\n" + )); + first.assert_async().await; + second.assert_async().await; +} + +#[tokio::test] +async fn scheduled_list_outputs_json_envelope() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/chat.scheduledMessages.list") + .match_body(Matcher::Exact("limit=100".to_string())) + .with_body(format!( + r#"{{"ok":true,"scheduled_messages":[{{"id":"Q1","channel_id":"{CHANNEL}","post_at":1893456000,"text":"one"}}]}}"# + )) + .create_async() + .await; + let output = command(&server, &temp) + .args(["messages", "scheduled", "list"]) + .output() + .unwrap(); + assert!(output.status.success()); + let json: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["scheduled_messages"][0]["id"], "Q1"); + list.assert_async().await; +} + +#[tokio::test] +async fn scheduled_list_detects_repeated_cursor() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let repeated = server + .mock("POST", "/chat.scheduledMessages.list") + .with_body( + r#"{"ok":true,"scheduled_messages":[],"response_metadata":{"next_cursor":"same"}}"#, + ) + .expect(2) + .create_async() + .await; + command(&server, &temp) + .args(["messages", "scheduled", "list"]) + .assert() + .failure(); + repeated.assert_async().await; +} + +#[tokio::test] +async fn scheduled_delete_resolves_channel_and_outputs_json() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let delete = server + .mock("POST", "/chat.deleteScheduledMessage") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("channel".into(), CHANNEL.into()), + Matcher::UrlEncoded("scheduled_message_id".into(), "Q123".into()), + ])) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + let output = command(&server, &temp) + .args(["messages", "scheduled", "delete", CHANNEL, "Q123"]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::<Value>(&output.stdout).unwrap()["scheduled_message_id"], + "Q123" + ); + delete.assert_async().await; + + let denied = server + .mock("POST", "/chat.deleteScheduledMessage") + .with_body(r#"{"ok":false,"error":"not_authed"}"#) + .create_async() + .await; + command(&server, &temp) + .args(["messages", "scheduled", "delete", CHANNEL, "Q999"]) + .assert() + .failure(); + denied.assert_async().await; +} From 0ede9be7d493a21aa34f8042be151d071219b28d Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Wed, 9 Sep 2026 23:57:22 +1000 Subject: [PATCH 07/22] feat(oauth-docs): Verify and document the reachable OAuth flow --- CHANGELOG.md | 6 +++++ README.md | 51 +++++++++++++++++++++++++++++++++++++++++++ skills/slack/AUTH.md | 38 ++++++++++++++++++++++++++++---- skills/slack/SKILL.md | 20 ++++++++++++++++- 4 files changed, 110 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a1bbd1..5c39213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Channel bookmarks**: list, add, and remove channel link bookmarks with `slack bookmarks`, including optional emoji and script-friendly TSV output. +### Changed + +- **OAuth documentation**: clarify that `auth add` reaches the configured Slack + app OAuth flow by default, document browser/manual routes, callback setup, + credential errors, token storage, and explicit scope replacement. + ## [0.2.1] - 2026-09-08 ### Fixed diff --git a/README.md b/README.md index 6bc2eb1..2730709 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,12 @@ slack completions powershell >> $PROFILE # Authenticate with a token slack auth add --token xoxp-your-token-here +# Or, optionally, authorize through your own Slack app after configuring its +# redirect URL as described under Authentication +export SLACK_CLIENT_ID="your-slack-app-client-id" +export SLACK_CLIENT_SECRET="your-slack-app-client-secret" +slack auth add + # Check auth status slack auth status @@ -123,6 +129,51 @@ slack auth remove T1234567890 slack auth browser-help ``` +#### OAuth with a Slack app + +OAuth is an optional alternative to direct tokens and local session import. In +your Slack app's **OAuth & Permissions** settings, add this exact redirect URL: + +```text +http://localhost:8765/callback +``` + +Then export the Slack **app credentials** shown in **Basic Information** (these +identify the app; they are not a Slack access token): + +```bash +export SLACK_CLIENT_ID="your-slack-app-client-id" +export SLACK_CLIENT_SECRET="your-slack-app-client-secret" +``` + +Run OAuth without a positional workspace or `--url`: + +```bash +slack auth add # default OAuth route; opens a browser +slack auth add --oauth # explicitly selects the same browser flow +slack auth add --manual # prints the URL and asks for the full redirect URL + +# Replace the defaults with an explicit comma-separated scope list +slack auth add --oauth --scopes channels:read,channels:history,users:read,search:read,chat:write +``` + +The browser flow listens on `localhost:8765` for the callback. The manual flow +uses the same configured callback URL but does not need to receive it: after +Slack redirects (the page may fail to load), paste the full URL from the +browser's address bar into the CLI. If either credential variable is missing, +`auth add` returns a configuration error instead of starting OAuth. + +The current CLI scope defaults are `channels:read`, `channels:history`, +`users:read`, and `search:read`. `--scopes` replaces that list; scopes needed +by other commands are not added automatically, so request the complete set +your Slack app and intended commands require. Successful OAuth tokens use the +same system keyring as other auth methods, or the existing file store when +`SLACK_TOKEN_STORE_PATH` is set. + +A positional workspace, `--url`, or `--from-browser` selects local token +extraction instead of OAuth. Direct `--token` and `--xoxc`/`--xoxd` flows are +unchanged. + #### Selecting a workspace With multiple workspaces configured, target one per command with `-w` / diff --git a/skills/slack/AUTH.md b/skills/slack/AUTH.md index 9499773..df56629 100644 --- a/skills/slack/AUTH.md +++ b/skills/slack/AUTH.md @@ -31,13 +31,41 @@ slack auth add --token xoxb-your-bot-token # Browser tokens (full workspace access without creating a Slack app) slack auth add --xoxc xoxc-... --xoxd xoxd-... -# OAuth flow (opens browser) -slack auth add --oauth +# OAuth through a configured Slack app +slack auth add # default flow; opens a browser +slack auth add --oauth # explicitly selects the same browser flow +slack auth add --manual # prints a URL; paste the full redirect URL +``` + +For OAuth, add `http://localhost:8765/callback` as an exact redirect URL in the +Slack app's **OAuth & Permissions** settings, then export its **Basic +Information** credentials: -# Manual OAuth (no browser — prints URL for you to visit) -slack auth add --oauth --manual +```bash +export SLACK_CLIENT_ID="your-slack-app-client-id" +export SLACK_CLIENT_SECRET="your-slack-app-client-secret" ``` +These values identify the Slack app; neither is a Slack access token. The +browser mode listens for the localhost callback. Manual mode uses the same +redirect URL but asks you to paste the full URL after Slack redirects, so it is +fine if the localhost page does not load. If either variable is missing, OAuth +returns a configuration error. + +The CLI currently requests `channels:read,channels:history,users:read,search:read` +by default. Replace this list with the complete scopes your intended commands +need by using the existing comma-separated syntax, for example: + +```bash +slack auth add --oauth --scopes channels:read,channels:history,users:read,search:read,chat:write +``` + +`--scopes` replaces rather than extends the defaults; new command scopes are +not added automatically. OAuth tokens are stored through the same system +keyring or `SLACK_TOKEN_STORE_PATH` file store as direct and browser tokens. +A positional workspace, `--url`, or `--from-browser` selects local extraction +instead of OAuth. + Run `slack auth browser-help` for step-by-step instructions on extracting browser tokens. `slack auth add <subdomain>` currently supports macOS (Chromium-family browsers @@ -109,6 +137,8 @@ slack auth remove T1234567890 # remove a workspace (team ID or domain) | `SLACK_TOKEN` | Override token for all commands | | `SLACK_WORKSPACE` | Default workspace (team ID or domain, same as `-w`) | | `SLACK_TOKEN_STORE_PATH` | Use a JSON file instead of system keyring (set this first if the keyring is unavailable) | +| `SLACK_CLIENT_ID` | Slack app client ID required to start OAuth | +| `SLACK_CLIENT_SECRET` | Slack app client secret required to exchange an OAuth code | ## Diagnosing auth issues diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md index d69301c..a712b58 100644 --- a/skills/slack/SKILL.md +++ b/skills/slack/SKILL.md @@ -77,12 +77,30 @@ slack auth list --check slack auth add --token xoxp-your-token slack auth add --xoxc xoxc-... --xoxd xoxd-... +# Optional OAuth through a configured Slack app +export SLACK_CLIENT_ID="your-slack-app-client-id" +export SLACK_CLIENT_SECRET="your-slack-app-client-secret" +slack auth add # default browser OAuth route +slack auth add --oauth # the same route, selected explicitly +slack auth add --manual # print URL; paste the full redirect URL + # Check current auth / switch default workspace slack auth status slack auth switch T1234567890 ``` -See [AUTH.md](AUTH.md) for full authentication reference. +For OAuth, configure the Slack app redirect URL exactly as +`http://localhost:8765/callback`. Both browser and manual modes use it; manual +mode may show an unreachable localhost page before its URL is pasted into the +CLI. Missing app credentials produce a configuration error. The credentials +above identify the Slack app and are not an access token; resulting tokens are +saved in the same keyring/file store as other methods. + +OAuth currently defaults to `channels:read,channels:history,users:read,search:read`. +Use `--scopes scope1,scope2` to replace (not extend) that list; scopes for other +commands are not added automatically. A positional workspace or `--url` uses +local token extraction rather than OAuth. See [AUTH.md](AUTH.md) for the full +authentication reference. ## Resolving a workspace by name (IMPORTANT for agents) From a5b5a3076ac5c4c579b5908ec7fc9bdb0722c070 Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 00:08:46 +1000 Subject: [PATCH 08/22] docs: consolidate documentation for feature batch --- CHANGELOG.md | 61 ++-- README.md | 48 ++- skills/slack/FILES.md | 81 +++++ skills/slack/REACTIONS.md | 15 + skills/slack/REMINDERS.md | 16 + skills/slack/SKILL.md | 30 +- skills/slack/STATUS.md | 18 + src/api/file_ops.rs | 313 ++++++++++++++++ src/api/mod.rs | 1 + src/cli/files.rs | 427 +++++++++++++++++++--- tests/cli_file_ops.rs | 731 ++++++++++++++++++++++++++++++++++++++ 11 files changed, 1654 insertions(+), 87 deletions(-) create mode 100644 skills/slack/FILES.md create mode 100644 skills/slack/REACTIONS.md create mode 100644 skills/slack/REMINDERS.md create mode 100644 skills/slack/STATUS.md create mode 100644 src/api/file_ops.rs create mode 100644 tests/cli_file_ops.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c39213..62c9383 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,27 +9,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Message mutations and permalinks**: edit and delete messages, mark channels - read, retrieve strict permalinks, and best-effort enrich successful send/get - JSON while preserving plain output. -- **Advanced and scheduled sending**: send Block Kit payloads and broadcast - thread replies, schedule messages up to 120 days ahead, and list or delete - queued messages. -- **Message reading**: read exclusive date/timestamp-bounded channel history, - collect all history pages, resolve authors and mentions from one paginated - user-directory traversal, and select search result sort order. -- **Channels**: list and resolve channel members, create and manage channel - lifecycle and membership, and show a capability-dependent Web API unread - overview with unavailable-count reporting. -- **Identity and user groups**: send direct messages with `messages send @user`, - look up users by email, and list user groups or their members with optional - bulk user-name resolution. -- **Pins**: add, remove, and list channel pins with `slack pins`, preserving - message, file, and other pin item payloads in JSON output. +- **Direct messages by user**: send directly to `@username` or a user ID by + opening or reusing the user's IM conversation. +- **User lookup by email**: resolve email addresses through + `users.lookupByEmail` with `slack users info`. +- **User groups**: list enabled groups and their members, with optional bulk + user-name resolution. +- **Message editing**: replace message text by `channel:timestamp` or Slack + permalink, with Markdown-to-mrkdwn conversion or verbatim text. +- **Message deletion**: delete a message by `channel:timestamp` or Slack + permalink without an interactive prompt. +- **Message permalinks**: retrieve strict permalinks and best-effort enrich + successful send/get JSON while preserving plain output. +- **Broadcast thread replies**: make a thread reply visible in its channel with + `messages send --broadcast`. +- **Message read markers**: mark a channel read through a timestamp, directly + or after an immediate send. +- **Scheduled messages**: schedule messages up to 120 days ahead, then list or + delete queued messages. +- **Block Kit messages**: send a nonempty Block Kit JSON array from a file or + stdin, with optional fallback text. +- **Resolved message output**: resolve authors and mentions from one paginated + workspace user-directory traversal. +- **Bounded message history**: read exclusive date/timestamp bounds and fetch + complete channel history with cursor pagination. +- **Message search sorting**: order search results by score or timestamp in + ascending or descending order. +- **Channel members**: list channel member IDs with optional bulk user-name + resolution. +- **Channel lifecycle**: create, join, leave, archive, restore, invite to, + rename, and update the topic or purpose of channels. +- **Unread overview**: show capability-dependent Web API unread counts and + report channels for which Slack omits count data. +- **File uploads**: upload named files through Slack's supported external-upload + flow, optionally sharing them to a channel or thread. +- **File search**: search workspace files with a user OAuth or browser token, + preserving Slack pagination metadata. +- **Pins**: add, remove, and list channel pins while preserving message, file, + and other pin item payloads in JSON output. - **Custom emoji list**: list workspace custom emoji URLs and aliases with - `slack emoji list`, with sorted TSV output available through `--plain`. -- **Channel bookmarks**: list, add, and remove channel link bookmarks with - `slack bookmarks`, including optional emoji and script-friendly TSV output. + sorted TSV output available through `--plain`. +- **Channel bookmarks**: list, add, and remove channel link bookmarks with an + optional emoji and script-friendly TSV output. ### Changed diff --git a/README.md b/README.md index 2730709..2b4c3b0 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ A comprehensive Rust CLI tool for Slack, designed for AI agents and automation. ## Features - **Multiple authentication methods**: OAuth, browser tokens (xoxc+xoxd), direct tokens (xoxp/xoxb) -- **Full workspace access**: Channels, messages, threads, search, files, reactions, reminders, status +- **Full workspace access**: Manage channels, messages, DMs, users, user groups, files, pins, bookmarks, custom emoji, reactions, reminders, and status +- **Message and file workflows**: Edit, delete, schedule, and search messages; upload and search files - **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 @@ -437,6 +438,7 @@ slack users info alice@example.com # Send a direct message (opens or reuses the IM, then sends normally) slack messages send @username "Hello directly" +slack messages send U123456789 "Hello by user ID" # List user groups and group members slack users groups list @@ -461,16 +463,35 @@ slack files list # List files in a channel slack files list --channel "#general" -# List files by type -slack files list --types images,documents +# Filter and paginate files +slack files list --user U123456789 --limit 50 --cursor NEXT_CURSOR # Get file info slack files info F123456789 -# Download a file -slack files download F123456789 --output ./downloads/ +# Download a file (optionally emit base64) +slack files get F123456789 --output ./downloads/report.pdf +slack files get F123456789 --base64 + +# Upload a file (filename defaults to the path basename) +slack files upload ./report.pdf --channel "#general" --title "Quarterly report" +slack files upload ./data.bin --filename archive.bin + +# Share an upload in a thread with a comment +slack files upload ./notes.txt --channel C123456789 \ + --comment "Meeting notes" --thread-ts 1234567890.123456 + +# Search files (user or browser token only) +slack files search "quarterly report" +slack files search "from:alice has:pdf" --count 50 --page 2 ``` +Uploads use Slack's external-upload flow and require the `files:write` scope. +`--filename` must be supplied when the path has no UTF-8 basename; otherwise +its standalone value overrides the basename. `--comment` and `--thread-ts` +require `--channel`. File search requires `search:read` and a user OAuth or +stored browser token; Slack does not support file search with bot tokens. + ### Reactions (`slack reactions` or `slack r`) ```bash @@ -536,12 +557,12 @@ the bookmark ID. slack status get # Set status with emoji and text -slack status set ":coffee:" "Taking a break" +slack status set "Taking a break" --emoji coffee # Set status with expiration -slack status set ":meeting:" "In a meeting" --expires 1h -slack status set ":calendar:" "Out of office" --expires today -slack status set ":palm_tree:" "On vacation" --expires tomorrow +slack status set "In a meeting" --emoji meeting --expires 1h +slack status set "Out of office" --emoji calendar --expires today +slack status set "On vacation" --emoji palm_tree --expires tomorrow # Clear status slack status clear @@ -558,8 +579,8 @@ slack status presence auto slack reminders list # Create a reminder -slack reminders add "Review PRs" --time "in 2 hours" -slack reminders add "Team meeting" --time "tomorrow at 10am" +slack reminders add "Review PRs" --when "in 2 hours" +slack reminders add "Team meeting" --when "tomorrow at 10am" # Complete a reminder slack reminders complete Rm123456789 @@ -625,8 +646,9 @@ Duplicate parameter names (across `-f`/`-F`) are rejected. 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. +- Not supported by `slack api`: custom headers, raw-body file uploads, + name→ID resolution, jq filtering, or Edge API endpoints. Use + `slack files upload` for Slack's supported external-upload flow. **Output** is the full JSON response from Slack on success. `--plain` is not supported for `slack api`. Slack `ok: false` responses, rate limits, and diff --git a/skills/slack/FILES.md b/skills/slack/FILES.md new file mode 100644 index 0000000..a54fe68 --- /dev/null +++ b/skills/slack/FILES.md @@ -0,0 +1,81 @@ +# slack files + +Work with Slack files. JSON is the default output; pass the global `--plain` +flag before `files` for script-friendly output. + +## Upload a file + +```bash +slack files upload <path> [--channel <channel>] [--title <title>] \ + [--comment <text>] [--thread-ts <ts>] [--filename <filename>] +``` + +Examples: + +```bash +# Upload without sharing it to a channel +slack files upload ./report.pdf + +# Resolve a channel name, share the file, and set its display title +slack files upload ./report.pdf --channel general --title "Quarterly report" + +# Share in a thread with an initial comment +slack files upload ./notes.txt --channel C123456789 \ + --comment "Meeting notes" --thread-ts 1234567890.123456 +``` + +Uploads require Slack's `files:write` scope and use the supported external +upload flow (`files.getUploadURLExternal`, raw byte upload, then +`files.completeUploadExternal`). The path must name a regular, readable file; +stdin and directories are not accepted. Uploads are not subject to the CLI's +5 MiB download limit. + +The uploaded filename defaults to the path's UTF-8 basename. Use `--filename` +to override it or when the path has no usable UTF-8 basename. The override must +be a standalone, non-empty filename without path separators or control +characters. `--comment` and `--thread-ts` are valid only with `--channel`. + +JSON output has the form: + +```json +{"ok": true, "files": [{"id": "F123456789"}]} +``` + +Plain output writes one returned file ID per line. + +## Search files + +```bash +slack files search <query> [--count <n>] [--page <n>] +``` + +`--count` defaults to 20 and accepts 1 through 100. `--page` defaults to 1 and +must be positive. + +```bash +slack files search "quarterly report" +slack files search "from:alice has:pdf" --count 50 --page 2 +``` + +Search uses `search.files`, requires `search:read`, and is available only with +a user OAuth token or a stored browser token. Slack does not support search +with bot tokens. JSON output preserves Slack's `total`, `pagination`, and file +matches. Plain output is tab-separated: + +```text +id<TAB>title-or-name<TAB>permalink +``` + +Missing titles, names, or permalinks are emitted as empty fields. Tabs and line +breaks inside fields are escaped. + +## Existing file commands + +```bash +slack files list [--channel <channel>] [--user <user>] [--limit <n>] [--cursor <page>] +slack files info <file-id> +slack files get <file-id> [--output <path>] [--base64] +``` + +The 5 MiB safety limit applies to downloads performed by `files get`, not to +`files upload`. diff --git a/skills/slack/REACTIONS.md b/skills/slack/REACTIONS.md new file mode 100644 index 0000000..a605a42 --- /dev/null +++ b/skills/slack/REACTIONS.md @@ -0,0 +1,15 @@ +# slack reactions + +Add, remove, and inspect emoji reactions on Slack messages. Channel names and +IDs are accepted; emoji names are passed without surrounding colons. + +## Commands + +```bash +slack reactions add "#general" 1234567890.123456 thumbsup +slack reactions remove "#general" 1234567890.123456 thumbsup +slack reactions list "#general" 1234567890.123456 +``` + +Output is JSON by default. Add the global `--plain` flag for TSV output. Slack +enforces access to the conversation and the token's reaction scopes. diff --git a/skills/slack/REMINDERS.md b/skills/slack/REMINDERS.md new file mode 100644 index 0000000..d2a76ed --- /dev/null +++ b/skills/slack/REMINDERS.md @@ -0,0 +1,16 @@ +# slack reminders + +List and manage reminders for the authenticated Slack user. + +## Commands + +```bash +slack reminders list +slack reminders add "Review pull requests" --when "in 2 hours" +slack reminders add "Team meeting" --when "tomorrow at 10am" +slack reminders complete Rm123456789 +slack reminders delete Rm123456789 +``` + +Reminder times accept supported natural expressions and explicit date/time +forms. Output is JSON by default; add the global `--plain` flag for TSV. diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md index a712b58..78dceb1 100644 --- a/skills/slack/SKILL.md +++ b/skills/slack/SKILL.md @@ -1,6 +1,6 @@ --- name: slack -description: Send and read Slack messages, search conversations, manage channels, users, files, reactions, status, and reminders across multiple workspaces. Use when the user wants to interact with Slack — post a message, check recent messages, search for something, or work with a specific workspace/team by name. Can discover and connect workspaces the user is already signed into locally (desktop app or browser). +description: Send and read Slack messages; DM @users; edit, delete, schedule, and search messages; manage channels, user groups, file uploads, file search, pins, bookmarks, custom emoji, reactions, status, and reminders across workspaces. Use when the user wants to interact with Slack or connect a locally signed-in workspace. license: MIT compatibility: Requires the slack CLI. If not installed, direct the user to https://github.com/TeamCadenceAI/slack-cli allowed-tools: Bash(slack:*) Bash(jq:*) @@ -173,8 +173,11 @@ slack messages send "#general" "Fallback" --blocks blocks.json slack messages send "#general" "Reply" --thread-ts 1234567890.123456 --broadcast slack messages send "#general" "Tomorrow" --schedule "tomorrow at 9am" slack messages edit "#general:1234567890.123456" "Corrected text" +slack messages delete "#general:1234567890.123456" slack messages permalink "#general:1234567890.123456" +slack messages mark "#general" 1234567890.123456 slack messages scheduled list +slack messages scheduled delete "#general" Q123456789 ``` Natural schedule expressions use local time and must be future times within @@ -258,6 +261,7 @@ type). Missing scopes are reported as Slack API errors. See [USERS.md](USERS.md) ### Manage pins ```bash slack pins add "#general" 1234567890.123456 +slack pins remove "#general" 1234567890.123456 slack pins list "#general" ``` @@ -289,15 +293,31 @@ slack status set "In a meeting" --emoji meeting --expires 1h slack status clear ``` +### Upload and search files +```bash +# Upload; the path basename is used unless --filename is supplied +slack files upload ./report.pdf --channel general --title "Quarterly report" + +# --comment and --thread-ts require --channel +slack files upload ./notes.txt --channel C123456789 \ + --comment "Meeting notes" --thread-ts 1234567890.123456 + +# Search requires a user OAuth or stored browser token (not a bot token) +slack files search "quarterly report" --count 20 --page 1 +``` + +Uploads require `files:write`; searches require `search:read`. See +[FILES.md](FILES.md) for validation, output, and authentication details. + ## Command reference files | File | Commands | |------|----------| | [AUTH.md](AUTH.md) | `auth add/discover/list/remove/status/switch/browser-help` | -| [CHANNELS.md](CHANNELS.md) | `channels list/info/dms/export` | -| [MESSAGES.md](MESSAGES.md) | `messages list/send/search/thread/get` | -| [USERS.md](USERS.md) | `users list/info/me/groups/export` | -| [FILES.md](FILES.md) | `files list/info/get` | +| [CHANNELS.md](CHANNELS.md) | `channels list/info/dms/members/create/join/leave/archive/unarchive/invite/set-topic/set-purpose/rename/unread/export` | +| [MESSAGES.md](MESSAGES.md) | `messages list/thread/send/edit/delete/permalink/mark/scheduled/search/get` | +| [USERS.md](USERS.md) | `users list/info/me/groups/export`; direct messages by user | +| [FILES.md](FILES.md) | `files list/info/get/upload/search` | | [REACTIONS.md](REACTIONS.md) | `reactions add/remove/list` | | [PINS.md](PINS.md) | `pins add/remove/list` | | [EMOJI.md](EMOJI.md) | `emoji list` | diff --git a/skills/slack/STATUS.md b/skills/slack/STATUS.md new file mode 100644 index 0000000..16b7351 --- /dev/null +++ b/skills/slack/STATUS.md @@ -0,0 +1,18 @@ +# slack status + +Read or update the authenticated user's Slack status and presence. + +## Commands + +```bash +slack status get +slack status set "In a meeting" --emoji meeting +slack status set "Out of office" --emoji calendar --expires tomorrow +slack status clear +slack status presence away +slack status presence auto +``` + +`--expires` accepts durations such as `30m`, `1h`, and `4h`, plus `today` or +`tomorrow`. Emoji names are supplied without surrounding colons. Output is JSON +by default; add the global `--plain` flag for TSV. diff --git a/src/api/file_ops.rs b/src/api/file_ops.rs new file mode 100644 index 0000000..9ff78c6 --- /dev/null +++ b/src/api/file_ops.rs @@ -0,0 +1,313 @@ +//! External file upload and file-search Web API operations. + +use std::net::IpAddr; +use std::time::Duration; + +use reqwest::header::{CONTENT_TYPE, RETRY_AFTER}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use url::Url; + +use crate::api::SlackClient; +use crate::error::{Result, SlackError}; +use crate::models::File; + +const RAW_UPLOAD_TIMEOUT: Duration = Duration::from_secs(30); +const API_BASE_ENV: &str = "SLACK_API_BASE_URL"; + +/// Response from `files.getUploadURLExternal`. +#[derive(Debug, Serialize)] +pub struct GetUploadUrlResponse { + /// Short-lived URL that accepts the file bytes. + pub upload_url: String, + /// Slack file ID to pass to `files.completeUploadExternal`. + pub file_id: String, +} + +/// A file submitted to `files.completeUploadExternal`. +#[derive(Debug, Serialize)] +pub struct CompleteUploadFile { + /// File ID returned by `files.getUploadURLExternal`. + pub id: String, + /// Optional display title. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option<String>, +} + +/// Response from `files.completeUploadExternal`. +#[derive(Debug, Serialize, Deserialize)] +pub struct CompleteUploadResponse { + /// Completed Slack file objects. + pub files: Vec<File>, +} + +/// Request parameters for `search.files`. +#[derive(Debug, Serialize)] +pub struct SearchFilesParams { + /// Slack file-search query. + pub query: String, + /// Number of matches per page. + pub count: u32, + /// One-based page number. + pub page: u32, +} + +/// Response from `search.files`. +#[derive(Debug, Serialize, Deserialize)] +pub struct SearchFilesResponse { + /// Search result container returned by Slack. + pub files: SearchFileResults, +} + +/// File-search matches and pagination metadata. +#[derive(Debug, Serialize, Deserialize)] +pub struct SearchFileResults { + /// Total number of matches. + pub total: u32, + /// Slack's page metadata, when returned. + #[serde(default)] + pub pagination: Option<crate::api::SearchPagination>, + /// Files on this page. + pub matches: Vec<File>, +} + +#[derive(Serialize)] +struct GetUploadUrlParams<'a> { + filename: &'a str, + length: u64, +} + +#[derive(Serialize)] +struct CompleteUploadParams<'a> { + files: Vec<CompleteUploadFile>, + #[serde(skip_serializing_if = "Option::is_none")] + channel_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + initial_comment: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + thread_ts: Option<&'a str>, +} + +impl SlackClient { + /// Request a short-lived URL for an external file upload. + pub async fn files_get_upload_url_external( + &self, + filename: &str, + length: u64, + ) -> Result<GetUploadUrlResponse> { + // Deserialize through Value so a malformed payload containing a signed + // upload URL is never included in the generic parser's debug logging. + let value: serde_json::Value = self + .request( + "files.getUploadURLExternal", + &GetUploadUrlParams { filename, length }, + ) + .await?; + let upload_url = required_string(&value, "upload_url")?; + let file_id = required_string(&value, "file_id")?; + + Ok(GetUploadUrlResponse { + upload_url, + file_id, + }) + } + + /// Complete an external upload and optionally share it to a channel. + pub async fn files_complete_upload_external( + &self, + file: CompleteUploadFile, + channel_id: Option<&str>, + initial_comment: Option<&str>, + thread_ts: Option<&str>, + ) -> Result<CompleteUploadResponse> { + if file.id.trim().is_empty() { + return Err(invalid_response( + "upload response contained an empty file ID", + )); + } + + let value: serde_json::Value = self + .request( + "files.completeUploadExternal", + &CompleteUploadParams { + files: vec![file], + channel_id, + initial_comment, + thread_ts, + }, + ) + .await?; + let response: CompleteUploadResponse = deserialize_response(value)?; + validate_files(&response.files, true)?; + Ok(response) + } + + /// Run all three stages of Slack's external file-upload flow. + /// + /// The raw upload is attempted exactly once and is never sent with Slack + /// authorization headers or browser cookies. + pub async fn files_upload_external( + &self, + filename: &str, + bytes: Vec<u8>, + title: Option<&str>, + channel_id: Option<&str>, + initial_comment: Option<&str>, + thread_ts: Option<&str>, + ) -> Result<CompleteUploadResponse> { + let length = bytes.len() as u64; + let target = self.files_get_upload_url_external(filename, length).await?; + + upload_raw_bytes(&target.upload_url, bytes).await?; + + self.files_complete_upload_external( + CompleteUploadFile { + id: target.file_id, + title: title.map(str::to_string), + }, + channel_id, + initial_comment, + thread_ts, + ) + .await + } + + /// Search workspace files. + /// + /// Slack only supports this method for user and browser tokens. + pub async fn search_files(&self, params: SearchFilesParams) -> Result<SearchFilesResponse> { + if !self.supports_search() { + return Err(SlackError::SearchNotAvailable); + } + + let value: serde_json::Value = self.request("search.files", ¶ms).await?; + let response: SearchFilesResponse = deserialize_response(value)?; + validate_files(&response.files.matches, false)?; + Ok(response) + } +} + +fn required_string(value: &serde_json::Value, field: &str) -> Result<String> { + value + .get(field) + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .ok_or_else(|| invalid_response(&format!("response omitted required field `{}`", field))) +} + +fn deserialize_response<T: DeserializeOwned>(value: serde_json::Value) -> Result<T> { + serde_json::from_value(value).map_err(|error| SlackError::Api { + error: "invalid_response".to_string(), + detail: Some(format!( + "response omitted or contained invalid required data: {}", + error + )), + }) +} + +fn validate_files(files: &[File], require_file: bool) -> Result<()> { + if require_file && files.is_empty() { + return Err(invalid_response("completion response contained no files")); + } + if files.iter().any(|file| file.id.trim().is_empty()) { + return Err(invalid_response("response contained an empty file ID")); + } + Ok(()) +} + +fn invalid_response(detail: &str) -> SlackError { + SlackError::Api { + error: "invalid_response".to_string(), + detail: Some(detail.to_string()), + } +} + +async fn upload_raw_bytes(upload_url: &str, bytes: Vec<u8>) -> Result<()> { + let url = validate_upload_url(upload_url)?; + let client = reqwest::Client::builder() + .timeout(RAW_UPLOAD_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .cookie_store(false) + .build() + .map_err(SlackError::Network)?; + + let response = client + .post(url) + .header(CONTENT_TYPE, "application/octet-stream") + .body(bytes) + .send() + .await + .map_err(|error| SlackError::Network(error.without_url()))?; + + if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { + let retry_after = response + .headers() + .get(RETRY_AFTER) + .and_then(|header| header.to_str().ok()) + .and_then(|value| value.parse::<u64>().ok()) + .unwrap_or(60); + return Err(SlackError::RateLimited(retry_after)); + } + + if !response.status().is_success() { + return Err(SlackError::Api { + error: "upload_failed".to_string(), + detail: Some(format!( + "raw upload returned HTTP status {}", + response.status().as_u16() + )), + }); + } + + // The upload service's response body is intentionally not read or logged. + Ok(()) +} + +fn validate_upload_url(value: &str) -> Result<Url> { + let url = Url::parse(value) + .map_err(|_| SlackError::Usage("Slack returned an invalid upload URL".to_string()))?; + + if !url.username().is_empty() || url.password().is_some() { + return Err(SlackError::Usage( + "Slack returned an upload URL containing credentials".to_string(), + )); + } + if url.fragment().is_some() { + return Err(SlackError::Usage( + "Slack returned an upload URL containing a fragment".to_string(), + )); + } + + let secure = url.scheme() == "https"; + let loopback_exception = url.scheme() == "http" + && is_loopback_url(&url) + && std::env::var(API_BASE_ENV) + .ok() + .and_then(|base| Url::parse(&base).ok()) + .is_some_and(|base| is_loopback_url(&base)); + + if !secure && !loopback_exception { + return Err(SlackError::Usage( + "Slack upload URLs must use HTTPS".to_string(), + )); + } + + if url.host_str().is_none() { + return Err(SlackError::Usage( + "Slack returned an upload URL without a host".to_string(), + )); + } + + Ok(url) +} + +fn is_loopback_url(url: &Url) -> bool { + match url.host_str() { + Some(host) if host.eq_ignore_ascii_case("localhost") => true, + Some(host) => host + .parse::<IpAddr>() + .map(|address| address.is_loopback()) + .unwrap_or(false), + None => false, + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index b0b754e..8c61c6e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -12,6 +12,7 @@ pub mod channel_ops; pub mod chat_ops; mod client; pub mod edge; +pub mod file_ops; pub mod identity_ops; pub mod pin_emoji_ops; mod rate_limiter; diff --git a/src/cli/files.rs b/src/cli/files.rs index 95812a3..e4df0f4 100644 --- a/src/cli/files.rs +++ b/src/cli/files.rs @@ -1,6 +1,8 @@ //! Files CLI commands for Slack CLI //! -//! Handles file operations: get, info, list. +//! Handles file operations: get, info, list, upload, and search. + +use std::path::{Path, PathBuf}; use clap::{Args, Subcommand}; @@ -52,6 +54,46 @@ pub enum FilesCommands { #[arg(long)] cursor: Option<String>, }, + + /// Upload a file using Slack's external-upload flow + Upload { + /// Path to a regular readable file + path: PathBuf, + + /// Channel name or ID to share the file to + #[arg(long)] + channel: Option<String>, + + /// File title displayed in Slack + #[arg(long)] + title: Option<String>, + + /// Comment to post with the shared file + #[arg(long, requires = "channel")] + comment: Option<String>, + + /// Thread timestamp to share the file into + #[arg(long, requires = "channel")] + thread_ts: Option<String>, + + /// Override the uploaded filename + #[arg(long)] + filename: Option<String>, + }, + + /// Search files (requires a user or browser token) + Search { + /// Slack file-search query + query: String, + + /// Number of results per page (1-100) + #[arg(long, default_value_t = 20, value_parser = parse_search_count)] + count: u32, + + /// One-based page number + #[arg(long, default_value_t = 1, value_parser = parse_search_page)] + page: u32, + }, } /// Run the files command @@ -66,8 +108,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 { @@ -99,57 +140,34 @@ pub async fn run( ) .await?; } - } - - Ok(()) -} -/// Get the authentication token -fn get_token( - workspace: Option<&str>, - token_override: Option<&str>, -) -> crate::error::Result<crate::auth::TokenSet> { - use crate::auth::{get_token_store, TokenSet, TokenType}; - use crate::error::SlackError; - - 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(), - )); + FilesCommands::Upload { + path, + channel, + title, + comment, + thread_ts, + filename, + } => { + upload_file( + &client, + path, + channel.as_deref(), + title.as_deref(), + comment.as_deref(), + thread_ts.as_deref(), + filename.as_deref(), + output_mode, + ) + .await?; } - 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) + FilesCommands::Search { query, count, page } => { + search_files(&client, query, *count, *page, output_mode).await?; } } + + Ok(()) } /// Download a file @@ -286,6 +304,187 @@ async fn list_files( Ok(()) } +/// Upload a regular file through Slack's external-upload flow. +#[allow(clippy::too_many_arguments)] +async fn upload_file( + client: &crate::api::SlackClient, + path: &Path, + channel: Option<&str>, + title: Option<&str>, + comment: Option<&str>, + thread_ts: Option<&str>, + filename_override: Option<&str>, + output_mode: crate::output::OutputMode, +) -> crate::error::Result<()> { + use std::io::Read; + + use crate::error::SlackError; + use crate::output::write_json; + + if (comment.is_some() || thread_ts.is_some()) && channel.is_none() { + return Err(SlackError::Usage( + "--comment and --thread-ts require --channel".to_string(), + )); + } + if path == Path::new("-") { + return Err(SlackError::Usage( + "file uploads do not accept stdin; provide a file path".to_string(), + )); + } + + let filename = upload_filename(path, filename_override)?; + let mut file = std::fs::File::open(path)?; + if !file.metadata()?.is_file() { + return Err(SlackError::Usage(format!( + "upload path is not a regular file: {}", + path.display() + ))); + } + + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + + // Resolution intentionally happens before the upload URL is requested. + let channel_id = match channel { + Some(identifier) => Some(client.resolve_channel(identifier).await?), + None => None, + }; + + let response = client + .files_upload_external( + &filename, + bytes, + title, + channel_id.as_deref(), + comment, + thread_ts, + ) + .await?; + + if output_mode == crate::output::OutputMode::Plain { + for file in &response.files { + println!("{}", file.id); + } + } else { + write_json(&serde_json::json!({ + "ok": true, + "files": response.files, + }))?; + } + + Ok(()) +} + +fn upload_filename(path: &Path, filename_override: Option<&str>) -> crate::error::Result<String> { + use crate::error::SlackError; + + let filename = match filename_override { + Some(filename) => filename, + None => path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + SlackError::Usage( + "upload path has no UTF-8 filename; supply --filename".to_string(), + ) + })?, + }; + + if filename.trim().is_empty() + || filename == "." + || filename == ".." + || filename.contains('/') + || filename.contains('\\') + || filename.chars().any(char::is_control) + { + return Err(SlackError::Usage( + "--filename must be a non-empty filename without path separators or control characters" + .to_string(), + )); + } + + Ok(filename.to_string()) +} + +/// Search files and render either structured JSON or escaped TSV. +async fn search_files( + client: &crate::api::SlackClient, + query: &str, + count: u32, + page: u32, + output_mode: crate::output::OutputMode, +) -> crate::error::Result<()> { + use crate::api::file_ops::SearchFilesParams; + use crate::error::SlackError; + use crate::output::write_json; + + if !client.supports_search() { + return Err(SlackError::SearchNotAvailable); + } + + let response = client + .search_files(SearchFilesParams { + query: query.to_string(), + count, + page, + }) + .await?; + + if output_mode == crate::output::OutputMode::Plain { + for file in &response.files.matches { + println!( + "{}\t{}\t{}", + escape_tsv(&file.id), + escape_tsv( + file.title + .as_deref() + .filter(|title| !title.is_empty()) + .or(file.name.as_deref()) + .unwrap_or(""), + ), + escape_tsv(file.permalink.as_deref().unwrap_or("")), + ); + } + } else { + write_json(&serde_json::json!({ + "total": response.files.total, + "pagination": response.files.pagination, + "files": response.files.matches, + }))?; + } + + Ok(()) +} + +fn escape_tsv(value: &str) -> String { + value + .replace('\t', "\\t") + .replace('\n', "\\n") + .replace('\r', "\\r") +} + +fn parse_search_count(value: &str) -> std::result::Result<u32, String> { + let count = value + .parse::<u32>() + .map_err(|_| "count must be an integer from 1 to 100".to_string())?; + if (1..=100).contains(&count) { + Ok(count) + } else { + Err("count must be from 1 to 100".to_string()) + } +} + +fn parse_search_page(value: &str) -> std::result::Result<u32, String> { + let page = value + .parse::<u32>() + .map_err(|_| "page must be a positive integer".to_string())?; + if page > 0 { + Ok(page) + } else { + Err("page must be positive".to_string()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -420,6 +619,136 @@ mod tests { } } + #[test] + fn test_parse_files_upload() { + let cli = Cli::try_parse_from([ + "slack", + "files", + "upload", + "report.bin", + "--channel", + "general", + "--title", + "Quarterly report", + "--comment", + "Please review", + "--thread-ts", + "123.456", + "--filename", + "report-final.bin", + ]) + .unwrap(); + + match cli.command { + crate::cli::Commands::Files(FilesCmd { + command: + FilesCommands::Upload { + path, + channel, + title, + comment, + thread_ts, + filename, + }, + }) => { + assert_eq!(path, PathBuf::from("report.bin")); + assert_eq!(channel.as_deref(), Some("general")); + assert_eq!(title.as_deref(), Some("Quarterly report")); + assert_eq!(comment.as_deref(), Some("Please review")); + assert_eq!(thread_ts.as_deref(), Some("123.456")); + assert_eq!(filename.as_deref(), Some("report-final.bin")); + } + _ => panic!("Expected Upload command"), + } + } + + #[test] + fn test_parse_files_upload_comment_requires_channel() { + assert!(Cli::try_parse_from([ + "slack", + "files", + "upload", + "report.bin", + "--comment", + "hello", + ]) + .is_err()); + assert!(Cli::try_parse_from([ + "slack", + "files", + "upload", + "report.bin", + "--thread-ts", + "123.456", + ]) + .is_err()); + } + + #[test] + fn test_parse_files_search_defaults_and_options() { + let defaults = Cli::try_parse_from(["slack", "files", "search", "budget"]).unwrap(); + match defaults.command { + crate::cli::Commands::Files(FilesCmd { + command: FilesCommands::Search { query, count, page }, + }) => { + assert_eq!(query, "budget"); + assert_eq!(count, 20); + assert_eq!(page, 1); + } + _ => panic!("Expected Search command"), + } + + let custom = Cli::try_parse_from([ + "slack", "files", "search", "budget", "--count", "50", "--page", "3", + ]) + .unwrap(); + match custom.command { + crate::cli::Commands::Files(FilesCmd { + command: FilesCommands::Search { count, page, .. }, + }) => { + assert_eq!(count, 50); + assert_eq!(page, 3); + } + _ => panic!("Expected Search command"), + } + } + + #[test] + fn test_parse_files_search_rejects_ranges() { + assert!( + Cli::try_parse_from(["slack", "files", "search", "budget", "--count", "0"]).is_err() + ); + assert!( + Cli::try_parse_from(["slack", "files", "search", "budget", "--count", "101"]).is_err() + ); + assert!( + Cli::try_parse_from(["slack", "files", "search", "budget", "--page", "0"]).is_err() + ); + } + + #[test] + fn test_upload_filename_rejects_empty_and_invalid_overrides() { + let path = PathBuf::from("report.txt"); + assert!(upload_filename(&path, Some("")).is_err()); + assert!(upload_filename(&path, Some("..")).is_err()); + assert!(upload_filename(&path, Some("folder/file.txt")).is_err()); + assert!(upload_filename(&path, Some("bad\nname")).is_err()); + } + + #[cfg(unix)] + #[test] + fn test_upload_filename_requires_utf8_basename_unless_overridden() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let invalid = PathBuf::from(OsString::from_vec(vec![b'f', 0xff])); + assert!(upload_filename(&invalid, None).is_err()); + assert_eq!( + upload_filename(&invalid, Some("fallback.bin")).unwrap(), + "fallback.bin" + ); + } + #[test] fn test_parse_files_alias() { let cli = Cli::try_parse_from(["slack", "f", "list"]).unwrap(); diff --git a/tests/cli_file_ops.rs b/tests/cli_file_ops.rs new file mode 100644 index 0000000..d8bd6ce --- /dev/null +++ b/tests/cli_file_ops.rs @@ -0,0 +1,731 @@ +//! End-to-end tests for external uploads and file search. + +use std::path::{Path, PathBuf}; + +use assert_cmd::cargo::cargo_bin_cmd; +use assert_cmd::Command; +use mockito::{Matcher, ServerGuard}; +use predicates::prelude::*; +use tempfile::TempDir; + +const USER_TOKEN: &str = "xoxp-file-test-token-1234567890"; +const BOT_TOKEN: &str = "xoxb-file-test-token-1234567890"; +const BROWSER_TOKEN: &str = "xoxc-file-test-token-1234567890"; +const BROWSER_COOKIE: &str = "xoxd-file-test-cookie-1234567890"; +const FILE_ID: &str = "F123456789"; + +async fn server() -> ServerGuard { + mockito::Server::new_async().await +} + +fn slack_cmd(api_url: &str, store_path: &Path) -> Command { + let mut cmd = cargo_bin_cmd!("slack"); + cmd.env("SLACK_API_BASE_URL", api_url) + .env("SLACK_TOKEN_STORE_PATH", store_path) + .env_remove("SLACK_TOKEN") + .env_remove("SLACK_WORKSPACE") + .env_remove("SLACK_PLAIN"); + cmd +} + +fn user_cmd(api_url: &str, tmp: &TempDir) -> Command { + let mut cmd = slack_cmd(api_url, &tmp.path().join("no-tokens.json")); + cmd.env("SLACK_TOKEN", USER_TOKEN); + cmd +} + +fn write_browser_store(tmp: &TempDir) -> PathBuf { + let path = tmp.path().join("tokens.json"); + let data = serde_json::json!({ + "tokens": { + "T_BROWSER": { + "token_type": "browser", + "access_token": BROWSER_TOKEN, + "xoxd_cookie": BROWSER_COOKIE, + "team_id": "T_BROWSER", + "team_name": "Browser Workspace", + "team_domain": "browser-workspace", + "user_id": "U123456789", + "created_at": "2024-01-01T00:00:00Z", + "scopes": [] + } + }, + "default": "T_BROWSER", + "workspaces": ["T_BROWSER"] + }); + std::fs::write(&path, data.to_string()).unwrap(); + path +} + +fn upload_file(tmp: &TempDir, name: &str, bytes: &[u8]) -> PathBuf { + let path = tmp.path().join(name); + std::fs::write(&path, bytes).unwrap(); + path +} + +fn completed_file_json() -> &'static str { + r#"{"ok":true,"files":[{"id":"F123456789","name":"report.bin","title":"Quarterly report","permalink":"https://workspace.slack.com/files/F123456789"}]}"# +} + +#[tokio::test] +async fn upload_runs_all_stages_resolves_channel_and_keeps_raw_request_unauthenticated() { + let mut api = server().await; + let mut raw = server().await; + let tmp = TempDir::new().unwrap(); + let path = upload_file(&tmp, "source.bin", b"\0\x01binary\xff"); + let store = write_browser_store(&tmp); + + let resolve = api + .mock("POST", "/conversations.list") + .match_header("authorization", format!("Bearer {}", BROWSER_TOKEN).as_str()) + .match_header("cookie", format!("d={}", BROWSER_COOKIE).as_str()) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true,"channels":[{"id":"C123456789","name":"general"}],"response_metadata":{"next_cursor":""}}"#) + .create_async() + .await; + let get_url = api + .mock("POST", "/files.getUploadURLExternal") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("filename".into(), "report.bin".into()), + Matcher::UrlEncoded("length".into(), "9".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(format!( + r#"{{"ok":true,"upload_url":"{}/raw-upload?signature=secret","file_id":"{}"}}"#, + raw.url(), + FILE_ID + )) + .create_async() + .await; + let raw_upload = raw + .mock("POST", "/raw-upload") + .match_query(Matcher::UrlEncoded("signature".into(), "secret".into())) + .match_header("content-type", "application/octet-stream") + .match_header("authorization", Matcher::Missing) + .match_header("cookie", Matcher::Missing) + .match_body(Matcher::from(b"\0\x01binary\xff".to_vec())) + .with_status(200) + .with_body("signed response must not matter") + .create_async() + .await; + let complete = api + .mock("POST", "/files.completeUploadExternal") + .match_header( + "authorization", + format!("Bearer {}", BROWSER_TOKEN).as_str(), + ) + .match_header("cookie", format!("d={}", BROWSER_COOKIE).as_str()) + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded( + "files".into(), + format!(r#"[{{"id":"{}","title":"Quarterly report"}}]"#, FILE_ID), + ), + Matcher::UrlEncoded("channel_id".into(), "C123456789".into()), + Matcher::UrlEncoded("initial_comment".into(), "please review".into()), + Matcher::UrlEncoded("thread_ts".into(), "123.456".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(completed_file_json()) + .create_async() + .await; + + let output = slack_cmd(&api.url(), &store) + .args([ + "files", + "upload", + path.to_str().unwrap(), + "--channel", + "general", + "--title", + "Quarterly report", + "--comment", + "please review", + "--thread-ts", + "123.456", + "--filename", + "report.bin", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let json: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(json["ok"], true); + assert_eq!(json["files"][0]["id"], FILE_ID); + + resolve.assert_async().await; + get_url.assert_async().await; + raw_upload.assert_async().await; + complete.assert_async().await; +} + +#[tokio::test] +async fn upload_uses_basename_actual_length_and_plain_ids() { + let mut api = server().await; + let mut raw = server().await; + let tmp = TempDir::new().unwrap(); + let path = upload_file(&tmp, "notes.txt", b"changed bytes"); + + let get_url = api + .mock("POST", "/files.getUploadURLExternal") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("filename".into(), "notes.txt".into()), + Matcher::UrlEncoded("length".into(), "13".into()), + ])) + .with_status(200) + .with_body(format!( + r#"{{"ok":true,"upload_url":"{}/upload","file_id":"{}"}}"#, + raw.url(), + FILE_ID + )) + .create_async() + .await; + let raw_upload = raw + .mock("POST", "/upload") + .match_body(Matcher::from(b"changed bytes".to_vec())) + .with_status(204) + .create_async() + .await; + let complete = api + .mock("POST", "/files.completeUploadExternal") + .match_body(Matcher::UrlEncoded( + "files".into(), + format!(r#"[{{"id":"{}"}}]"#, FILE_ID), + )) + .with_status(200) + .with_body(r#"{"ok":true,"files":[{"id":"F123456789"},{"id":"F987654321"}]}"#) + .create_async() + .await; + + user_cmd(&api.url(), &tmp) + .args(["--plain", "files", "upload", path.to_str().unwrap()]) + .assert() + .success() + .stdout("F123456789\nF987654321\n"); + + get_url.assert_async().await; + raw_upload.assert_async().await; + complete.assert_async().await; +} + +#[tokio::test] +async fn upload_sends_zero_length_for_an_empty_regular_file() { + let mut api = server().await; + let mut raw = server().await; + let tmp = TempDir::new().unwrap(); + let path = upload_file(&tmp, "empty.txt", b""); + + let get_url = api + .mock("POST", "/files.getUploadURLExternal") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("filename".into(), "empty.txt".into()), + Matcher::UrlEncoded("length".into(), "0".into()), + ])) + .with_status(200) + .with_body(format!( + r#"{{"ok":true,"upload_url":"{}/empty","file_id":"{}"}}"#, + raw.url(), + FILE_ID + )) + .create_async() + .await; + let raw_upload = raw + .mock("POST", "/empty") + .match_body(Matcher::Exact(String::new())) + .with_status(200) + .create_async() + .await; + let complete = api + .mock("POST", "/files.completeUploadExternal") + .with_status(200) + .with_body(r#"{"ok":true,"files":[{"id":"F123456789"}]}"#) + .create_async() + .await; + + user_cmd(&api.url(), &tmp) + .args(["files", "upload", path.to_str().unwrap()]) + .assert() + .success(); + + get_url.assert_async().await; + raw_upload.assert_async().await; + complete.assert_async().await; +} + +#[tokio::test] +async fn upload_allows_files_larger_than_download_limit() { + let mut api = server().await; + let mut raw = server().await; + let tmp = TempDir::new().unwrap(); + let bytes = vec![b'x'; 5 * 1024 * 1024 + 1]; + let path = upload_file(&tmp, "large.bin", &bytes); + + let get_url = api + .mock("POST", "/files.getUploadURLExternal") + .match_body(Matcher::UrlEncoded( + "length".into(), + (5 * 1024 * 1024 + 1).to_string(), + )) + .with_status(200) + .with_body(format!( + r#"{{"ok":true,"upload_url":"{}/large","file_id":"{}"}}"#, + raw.url(), + FILE_ID + )) + .create_async() + .await; + let raw_upload = raw + .mock("POST", "/large") + .match_body(Matcher::from(bytes)) + .with_status(200) + .create_async() + .await; + let complete = api + .mock("POST", "/files.completeUploadExternal") + .with_status(200) + .with_body(r#"{"ok":true,"files":[{"id":"F123456789"}]}"#) + .create_async() + .await; + + user_cmd(&api.url(), &tmp) + .args(["files", "upload", path.to_str().unwrap()]) + .assert() + .success(); + + get_url.assert_async().await; + raw_upload.assert_async().await; + complete.assert_async().await; +} + +#[test] +fn upload_rejects_missing_directory_and_invalid_filename_without_io() { + let tmp = TempDir::new().unwrap(); + + user_cmd("http://127.0.0.1:1", &tmp) + .args([ + "files", + "upload", + tmp.path().join("missing").to_str().unwrap(), + ]) + .assert() + .failure() + .stdout(predicate::str::contains("io_error")); + user_cmd("http://127.0.0.1:1", &tmp) + .args(["files", "upload", tmp.path().to_str().unwrap()]) + .assert() + .failure() + .stdout(predicate::str::contains("not a regular file")); + let nonempty = upload_file(&tmp, "ok.txt", b"x"); + user_cmd("http://127.0.0.1:1", &tmp) + .args([ + "files", + "upload", + nonempty.to_str().unwrap(), + "--filename", + "../secret", + ]) + .assert() + .code(2) + .stdout(predicate::str::contains("filename")); + user_cmd("http://127.0.0.1:1", &tmp) + .args(["files", "upload", "-"]) + .assert() + .code(2) + .stdout(predicate::str::contains("stdin")); +} + +#[test] +fn upload_comment_and_thread_require_channel() { + let tmp = TempDir::new().unwrap(); + let path = upload_file(&tmp, "ok.txt", b"x"); + user_cmd("http://127.0.0.1:1", &tmp) + .args([ + "files", + "upload", + path.to_str().unwrap(), + "--comment", + "hello", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains( + "required arguments were not provided", + )); + user_cmd("http://127.0.0.1:1", &tmp) + .args([ + "files", + "upload", + path.to_str().unwrap(), + "--thread-ts", + "123.456", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains( + "required arguments were not provided", + )); +} + +async fn assert_raw_failure_does_not_complete(status: usize, retry_after: Option<&str>) { + let mut api = server().await; + let mut raw = server().await; + let tmp = TempDir::new().unwrap(); + let path = upload_file(&tmp, "failure.bin", b"payload"); + + let get_url = api + .mock("POST", "/files.getUploadURLExternal") + .with_status(200) + .with_body(format!( + r#"{{"ok":true,"upload_url":"{}/failure","file_id":"{}"}}"#, + raw.url(), + FILE_ID + )) + .create_async() + .await; + let mut raw_builder = raw + .mock("POST", "/failure") + .match_body(Matcher::from(b"payload".to_vec())) + .with_status(status); + if let Some(value) = retry_after { + raw_builder = raw_builder.with_header("retry-after", value); + } + let raw_upload = raw_builder.create_async().await; + let complete = api + .mock("POST", "/files.completeUploadExternal") + .expect(0) + .create_async() + .await; + + let assertion = user_cmd(&api.url(), &tmp) + .args(["files", "upload", path.to_str().unwrap()]) + .assert() + .failure(); + if status == 429 { + assertion.stdout(predicate::str::contains("rate_limited")); + } else { + assertion + .stdout(predicate::str::contains("upload_failed")) + .stdout(predicate::str::contains(status.to_string())) + .stdout(predicate::str::contains("payload").not()); + } + + get_url.assert_async().await; + raw_upload.assert_async().await; + complete.assert_async().await; +} + +#[tokio::test] +async fn raw_upload_http_and_rate_limit_failures_do_not_complete() { + assert_raw_failure_does_not_complete(500, None).await; + assert_raw_failure_does_not_complete(429, Some("7")).await; +} + +#[tokio::test] +async fn raw_upload_does_not_follow_redirects_or_complete() { + let mut api = server().await; + let mut raw = server().await; + let tmp = TempDir::new().unwrap(); + let path = upload_file(&tmp, "redirect.bin", b"payload"); + let get_url = api + .mock("POST", "/files.getUploadURLExternal") + .with_status(200) + .with_body(format!( + r#"{{"ok":true,"upload_url":"{}/redirect","file_id":"{}"}}"#, + raw.url(), + FILE_ID + )) + .create_async() + .await; + let redirect = raw + .mock("POST", "/redirect") + .with_status(302) + .with_header("location", "/sink") + .create_async() + .await; + let sink = raw.mock("POST", "/sink").expect(0).create_async().await; + let complete = api + .mock("POST", "/files.completeUploadExternal") + .expect(0) + .create_async() + .await; + + user_cmd(&api.url(), &tmp) + .args(["files", "upload", path.to_str().unwrap()]) + .assert() + .failure() + .stdout(predicate::str::contains("upload_failed")); + + get_url.assert_async().await; + redirect.assert_async().await; + sink.assert_async().await; + complete.assert_async().await; +} + +#[tokio::test] +async fn upload_rejects_insecure_non_loopback_credentials_and_fragments_before_raw_io() { + for upload_url in [ + "http://example.com/upload", + "http://user:password@127.0.0.1:9/upload", + "http://127.0.0.1:9/upload#fragment", + ] { + let mut api = server().await; + let tmp = TempDir::new().unwrap(); + let path = upload_file(&tmp, "secure.bin", b"payload"); + let get_url = api + .mock("POST", "/files.getUploadURLExternal") + .with_status(200) + .with_body(format!( + r#"{{"ok":true,"upload_url":"{}","file_id":"{}"}}"#, + upload_url, FILE_ID + )) + .create_async() + .await; + let complete = api + .mock("POST", "/files.completeUploadExternal") + .expect(0) + .create_async() + .await; + + user_cmd(&api.url(), &tmp) + .args(["files", "upload", path.to_str().unwrap()]) + .assert() + .failure() + .stdout(predicate::str::contains("usage_error")); + + get_url.assert_async().await; + complete.assert_async().await; + } +} + +#[tokio::test] +async fn upload_network_failure_does_not_complete() { + let mut api = server().await; + let dead_url = "http://127.0.0.1:1"; + let tmp = TempDir::new().unwrap(); + let path = upload_file(&tmp, "network.bin", b"payload"); + let get_url = api + .mock("POST", "/files.getUploadURLExternal") + .with_status(200) + .with_body(format!( + r#"{{"ok":true,"upload_url":"{}/gone?signature=do-not-print","file_id":"{}"}}"#, + dead_url, FILE_ID + )) + .create_async() + .await; + let complete = api + .mock("POST", "/files.completeUploadExternal") + .expect(0) + .create_async() + .await; + + user_cmd(&api.url(), &tmp) + .args(["files", "upload", path.to_str().unwrap()]) + .assert() + .failure() + .stdout(predicate::str::contains("network_error")) + .stdout(predicate::str::contains("do-not-print").not()); + + get_url.assert_async().await; + complete.assert_async().await; +} + +#[tokio::test] +async fn upload_propagates_completion_error_and_validates_required_response_fields() { + let mut api = server().await; + let mut raw = server().await; + let tmp = TempDir::new().unwrap(); + let path = upload_file(&tmp, "complete.bin", b"payload"); + let get_url = api + .mock("POST", "/files.getUploadURLExternal") + .with_status(200) + .with_body(format!( + r#"{{"ok":true,"upload_url":"{}/upload","file_id":"{}"}}"#, + raw.url(), + FILE_ID + )) + .create_async() + .await; + let raw_upload = raw + .mock("POST", "/upload") + .with_status(200) + .create_async() + .await; + let complete = api + .mock("POST", "/files.completeUploadExternal") + .with_status(200) + .with_body(r#"{"ok":false,"error":"not_in_channel"}"#) + .create_async() + .await; + + user_cmd(&api.url(), &tmp) + .args(["files", "upload", path.to_str().unwrap()]) + .assert() + .failure() + .stdout(predicate::str::contains("not_in_channel")) + .stdout(predicate::str::contains("shared").not()); + + get_url.assert_async().await; + raw_upload.assert_async().await; + complete.assert_async().await; + + let mut api = server().await; + let missing = api + .mock("POST", "/files.getUploadURLExternal") + .with_status(200) + .with_body(r#"{"ok":true,"file_id":"F123456789"}"#) + .create_async() + .await; + user_cmd(&api.url(), &tmp) + .args(["files", "upload", path.to_str().unwrap()]) + .assert() + .failure() + .stdout(predicate::str::contains("invalid_response")); + missing.assert_async().await; +} + +#[tokio::test] +async fn search_files_defaults_preserves_metadata_and_optional_fields() { + let mut api = server().await; + let tmp = TempDir::new().unwrap(); + let search = api + .mock("POST", "/search.files") + .match_header("authorization", format!("Bearer {}", USER_TOKEN).as_str()) + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("query".into(), "quarterly report".into()), + Matcher::UrlEncoded("count".into(), "20".into()), + Matcher::UrlEncoded("page".into(), "1".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true,"files":{"total":2,"pagination":{"total_count":2,"page":1,"per_page":20,"page_count":1,"first":1,"last":2},"matches":[{"id":"F123456789","title":"Report","permalink":"https://example.test/file"},{"id":"F987654321"}]}}"#) + .create_async() + .await; + + let output = user_cmd(&api.url(), &tmp) + .args(["files", "search", "quarterly report"]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let json: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(json["total"], 2); + assert_eq!(json["pagination"]["per_page"], 20); + assert_eq!(json["files"][1]["id"], "F987654321"); + + search.assert_async().await; +} + +#[tokio::test] +async fn search_files_custom_pagination_empty_results_and_browser_auth() { + let mut api = server().await; + let tmp = TempDir::new().unwrap(); + let store = write_browser_store(&tmp); + let search = api + .mock("POST", "/search.files") + .match_header( + "authorization", + format!("Bearer {}", BROWSER_TOKEN).as_str(), + ) + .match_header("cookie", format!("d={}", BROWSER_COOKIE).as_str()) + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("query".into(), "nothing".into()), + Matcher::UrlEncoded("count".into(), "100".into()), + Matcher::UrlEncoded("page".into(), "4".into()), + ])) + .with_status(200) + .with_body(r#"{"ok":true,"files":{"total":0,"matches":[]}}"#) + .create_async() + .await; + + let output = slack_cmd(&api.url(), &store) + .args([ + "files", "search", "nothing", "--count", "100", "--page", "4", + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + let json: serde_json::Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(json["total"], 0); + assert_eq!(json["pagination"], serde_json::Value::Null); + assert_eq!(json["files"], serde_json::json!([])); + search.assert_async().await; +} + +#[tokio::test] +async fn search_files_plain_escapes_all_tsv_control_characters() { + let mut api = server().await; + let tmp = TempDir::new().unwrap(); + let search = api + .mock("POST", "/search.files") + .with_status(200) + .with_body(r#"{"ok":true,"files":{"total":2,"matches":[{"id":"F123456789","title":"title\tline\nnext\rend","name":"ignored","permalink":"https://example.test/a\tb"},{"id":"F987654321","name":"fallback"}]}}"#) + .create_async() + .await; + + user_cmd(&api.url(), &tmp) + .args(["--plain", "files", "search", "report"]) + .assert() + .success() + .stdout(concat!( + "F123456789\ttitle\\tline\\nnext\\rend\thttps://example.test/a\\tb\n", + "F987654321\tfallback\t\n" + )); + search.assert_async().await; +} + +#[tokio::test] +async fn search_files_bot_gate_makes_no_request_and_errors_propagate() { + let mut api = server().await; + let tmp = TempDir::new().unwrap(); + let untouched = api + .mock("POST", "/search.files") + .expect(0) + .create_async() + .await; + let mut bot = user_cmd(&api.url(), &tmp); + bot.env("SLACK_TOKEN", BOT_TOKEN) + .args(["files", "search", "report"]) + .assert() + .failure() + .stdout(predicate::str::contains("search_not_available")); + untouched.assert_async().await; + + let mut api = server().await; + let error = api + .mock("POST", "/search.files") + .with_status(200) + .with_body(r#"{"ok":false,"error":"missing_scope"}"#) + .create_async() + .await; + user_cmd(&api.url(), &tmp) + .args(["files", "search", "report"]) + .assert() + .failure() + .stdout(predicate::str::contains("missing_scope")); + error.assert_async().await; +} + +#[tokio::test] +async fn search_files_rejects_empty_ids() { + let mut api = server().await; + let tmp = TempDir::new().unwrap(); + let search = api + .mock("POST", "/search.files") + .with_status(200) + .with_body(r#"{"ok":true,"files":{"total":1,"matches":[{"id":""}]}}"#) + .create_async() + .await; + user_cmd(&api.url(), &tmp) + .args(["files", "search", "report"]) + .assert() + .failure() + .stdout(predicate::str::contains("invalid_response")); + search.assert_async().await; +} From 7c4084ba0fa6b6f2d61321928b447b7757cccc8e Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 00:21:34 +1000 Subject: [PATCH 09/22] chore: ci fix-ups --- Cargo.lock | 329 ++++++++++---------------------------------- Cargo.toml | 3 +- src/cli/messages.rs | 2 +- 3 files changed, 74 insertions(+), 260 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 17130c1..1ed3566 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 4 +version = 3 [[package]] name = "aes" @@ -338,11 +338,12 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "colored" -version = "3.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ - "windows-sys 0.61.2", + "lazy_static", + "windows-sys 0.52.0", ] [[package]] @@ -462,9 +463,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", ] @@ -486,17 +487,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "document-features" version = "0.2.12" @@ -528,7 +518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -795,9 +785,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" [[package]] name = "hashlink" @@ -985,7 +975,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1016,115 +1006,44 @@ dependencies = [ ] [[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" +name = "idna" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", + "idna_adapter", "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", + "utf8_iter", ] [[package]] -name = "idna" +name = "idna_adapter" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +checksum = "279259b0ac81c89d11c290495fdcfa96ea3643b7df311c138b6fe8ca5237f0f8" dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", + "idna_mapping", + "unicode-bidi", + "unicode-normalization", ] [[package]] -name = "idna_adapter" -version = "1.2.1" +name = "idna_mapping" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "11c13906586a4b339310541a274dd927aff6fcbb5b8e3af90634c4b31681c792" dependencies = [ - "icu_normalizer", - "icu_properties", + "unicode-joining-type", ] [[package]] name = "indexmap" -version = "2.13.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.15.5", ] [[package]] @@ -1249,12 +1168,6 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - [[package]] name = "litrs" version = "1.0.0" @@ -1316,22 +1229,21 @@ dependencies = [ [[package]] name = "mockito" -version = "1.7.2" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +checksum = "652cd6d169a36eaf9d1e6bce1a221130439a966d7f27858af66a33a66e9c4ee2" dependencies = [ "assert-json-diff", "bytes", "colored", - "futures-core", + "futures-util", "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.8.1", "hyper-util", "log", - "pin-project-lite", - "rand 0.9.2", + "rand 0.8.5", "regex", "serde_json", "serde_urlencoded", @@ -1368,9 +1280,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-traits" @@ -1540,15 +1452,6 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -1647,7 +1550,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.36", - "socket2 0.6.2", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -1684,9 +1587,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -1937,7 +1840,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2262,12 +2165,6 @@ dependencies = [ "lock_api", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "strsim" version = "0.11.1" @@ -2306,17 +2203,6 @@ dependencies = [ "futures-core", ] -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "system-configuration" version = "0.5.1" @@ -2348,7 +2234,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2418,30 +2304,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.46" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde_core", + "serde", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.26" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ "num-conv", "time-core", @@ -2459,16 +2345,6 @@ dependencies = [ "log", ] -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - [[package]] name = "tinyvec" version = "1.10.0" @@ -2684,12 +2560,33 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-joining-type" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d00a78170970967fdb83f9d49b92f959ab2bb829186b113e4f4604ad98e180" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -2698,15 +2595,14 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.8" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] @@ -3185,35 +3081,6 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - [[package]] name = "zerocopy" version = "0.8.38" @@ -3234,27 +3101,6 @@ dependencies = [ "syn", ] -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - [[package]] name = "zeroize" version = "1.8.2" @@ -3266,42 +3112,9 @@ dependencies = [ [[package]] name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 6987221..f249658 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,8 @@ name = "slack-cli" version = "0.2.1" edition = "2021" -rust-version = "1.78" +rust-version = "1.75" +default-run = "slack" license = "MIT" description = "A comprehensive CLI tool for Slack, designed for AI agents and automation" repository = "https://github.com/user/slack-cli" diff --git a/src/cli/messages.rs b/src/cli/messages.rs index 0bfb2a8..ab477c4 100644 --- a/src/cli/messages.rs +++ b/src/cli/messages.rs @@ -88,7 +88,7 @@ pub enum MessagesCommands { /// Send a message Send { - /// Channel name or ID + /// Channel name, channel ID, @user, or user ID channel: String, /// Message text (optional if using --stdin or --blocks) From bde61e95f7e216591270563e0514ada6444bdc3b Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 00:23:55 +1000 Subject: [PATCH 10/22] chore: restore Cargo.lock v4 and rust-version 1.78; fix stale MSRV note in AGENTS.md --- AGENTS.md | 4 +- Cargo.lock | 329 +++++++++++++++++++++++++++++++++++++++++------------ Cargo.toml | 2 +- 3 files changed, 261 insertions(+), 74 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4104782..0191614 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,12 +33,12 @@ - Verify linting: `cargo clippy --all-targets --all-features -- -D warnings` - Run targeted tests while iterating: `cargo test <test_name>` - Before finishing, run the full suite: `cargo test` -- MSRV is **1.75** — do not use features requiring a newer Rust edition or version +- MSRV is **1.78** (`rust-version` in Cargo.toml); CI builds on stable only ## CI checks (all must pass) CI runs on every push/PR to main. These are the exact checks: -1. `cargo build --verbose` (Linux, macOS, Windows × stable + 1.75) +1. `cargo build --verbose` (Linux, macOS, Windows × stable) 2. `cargo test --verbose` (with `SLACK_INTEGRATION_TESTS=1`) 3. `cargo fmt --all -- --check` 4. `cargo clippy --all-targets --all-features -- -D warnings` diff --git a/Cargo.lock b/Cargo.lock index 1ed3566..17130c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aes" @@ -338,12 +338,11 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "colored" -version = "2.2.0" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "lazy_static", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -463,9 +462,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", ] @@ -487,6 +486,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "document-features" version = "0.2.12" @@ -518,7 +528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -785,9 +795,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "hashlink" @@ -975,7 +985,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.2", "tokio", "tower-service", "tracing", @@ -1006,44 +1016,115 @@ dependencies = [ ] [[package]] -name = "idna" -version = "1.0.3" +name = "icu_collections" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ - "idna_adapter", + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", "smallvec", - "utf8_iter", + "zerovec", ] [[package]] -name = "idna_adapter" -version = "1.1.0" +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "279259b0ac81c89d11c290495fdcfa96ea3643b7df311c138b6fe8ca5237f0f8" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ - "idna_mapping", - "unicode-bidi", - "unicode-normalization", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "idna_mapping" +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11c13906586a4b339310541a274dd927aff6fcbb5b8e3af90634c4b31681c792" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "unicode-joining-type", + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] name = "indexmap" -version = "2.7.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -1168,6 +1249,12 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "litrs" version = "1.0.0" @@ -1229,21 +1316,22 @@ dependencies = [ [[package]] name = "mockito" -version = "1.6.1" +version = "1.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "652cd6d169a36eaf9d1e6bce1a221130439a966d7f27858af66a33a66e9c4ee2" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" dependencies = [ "assert-json-diff", "bytes", "colored", - "futures-util", + "futures-core", "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.8.1", "hyper-util", "log", - "rand 0.8.5", + "pin-project-lite", + "rand 0.9.2", "regex", "serde_json", "serde_urlencoded", @@ -1280,9 +1368,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-traits" @@ -1452,6 +1540,15 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1550,7 +1647,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.36", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -1587,9 +1684,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -1840,7 +1937,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2165,6 +2262,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -2203,6 +2306,17 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -2234,7 +2348,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2304,30 +2418,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.36" +version = "0.3.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" dependencies = [ "num-conv", "time-core", @@ -2345,6 +2459,16 @@ dependencies = [ "log", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -2560,33 +2684,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" -[[package]] -name = "unicode-joining-type" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8d00a78170970967fdb83f9d49b92f959ab2bb829186b113e4f4604ad98e180" - -[[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - [[package]] name = "untrusted" version = "0.9.0" @@ -2595,14 +2698,15 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.4" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -3081,6 +3185,35 @@ version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.38" @@ -3101,6 +3234,27 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.2" @@ -3112,9 +3266,42 @@ dependencies = [ [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index f249658..7a49006 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "slack-cli" version = "0.2.1" edition = "2021" -rust-version = "1.75" +rust-version = "1.78" default-run = "slack" license = "MIT" description = "A comprehensive CLI tool for Slack, designed for AI agents and automation" From e98301602f93599bfb27fa86017472887fdf6e18 Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 06:55:29 +1000 Subject: [PATCH 11/22] test(ci-gate): Make coverage gate real and enable skipped client mock tests --- .github/workflows/ci.yml | 2 +- AGENTS.md | 2 +- src/api/client.rs | 43 ---------------------------------------- 3 files changed, 2 insertions(+), 45 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e6294e..ed4d8ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,6 +133,6 @@ jobs: ${{ runner.os }}-cargo-coverage- - name: Run coverage - run: cargo llvm-cov --all-features --workspace + run: cargo llvm-cov --all-features --workspace --ignore-filename-regex 'src/bin/test_keyring\.rs' --fail-under-lines 80 env: SLACK_INTEGRATION_TESTS: "1" diff --git a/AGENTS.md b/AGENTS.md index 0191614..964a591 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ CI runs on every push/PR to main. These are the exact checks: 3. `cargo fmt --all -- --check` 4. `cargo clippy --all-targets --all-features -- -D warnings` 5. `cargo doc --no-deps --document-private-items` (with `RUSTDOCFLAGS=-D warnings`) -6. `cargo llvm-cov --all-features --workspace --fail-under 80` +6. `cargo llvm-cov --all-features --workspace --ignore-filename-regex 'src/bin/test_keyring\.rs' --fail-under-lines 80` (`src/bin/test_keyring.rs`, the diagnostic binary, is excluded from coverage) ## Guardrails (do not) diff --git a/src/api/client.rs b/src/api/client.rs index 45a869c..714096b 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -761,13 +761,6 @@ mod tests { async fn test_client_request_mock() { use crate::api::types::AuthTestResponse; - // Skip this test unless SLACK_RUN_MOCK_TESTS=1 is set - // because mockito requires socket binding which may fail in restricted environments - if std::env::var("SLACK_RUN_MOCK_TESTS").unwrap_or_default() != "1" { - eprintln!("Skipping test_client_request_mock (set SLACK_RUN_MOCK_TESTS=1 to run)"); - return; - } - use mockito::Server; let mut server = Server::new_async().await; @@ -971,20 +964,8 @@ mod tests { } } - 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; @@ -1016,9 +997,6 @@ mod tests { #[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; @@ -1060,9 +1038,6 @@ mod tests { #[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; @@ -1100,9 +1075,6 @@ mod tests { #[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; @@ -1132,9 +1104,6 @@ mod tests { #[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; @@ -1161,9 +1130,6 @@ mod tests { #[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. @@ -1206,9 +1172,6 @@ mod tests { #[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; @@ -1240,9 +1203,6 @@ mod tests { #[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; @@ -1270,9 +1230,6 @@ mod tests { #[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/<method> URL must still From 6f63b41eec8e3753f27881febd583cf7c0cbd5f4 Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 06:58:10 +1000 Subject: [PATCH 12/22] test(pr-critical): Cover Critical-risk entities resolve_channel pagination and reminders parse_when --- src/cli/reminders.rs | 86 +++++++++++++++++++++++++++++++++++++++++++- tests/api_resolve.rs | 78 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/src/cli/reminders.rs b/src/cli/reminders.rs index f0b95ad..cd12c92 100644 --- a/src/cli/reminders.rs +++ b/src/cli/reminders.rs @@ -394,7 +394,7 @@ async fn delete_reminder( #[cfg(test)] mod tests { use super::*; - use chrono::Timelike; + use chrono::{Duration, Local, Timelike}; use clap::{CommandFactory, Parser}; use crate::cli::Cli; @@ -557,4 +557,88 @@ mod tests { assert!(parse_relative_duration("2d").is_ok()); assert!(parse_relative_duration("invalid").is_err()); } + + fn local_datetime(timestamp: i64) -> chrono::DateTime<Local> { + chrono::DateTime::<chrono::Utc>::from_timestamp(timestamp, 0) + .unwrap() + .with_timezone(&Local) + } + + #[test] + fn test_parse_when_date_defaults_to_nine_local() { + let parsed = local_datetime(parse_when("2030-01-15").unwrap()); + assert_eq!( + parsed.date_naive(), + chrono::NaiveDate::from_ymd_opt(2030, 1, 15).unwrap() + ); + assert_eq!((parsed.hour(), parsed.minute(), parsed.second()), (9, 0, 0)); + } + + #[test] + fn test_parse_when_local_date_and_time_preserves_fields() { + let parsed = local_datetime(parse_when("2030-01-15 14:37").unwrap()); + assert_eq!( + parsed.date_naive(), + chrono::NaiveDate::from_ymd_opt(2030, 1, 15).unwrap() + ); + assert_eq!( + (parsed.hour(), parsed.minute(), parsed.second()), + (14, 37, 0) + ); + } + + #[test] + fn test_parse_when_tomorrow_without_time_defaults_to_nine_local() { + let before = Local::now().date_naive(); + let parsed = local_datetime(parse_when("tomorrow").unwrap()); + let after = Local::now().date_naive(); + + assert!( + parsed.date_naive() == before + Duration::days(1) + || parsed.date_naive() == after + Duration::days(1) + ); + assert_eq!((parsed.hour(), parsed.minute(), parsed.second()), (9, 0, 0)); + } + + #[test] + fn test_parse_when_today_without_time_defaults_to_seventeen_local() { + let before = Local::now().date_naive(); + let parsed = local_datetime(parse_when("today").unwrap()); + let after = Local::now().date_naive(); + + assert!(parsed.date_naive() == before || parsed.date_naive() == after); + assert_eq!( + (parsed.hour(), parsed.minute(), parsed.second()), + (17, 0, 0) + ); + } + + #[test] + fn test_parse_relative_duration_spelled_out_units() { + for (input, seconds) in [ + ("2 min", 2 * 60), + ("2 mins", 2 * 60), + ("2 minutes", 2 * 60), + ("2 hour", 2 * 60 * 60), + ("2 hours", 2 * 60 * 60), + ("2 day", 2 * 24 * 60 * 60), + ("2 days", 2 * 24 * 60 * 60), + ] { + let before = Local::now().timestamp(); + let parsed = parse_relative_duration(input).unwrap(); + let after = Local::now().timestamp(); + assert!(parsed >= before + seconds, "{input} was too early"); + assert!(parsed <= after + seconds, "{input} was too late"); + } + } + + #[test] + fn test_parse_relative_duration_rejects_invalid_unit() { + let error = parse_relative_duration("3 weeks").unwrap_err(); + assert!(matches!( + error, + crate::error::SlackError::Usage(message) + if message.contains("Invalid duration: '3 weeks'") + )); + } } diff --git a/tests/api_resolve.rs b/tests/api_resolve.rs index 70bf990..5837e9b 100644 --- a/tests/api_resolve.rs +++ b/tests/api_resolve.rs @@ -3,6 +3,21 @@ //! Tests using mockito to simulate Slack API responses. use mockito::{Matcher, Server}; +use slack_cli::{api::SlackClient, auth::TokenSet, error::SlackError}; + +const TOKEN: &str = "xoxp-test-token-12345678901234"; + +fn test_client(base_url: String) -> SlackClient { + let token = TokenSet::new_oauth( + TOKEN.to_string(), + "T12345678".to_string(), + "test".to_string(), + "U00000000".to_string(), + vec![], + ) + .unwrap(); + SlackClient::with_base_url(token, base_url).unwrap() +} // Note: These tests require creating a SlackClient with a custom base URL, // which is not currently supported. For now, we test the ID detection logic @@ -364,3 +379,66 @@ mod mock_api_tests { // Would test: resolve_user("bob") returns "U222222222" after pagination } } + +#[tokio::test] +async fn resolve_channel_finds_name_on_second_page() { + let mut server = Server::new_async().await; + let first = server + .mock("POST", "/conversations.list") + .match_body(Matcher::Exact( + "exclude_archived=false&limit=200&types=public_channel%2Cprivate_channel%2Cmpim%2Cim" + .to_string(), + )) + .with_body( + r#"{"ok":true,"channels":[{"id":"C11111111","name":"random"}],"response_metadata":{"next_cursor":"c2"}}"#, + ) + .expect(1) + .create_async() + .await; + let second = server + .mock("POST", "/conversations.list") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("cursor".into(), "c2".into()), + Matcher::UrlEncoded("limit".into(), "200".into()), + ])) + .with_body( + r#"{"ok":true,"channels":[{"id":"C22222222","name":"general"}],"response_metadata":{"next_cursor":""}}"#, + ) + .expect(1) + .create_async() + .await; + let client = test_client(server.url()); + + assert_eq!( + client.resolve_channel("#general").await.unwrap(), + "C22222222" + ); + first.assert_async().await; + second.assert_async().await; +} + +#[tokio::test] +async fn resolve_channel_empty_next_cursor_stops_with_not_found() { + let mut server = Server::new_async().await; + let list = server + .mock("POST", "/conversations.list") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("limit".into(), "200".into()), + Matcher::UrlEncoded("exclude_archived".into(), "false".into()), + Matcher::UrlEncoded( + "types".into(), + "public_channel,private_channel,mpim,im".into(), + ), + ])) + .with_body( + r#"{"ok":true,"channels":[{"id":"C11111111","name":"random"}],"response_metadata":{"next_cursor":""}}"#, + ) + .expect(1) + .create_async() + .await; + let client = test_client(server.url()); + + let error = client.resolve_channel("missing").await.unwrap_err(); + assert!(matches!(error, SlackError::ChannelNotFound(name) if name == "missing")); + list.assert_async().await; +} From 2fafc6def85f83e21702b595ce6cc2f00d249f0d Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 06:58:26 +1000 Subject: [PATCH 13/22] test(users): CLI tests for users group --- tests/cli_users_ops.rs | 366 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 tests/cli_users_ops.rs diff --git a/tests/cli_users_ops.rs b/tests/cli_users_ops.rs new file mode 100644 index 0000000..51ed126 --- /dev/null +++ b/tests/cli_users_ops.rs @@ -0,0 +1,366 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use mockito::{Matcher, ServerGuard}; +use predicates::prelude::*; +use serde_json::{json, Value}; +use std::path::Path; +use tempfile::TempDir; + +const TOKEN: &str = "xoxp-test-token-123456789"; +const USER_ID: &str = "U12345678"; + +fn command(server: &ServerGuard, temp: &TempDir) -> assert_cmd::Command { + let mut cmd = cargo_bin_cmd!("slack"); + cmd.env("SLACK_API_BASE_URL", server.url()) + .env("SLACK_TOKEN_STORE_PATH", temp.path().join("tokens.json")) + .env("SLACK_TOKEN", TOKEN) + .env_remove("SLACK_WORKSPACE") + .env_remove("SLACK_PLAIN"); + cmd +} + +fn body(fields: &[(&str, &str)]) -> Matcher { + Matcher::AllOf( + fields + .iter() + .map(|(key, value)| Matcher::UrlEncoded((*key).into(), (*value).into())) + .collect(), + ) +} + +fn run_json(server: &ServerGuard, temp: &TempDir, args: &[&str]) -> Value { + let output = command(server, temp).args(args).output().unwrap(); + assert!( + output.status.success(), + "stderr={} stdout={}", + String::from_utf8_lossy(&output.stderr), + String::from_utf8_lossy(&output.stdout) + ); + serde_json::from_slice(&output.stdout).unwrap() +} + +#[tokio::test] +async fn users_list_defaults_to_active_users_and_preserves_metadata_as_json() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/users.list") + .match_body(Matcher::Exact(String::new())) + .with_body( + r#"{"ok":true,"members":[{"id":"U11111111","name":"alice","real_name":"Alice","profile":{"email":"alice@example.com"}},{"id":"U22222222","name":"former","deleted":true}],"response_metadata":{"next_cursor":"next-page","messages":["notice"]}}"#, + ) + .create_async() + .await; + + let output = run_json(&server, &temp, &["users", "list"]); + assert_eq!(output["members"].as_array().unwrap().len(), 1); + assert_eq!(output["members"][0]["id"], "U11111111"); + assert_eq!(output["response_metadata"]["next_cursor"], "next-page"); + list.assert_async().await; +} + +#[tokio::test] +async fn users_list_plain_forwards_limit_and_cursor_and_can_include_deactivated() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/users.list") + .match_body(body(&[("limit", "25"), ("cursor", "page-two")])) + .with_body( + r#"{"ok":true,"members":[{"id":"U11111111","name":"alice","real_name":"Alice","profile":{"email":"alice@example.com"}},{"id":"U22222222","deleted":true}],"response_metadata":{"next_cursor":""}}"#, + ) + .create_async() + .await; + + command(&server, &temp) + .args([ + "--plain", + "users", + "list", + "--include-deactivated", + "--limit", + "25", + "--cursor", + "page-two", + ]) + .assert() + .success() + .stdout("U11111111\talice\tAlice\talice@example.com\nU22222222\t\t\t\n"); + list.assert_async().await; +} + +#[tokio::test] +async fn users_info_by_id_returns_the_full_json_user() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let info = server + .mock("POST", "/users.info") + .match_body(body(&[("user", USER_ID)])) + .with_body(format!( + r#"{{"ok":true,"user":{{"id":"{USER_ID}","name":"alice","real_name":"Alice Example","is_admin":true}}}}"# + )) + .create_async() + .await; + + let output = run_json(&server, &temp, &["users", "info", USER_ID]); + assert_eq!(output["id"], USER_ID); + assert_eq!(output["name"], "alice"); + assert_eq!(output["is_admin"], true); + info.assert_async().await; +} + +#[tokio::test] +async fn users_info_by_name_resolves_with_users_list_and_prints_plain_fields() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/users.list") + .match_body(body(&[("limit", "200")])) + .with_body(format!( + r#"{{"ok":true,"members":[{{"id":"{USER_ID}","name":"alice"}}],"response_metadata":{{"next_cursor":""}}}}"# + )) + .create_async() + .await; + let info = server + .mock("POST", "/users.info") + .match_body(body(&[("user", USER_ID)])) + .with_body(format!( + r#"{{"ok":true,"user":{{"id":"{USER_ID}","name":"alice","real_name":"Alice Example","profile":{{"email":"alice@example.com","title":"Engineer","phone":"555-0100","status_text":"Building","status_emoji":":hammer:"}},"is_admin":true,"is_owner":false,"is_bot":false,"deleted":false,"tz":"Europe/Berlin"}}}}"# + )) + .create_async() + .await; + + command(&server, &temp) + .args(["--plain", "users", "info", "@Alice"]) + .assert() + .success() + .stdout(format!( + "id\t{USER_ID}\nname\talice\nreal_name\tAlice Example\nemail\talice@example.com\ntitle\tEngineer\nphone\t555-0100\nstatus_text\tBuilding\nstatus_emoji\t:hammer:\nis_admin\ttrue\nis_owner\tfalse\nis_bot\tfalse\ndeleted\tfalse\ntz\tEurope/Berlin\n" + )); + list.assert_async().await; + info.assert_async().await; +} + +#[tokio::test] +async fn users_me_combines_auth_test_and_users_info_in_json() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let auth = server + .mock("POST", "/auth.test") + .match_body(Matcher::Exact(String::new())) + .with_body(format!( + r#"{{"ok":true,"url":"https://example.slack.com/","team":"Example","user":"alice","team_id":"T12345678","user_id":"{USER_ID}"}}"# + )) + .create_async() + .await; + let info = server + .mock("POST", "/users.info") + .match_body(body(&[("user", USER_ID)])) + .with_body(format!( + r#"{{"ok":true,"user":{{"id":"{USER_ID}","name":"alice"}}}}"# + )) + .create_async() + .await; + + let output = run_json(&server, &temp, &["users", "me"]); + assert_eq!(output["user"]["id"], USER_ID); + assert_eq!( + output["auth"], + json!({ + "team_id": "T12345678", + "team": "Example", + "url": "https://example.slack.com/" + }) + ); + auth.assert_async().await; + info.assert_async().await; +} + +#[tokio::test] +async fn users_me_plain_prints_identity_and_email() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let auth = server + .mock("POST", "/auth.test") + .with_body(format!( + r#"{{"ok":true,"url":"https://example.slack.com/","team":"Example","user":"alice","team_id":"T12345678","user_id":"{USER_ID}"}}"# + )) + .create_async() + .await; + let info = server + .mock("POST", "/users.info") + .match_body(body(&[("user", USER_ID)])) + .with_body(format!( + r#"{{"ok":true,"user":{{"id":"{USER_ID}","name":"alice","real_name":"Alice Example","profile":{{"email":"alice@example.com"}}}}}}"# + )) + .create_async() + .await; + + command(&server, &temp) + .args(["--plain", "users", "me"]) + .assert() + .success() + .stdout(format!( + "id\t{USER_ID}\nname\talice\nreal_name\tAlice Example\nteam_id\tT12345678\nteam\tExample\nemail\talice@example.com\n" + )); + auth.assert_async().await; + info.assert_async().await; +} + +#[tokio::test] +async fn users_export_paginates_filters_and_writes_escaped_csv() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let first = server + .mock("POST", "/users.list") + .match_body(body(&[("limit", "200")])) + .with_body( + r#"{"ok":true,"members":[{"id":"U11111111","name":"alice,ops","real_name":"Alice \"A\"","profile":{"email":"alice,ops@example.com"},"is_admin":true},{"id":"U22222222","name":"former","deleted":true}],"response_metadata":{"next_cursor":"more"}}"#, + ) + .create_async() + .await; + let second = server + .mock("POST", "/users.list") + .match_body(body(&[("limit", "200"), ("cursor", "more")])) + .with_body( + r#"{"ok":true,"members":[{"id":"U33333333"}],"response_metadata":{"next_cursor":""}}"#, + ) + .create_async() + .await; + let output_path = temp.path().join("users.csv"); + + command(&server, &temp) + .args(["users", "export", "--output", output_path.to_str().unwrap()]) + .assert() + .success() + .stderr(predicate::str::contains(format!( + "Exported 2 users to {}", + output_path.display() + ))); + assert_eq!( + std::fs::read_to_string(&output_path).unwrap(), + "id,name,real_name,email,is_admin\nU11111111,\"alice,ops\",\"Alice \"\"A\"\"\",\"alice,ops@example.com\",true\nU33333333,,,,false\n" + ); + first.assert_async().await; + second.assert_async().await; +} + +#[tokio::test] +async fn users_export_can_include_deactivated_users_on_stdout() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/users.list") + .match_body(body(&[("limit", "200")])) + .with_body( + r#"{"ok":true,"members":[{"id":"U22222222","name":"former","deleted":true}],"response_metadata":{}}"#, + ) + .create_async() + .await; + + command(&server, &temp) + .args(["users", "export", "--include-deactivated"]) + .assert() + .success() + .stdout("id,name,real_name,email,is_admin\nU22222222,former,,,false\n"); + list.assert_async().await; +} + +#[tokio::test] +async fn users_errors_cover_not_found_usage_and_missing_auth_without_extra_io() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/users.list") + .match_body(body(&[("limit", "200")])) + .with_body(r#"{"ok":true,"members":[],"response_metadata":{}}"#) + .create_async() + .await; + command(&server, &temp) + .args(["users", "info", "@missing"]) + .assert() + .code(1) + .stdout(predicate::str::contains("\"code\": \"user_not_found\"")); + list.assert_async().await; + + command(&server, &temp) + .args(["users", "info"]) + .assert() + .code(2) + .stderr(predicate::str::contains("required arguments")); + + let no_io = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + let mut missing_auth = command(&server, &temp); + missing_auth.env_remove("SLACK_TOKEN"); + missing_auth + .env("SLACK_TOKEN_STORE_PATH", temp.path().join("empty.json")) + .args(["users", "list"]) + .assert() + .code(1) + .stdout(predicate::str::contains("\"code\": \"auth_required\"")); + no_io.assert_async().await; +} + +fn write_workspace_store(path: &Path) { + let data = json!({ + "tokens": { + "T_WORK": { + "token_type": "user_o_auth", + "access_token": TOKEN, + "team_id": "T_WORK", + "team_name": "Work", + "team_domain": "work", + "user_id": USER_ID, + "created_at": "2024-01-01T00:00:00Z", + "scopes": [] + } + }, + "default": "T_WORK", + "workspaces": ["T_WORK"] + }); + std::fs::write(path, data.to_string()).unwrap(); +} + +#[tokio::test] +async fn users_support_workspace_tokens_and_reject_invalid_token_overrides() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store = temp.path().join("stored.json"); + write_workspace_store(&store); + let list = server + .mock("POST", "/users.list") + .match_body(Matcher::Exact(String::new())) + .with_body(r#"{"ok":true,"members":[],"response_metadata":{}}"#) + .create_async() + .await; + let mut stored = command(&server, &temp); + stored + .env_remove("SLACK_TOKEN") + .env("SLACK_TOKEN_STORE_PATH", &store) + .args(["--workspace", "work", "users", "list"]) + .assert() + .success(); + list.assert_async().await; + + for token in ["invalid-token", "xoxc-browser-token-123456789"] { + command(&server, &temp) + .args(["--token", token, "users", "list"]) + .assert() + .code(1) + .stdout(predicate::str::contains("\"code\": \"invalid_token\"")); + } + + let mut missing_workspace = command(&server, &temp); + missing_workspace + .env_remove("SLACK_TOKEN") + .env("SLACK_TOKEN_STORE_PATH", &store) + .args(["--workspace", "unknown", "users", "list"]) + .assert() + .code(1) + .stdout(predicate::str::contains( + "\"code\": \"workspace_not_found\"", + )); +} From 92c20c91927e67d9100fcdaed9e5734847b0c391 Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 06:59:06 +1000 Subject: [PATCH 14/22] test(edge-api): Tests for the Edge API client --- tests/api_edge.rs | 233 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 tests/api_edge.rs diff --git a/tests/api_edge.rs b/tests/api_edge.rs new file mode 100644 index 0000000..c517f51 --- /dev/null +++ b/tests/api_edge.rs @@ -0,0 +1,233 @@ +use mockito::{Matcher, Server, ServerGuard}; +use serde::Deserialize; +use serde_json::{json, Value}; +use slack_cli::api::EdgeClient; +use slack_cli::auth::TokenSet; +use slack_cli::error::SlackError; + +const XOXC_TOKEN: &str = "xoxc-test-token-1234567890"; +const XOXD_COOKIE: &str = "xoxd-test-cookie-value"; +const TEAM_ID: &str = "T123EDGE"; + +fn browser_token() -> TokenSet { + TokenSet::new_browser( + XOXC_TOKEN.to_string(), + XOXD_COOKIE.to_string(), + TEAM_ID.to_string(), + "Edge Workspace".to_string(), + "U123EDGE".to_string(), + ) + .unwrap() +} + +fn edge_client(server: &ServerGuard) -> EdgeClient { + EdgeClient::with_base_url(browser_token(), server.url()).unwrap() +} + +fn authenticated_mock(server: &mut ServerGuard, endpoint: &str, body: Value) -> mockito::Mock { + server + .mock("POST", format!("/{TEAM_ID}/{endpoint}").as_str()) + .match_header("authorization", format!("Bearer {XOXC_TOKEN}").as_str()) + .match_header("cookie", format!("d={XOXD_COOKIE}").as_str()) + .match_header("content-type", Matcher::Regex("application/json.*".into())) + .match_body(Matcher::Json(body)) +} + +#[tokio::test] +async fn client_boot_posts_authenticated_empty_json_and_deserializes_response() { + let mut server = Server::new_async().await; + let request = authenticated_mock(&mut server, "client.boot", json!({})) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "ok": true, + "self": {"id":"U123EDGE","name":"alice","real_name":"Alice Example"}, + "team": {"id":"T123EDGE","name":"Edge Workspace","domain":"edge-test"} + }"#, + ) + .create_async() + .await; + + let response = edge_client(&server).client_boot().await.unwrap(); + let user = response.self_user.unwrap(); + assert_eq!(user.id, "U123EDGE"); + assert_eq!(user.name.as_deref(), Some("alice")); + assert_eq!(user.real_name.as_deref(), Some("Alice Example")); + let team = response.team.unwrap(); + assert_eq!(team.id, TEAM_ID); + assert_eq!(team.name.as_deref(), Some("Edge Workspace")); + assert_eq!(team.domain.as_deref(), Some("edge-test")); + request.assert_async().await; +} + +#[tokio::test] +async fn conversations_view_posts_channel_and_deserializes_all_channel_fields() { + let mut server = Server::new_async().await; + let request = authenticated_mock( + &mut server, + "conversations.view", + json!({"channel": "C123CHANNEL"}), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "ok": true, + "channel": { + "id":"C123CHANNEL", + "name":"private-dev", + "is_channel":true, + "is_group":false, + "is_im":false, + "is_mpim":false, + "is_private":true, + "is_member":true + } + }"#, + ) + .create_async() + .await; + + let response = edge_client(&server) + .conversations_view("C123CHANNEL") + .await + .unwrap(); + let channel = response.channel.unwrap(); + assert_eq!(channel.id, "C123CHANNEL"); + assert_eq!(channel.name.as_deref(), Some("private-dev")); + assert_eq!(channel.is_channel, Some(true)); + assert_eq!(channel.is_group, Some(false)); + assert_eq!(channel.is_im, Some(false)); + assert_eq!(channel.is_mpim, Some(false)); + assert_eq!(channel.is_private, Some(true)); + assert_eq!(channel.is_member, Some(true)); + request.assert_async().await; +} + +#[tokio::test] +async fn search_channels_posts_query_and_limit_and_deserializes_results() { + let mut server = Server::new_async().await; + let request = authenticated_mock( + &mut server, + "channels.search", + json!({"query": "rust", "count": 25}), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "ok": true, + "channels": [ + {"id":"C123RUST","name":"rust","is_private":false,"num_members":42} + ] + }"#, + ) + .create_async() + .await; + + let response = edge_client(&server) + .search_channels("rust", 25) + .await + .unwrap(); + let channels = response.channels.unwrap(); + assert_eq!(channels.len(), 1); + assert_eq!(channels[0].id, "C123RUST"); + assert_eq!(channels[0].name.as_deref(), Some("rust")); + assert_eq!(channels[0].is_private, Some(false)); + assert_eq!(channels[0].num_members, Some(42)); + request.assert_async().await; +} + +#[derive(Debug, Deserialize)] +struct GenericResponse { + result: String, + total: u32, +} + +#[tokio::test] +async fn generic_request_posts_params_and_deserializes_typed_response() { + let mut server = Server::new_async().await; + let request = authenticated_mock( + &mut server, + "custom.method", + json!({"query":"hello", "options":{"include_archived":true}}), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true,"result":"matched","total":3}"#) + .create_async() + .await; + + let response: GenericResponse = edge_client(&server) + .request( + "custom.method", + &json!({"query":"hello", "options":{"include_archived":true}}), + ) + .await + .unwrap(); + assert_eq!(response.result, "matched"); + assert_eq!(response.total, 3); + request.assert_async().await; +} + +#[tokio::test] +async fn ok_false_maps_to_api_error() { + let mut server = Server::new_async().await; + let request = authenticated_mock(&mut server, "client.boot", json!({})) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":false,"error":"invalid_auth"}"#) + .create_async() + .await; + + let error = edge_client(&server).client_boot().await.unwrap_err(); + match error { + SlackError::Api { error, detail } => { + assert_eq!(error, "invalid_auth"); + assert_eq!(detail, None); + } + other => panic!("expected SlackError::Api, got {other:?}"), + } + request.assert_async().await; +} + +#[tokio::test] +async fn non_json_response_maps_to_network_error() { + let mut server = Server::new_async().await; + let request = authenticated_mock(&mut server, "broken.method", json!({"key":"value"})) + .with_status(200) + .with_header("content-type", "text/html") + .with_body("<html>not json</html>") + .create_async() + .await; + + let error = edge_client(&server) + .request::<Value, _>("broken.method", &json!({"key":"value"})) + .await + .unwrap_err(); + assert!(matches!(error, SlackError::Network(_)), "got {error:?}"); + request.assert_async().await; +} + +#[tokio::test] +async fn non_success_http_status_with_non_edge_body_maps_to_network_error() { + let mut server = Server::new_async().await; + let request = authenticated_mock( + &mut server, + "channels.search", + json!({"query":"rust","count":10}), + ) + .with_status(503) + .with_header("content-type", "application/json") + .with_body(r#"{"message":"service unavailable"}"#) + .create_async() + .await; + + let error = edge_client(&server) + .search_channels("rust", 10) + .await + .unwrap_err(); + assert!(matches!(error, SlackError::Network(_)), "got {error:?}"); + request.assert_async().await; +} From 6f4d8b209fa986b57fc1aaaad137a294eff22902 Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 07:01:47 +1000 Subject: [PATCH 15/22] test(reminders-cli): CLI tests for reminders group --- tests/cli_reminders_ops.rs | 337 +++++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 tests/cli_reminders_ops.rs diff --git a/tests/cli_reminders_ops.rs b/tests/cli_reminders_ops.rs new file mode 100644 index 0000000..ff13b7c --- /dev/null +++ b/tests/cli_reminders_ops.rs @@ -0,0 +1,337 @@ +use std::path::{Path, PathBuf}; + +use assert_cmd::cargo::cargo_bin_cmd; +use assert_cmd::Command; +use mockito::{Matcher, ServerGuard}; +use predicates::prelude::*; +use serde_json::{json, Value}; +use tempfile::TempDir; + +const TOKEN: &str = "xoxp-test-token-123456789"; +const STORED_TOKEN: &str = "xoxp-workspace-token-123456789"; + +fn isolated_command(server: &ServerGuard, store_path: &Path) -> Command { + let mut cmd = cargo_bin_cmd!("slack"); + cmd.env("SLACK_API_BASE_URL", server.url()) + .env("SLACK_TOKEN_STORE_PATH", store_path) + .env_remove("SLACK_TOKEN") + .env_remove("SLACK_WORKSPACE") + .env_remove("SLACK_PLAIN"); + cmd +} + +fn command(server: &ServerGuard, temp: &TempDir) -> Command { + let mut cmd = isolated_command(server, &temp.path().join("tokens.json")); + cmd.env("SLACK_TOKEN", TOKEN); + cmd +} + +fn write_workspace_store(temp: &TempDir) -> PathBuf { + let path = temp.path().join("tokens.json"); + let data = json!({ + "tokens": { + "T12345678": { + "token_type": "user_o_auth", + "access_token": STORED_TOKEN, + "team_id": "T12345678", + "team_name": "Workspace One", + "team_domain": "workspace-one", + "user_id": "U12345678", + "created_at": "2024-01-01T00:00:00Z", + "scopes": [] + } + }, + "default": "T12345678", + "workspaces": ["T12345678"] + }); + std::fs::write(&path, data.to_string()).unwrap(); + path +} + +fn numeric_time_body(text: &str) -> Matcher { + Matcher::AllOf(vec![ + Matcher::UrlEncoded("text".into(), text.into()), + Matcher::Regex(r"(?:^|&)time=-?[0-9]+(?:&|$)".into()), + ]) +} + +#[tokio::test] +async fn reminders_list_outputs_json_and_plain_rows() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let response = r#"{"ok":true,"reminders":[{"id":"Rm1","creator":"U1","user":"U2","text":"Review PR","time":1893456000},{"id":"Rm2","text":"Finished task","time":1893457000,"complete_ts":1893458000}]}"#; + + let json_list = server + .mock("POST", "/reminders.list") + .match_body(Matcher::Exact(String::new())) + .with_body(response) + .create_async() + .await; + let output = command(&server, &temp) + .args(["reminders", "list"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let reminders: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(reminders.as_array().unwrap().len(), 2); + assert_eq!(reminders[0]["id"], "Rm1"); + assert_eq!(reminders[0]["text"], "Review PR"); + assert_eq!(reminders[1]["complete_ts"], 1_893_458_000_i64); + json_list.assert_async().await; + + let plain_list = server + .mock("POST", "/reminders.list") + .match_body(Matcher::Exact(String::new())) + .with_body(response) + .create_async() + .await; + command(&server, &temp) + .args(["--plain", "reminders", "list"]) + .assert() + .success() + .stdout("Rm1\t1893456000\tpending\tReview PR\nRm2\t1893457000\tcomplete\tFinished task\n"); + plain_list.assert_async().await; +} + +#[tokio::test] +async fn reminders_list_handles_empty_json_and_plain_lists() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + + let empty_json = server + .mock("POST", "/reminders.list") + .match_body(Matcher::Exact(String::new())) + .with_body(r#"{"ok":true,"reminders":[]}"#) + .create_async() + .await; + command(&server, &temp) + .args(["reminders", "list"]) + .assert() + .success() + .stdout("[]\n"); + empty_json.assert_async().await; + + let empty_plain = server + .mock("POST", "/reminders.list") + .match_body(Matcher::Exact(String::new())) + .with_body(r#"{"ok":true,"reminders":[]}"#) + .create_async() + .await; + command(&server, &temp) + .args(["reminders", "list", "--plain"]) + .assert() + .success() + .stdout("No reminders\n"); + empty_plain.assert_async().await; +} + +#[tokio::test] +async fn reminders_plain_list_defaults_missing_optional_fields() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let list = server + .mock("POST", "/reminders.list") + .with_body(r#"{"ok":true,"reminders":[{"id":"Rm1"}]}"#) + .create_async() + .await; + + command(&server, &temp) + .args(["--plain", "reminders", "list"]) + .assert() + .success() + .stdout("Rm1\t0\tpending\t\n"); + list.assert_async().await; +} + +#[tokio::test] +async fn reminders_add_relative_time_posts_text_and_numeric_epoch() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let add = server + .mock("POST", "/reminders.add") + .match_body(numeric_time_body("Review PR")) + .with_body(r#"{"ok":true,"reminder":{"id":"Rm1"}}"#) + .create_async() + .await; + + command(&server, &temp) + .args(["reminders", "add", "Review PR", "--when", "in 2 hours"]) + .assert() + .success() + .stdout("") + .stderr("Reminder created: Rm1\n"); + add.assert_async().await; +} + +#[tokio::test] +async fn reminders_add_date_posts_text_and_numeric_epoch() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let add = server + .mock("POST", "/reminders.add") + .match_body(numeric_time_body("Christmas")) + .with_body(r#"{"ok":true,"reminder":{"id":"Rm-date"}}"#) + .create_async() + .await; + + command(&server, &temp) + .args(["reminders", "add", "Christmas", "--when", "2024-12-25"]) + .assert() + .success() + .stdout("") + .stderr("Reminder created: Rm-date\n"); + add.assert_async().await; +} + +#[tokio::test] +async fn reminders_complete_and_delete_post_reminder_id() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + + let complete = server + .mock("POST", "/reminders.complete") + .match_body(Matcher::UrlEncoded("reminder".into(), "Rm1".into())) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + command(&server, &temp) + .args(["reminders", "complete", "Rm1"]) + .assert() + .success() + .stdout("") + .stderr("Reminder Rm1 marked as complete\n"); + complete.assert_async().await; + + let delete = server + .mock("POST", "/reminders.delete") + .match_body(Matcher::UrlEncoded("reminder".into(), "Rm1".into())) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + command(&server, &temp) + .args(["reminders", "delete", "Rm1"]) + .assert() + .success() + .stdout("") + .stderr("Reminder Rm1 deleted\n"); + delete.assert_async().await; +} + +#[tokio::test] +async fn reminder_not_found_is_reported_as_an_api_error() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let complete = server + .mock("POST", "/reminders.complete") + .match_body(Matcher::UrlEncoded("reminder".into(), "Rm1".into())) + .with_body(r#"{"ok":false,"error":"reminder_not_found"}"#) + .create_async() + .await; + + let output = command(&server, &temp) + .args(["reminders", "complete", "Rm1"]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + serde_json::from_slice::<Value>(&output.stdout).unwrap(), + json!({ + "error": true, + "code": "api_error", + "message": "Slack API error: reminder_not_found", + "detail": null + }) + ); + complete.assert_async().await; +} + +#[tokio::test] +async fn invalid_when_fails_before_sending_an_add_request() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let no_add = server + .mock("POST", "/reminders.add") + .expect(0) + .create_async() + .await; + + command(&server, &temp) + .args(["reminders", "add", "Impossible", "--when", "next whenever"]) + .assert() + .code(2) + .stdout(predicate::str::contains("usage_error")) + .stdout(predicate::str::contains("Could not parse time")); + no_add.assert_async().await; +} + +#[tokio::test] +async fn reminders_select_stored_workspace_authentication() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = write_workspace_store(&temp); + let list = server + .mock("POST", "/reminders.list") + .match_header("authorization", format!("Bearer {STORED_TOKEN}").as_str()) + .with_body(r#"{"ok":true,"reminders":[]}"#) + .create_async() + .await; + + isolated_command(&server, &store_path) + .args(["reminders", "list", "--workspace", "workspace-one"]) + .assert() + .success() + .stdout("[]\n"); + list.assert_async().await; +} + +#[tokio::test] +async fn invalid_tokens_and_unknown_workspaces_fail_before_api_io() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = write_workspace_store(&temp); + let no_requests = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + for token in ["not-a-slack-token", "xoxc-browser-token"] { + let mut cmd = isolated_command(&server, &store_path); + cmd.env("SLACK_TOKEN", token) + .args(["reminders", "list"]) + .assert() + .code(1) + .stdout(predicate::str::contains("invalid_token")); + } + isolated_command(&server, &store_path) + .args(["reminders", "list", "--workspace", "missing"]) + .assert() + .code(1) + .stdout(predicate::str::contains("workspace_not_found")); + no_requests.assert_async().await; +} + +#[tokio::test] +async fn reminders_require_authentication_before_api_io() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let no_requests = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + let mut cmd = command(&server, &temp); + cmd.env_remove("SLACK_TOKEN"); + let output = cmd.args(["reminders", "list"]).output().unwrap(); + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + serde_json::from_slice::<Value>(&output.stdout).unwrap()["code"], + "auth_required" + ); + no_requests.assert_async().await; +} From cda741347163762670e0db0d7c0bcad71f570572 Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 07:02:44 +1000 Subject: [PATCH 16/22] test(status-reactions): CLI tests for status and reactions groups --- tests/cli_reactions_ops.rs | 241 ++++++++++++++++++++++++++ tests/cli_status_ops.rs | 335 +++++++++++++++++++++++++++++++++++++ 2 files changed, 576 insertions(+) create mode 100644 tests/cli_reactions_ops.rs create mode 100644 tests/cli_status_ops.rs diff --git a/tests/cli_reactions_ops.rs b/tests/cli_reactions_ops.rs new file mode 100644 index 0000000..afa136e --- /dev/null +++ b/tests/cli_reactions_ops.rs @@ -0,0 +1,241 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use mockito::{Matcher, ServerGuard}; +use predicates::prelude::*; +use serde_json::{json, Value}; +use tempfile::TempDir; + +const TOKEN: &str = "xoxp-test-token-123456789"; +const CHANNEL: &str = "C123456789"; +const TS: &str = "1234567890.123456"; + +fn command(server: &ServerGuard, temp: &TempDir) -> assert_cmd::Command { + let mut cmd = cargo_bin_cmd!("slack"); + cmd.env("SLACK_API_BASE_URL", server.url()) + .env("SLACK_TOKEN_STORE_PATH", temp.path().join("tokens.json")) + .env("SLACK_TOKEN", TOKEN) + .env_remove("SLACK_WORKSPACE") + .env_remove("SLACK_PLAIN"); + cmd +} + +fn body(fields: &[(&str, &str)]) -> Matcher { + Matcher::AllOf( + fields + .iter() + .map(|(key, value)| Matcher::UrlEncoded((*key).into(), (*value).into())) + .collect(), + ) +} + +async fn channel_resolution(server: &mut ServerGuard) -> mockito::Mock { + server + .mock("POST", "/conversations.list") + .match_body(body(&[ + ("limit", "200"), + ("exclude_archived", "false"), + ("types", "public_channel,private_channel,mpim,im"), + ])) + .with_body(format!( + r#"{{"ok":true,"channels":[{{"id":"{CHANNEL}","name":"general"}}],"response_metadata":{{"next_cursor":""}}}}"# + )) + .create_async() + .await +} + +#[tokio::test] +async fn reactions_add_resolves_channel_and_posts_normalized_emoji() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let resolution = channel_resolution(&mut server).await; + let add = server + .mock("POST", "/reactions.add") + .match_body(body(&[ + ("channel", CHANNEL), + ("timestamp", TS), + ("name", "thumbsup"), + ])) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + command(&server, &temp) + .args(["reactions", "add", "#general", TS, ":thumbsup:"]) + .assert() + .success() + .stdout("") + .stderr("Added :thumbsup:\n"); + resolution.assert_async().await; + add.assert_async().await; +} + +#[tokio::test] +async fn reactions_remove_resolves_channel_and_posts_normalized_emoji() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let resolution = channel_resolution(&mut server).await; + let remove = server + .mock("POST", "/reactions.remove") + .match_body(body(&[ + ("channel", CHANNEL), + ("timestamp", TS), + ("name", "eyes"), + ])) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + command(&server, &temp) + .args(["reactions", "remove", "#general", TS, "eyes:"]) + .assert() + .success() + .stdout("") + .stderr("Removed :eyes:\n"); + resolution.assert_async().await; + remove.assert_async().await; +} + +#[tokio::test] +async fn reactions_list_outputs_json_and_sends_full_form() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let get = server + .mock("POST", "/reactions.get") + .match_body(body(&[ + ("channel", CHANNEL), + ("timestamp", TS), + ("full", "true"), + ])) + .with_body(format!( + r#"{{"ok":true,"message":{{"ts":"{TS}","reactions":[{{"name":"thumbsup","count":2,"users":["U111111111","U222222222"]}}]}}}}"# + )) + .create_async() + .await; + + let output = command(&server, &temp) + .args(["reactions", "list", CHANNEL, TS]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::<Value>(&output.stdout).unwrap(), + json!({ + "reactions": [{ + "name": "thumbsup", + "count": 2, + "users": ["U111111111", "U222222222"], + }] + }) + ); + get.assert_async().await; +} + +#[tokio::test] +async fn reactions_list_plain_formats_each_reaction() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let get = server + .mock("POST", "/reactions.get") + .match_body(body(&[ + ("channel", CHANNEL), + ("timestamp", TS), + ("full", "true"), + ])) + .with_body(format!( + r#"{{"ok":true,"message":{{"ts":"{TS}","reactions":[{{"name":"eyes","count":2,"users":["U111111111","U222222222"]}},{{"name":"heart","count":1,"users":["U333333333"]}}]}}}}"# + )) + .create_async() + .await; + + command(&server, &temp) + .args(["--plain", "reactions", "list", CHANNEL, TS]) + .assert() + .success() + .stdout(":eyes: (2)\tU111111111,U222222222\n:heart: (1)\tU333333333\n"); + get.assert_async().await; +} + +#[tokio::test] +async fn reactions_list_plain_reports_missing_reactions_field() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let get = server + .mock("POST", "/reactions.get") + .with_body(format!(r#"{{"ok":true,"message":{{"ts":"{TS}"}}}}"#)) + .create_async() + .await; + + command(&server, &temp) + .args(["--plain", "reactions", "list", CHANNEL, TS]) + .assert() + .success() + .stdout("No reactions\n"); + get.assert_async().await; +} + +#[tokio::test] +async fn reaction_conflict_errors_exit_as_api_errors() { + for (subcommand, endpoint, error) in [ + ("add", "/reactions.add", "already_reacted"), + ("remove", "/reactions.remove", "no_reaction"), + ] { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let reaction = server + .mock("POST", endpoint) + .match_body(body(&[ + ("channel", CHANNEL), + ("timestamp", TS), + ("name", "thumbsup"), + ])) + .with_body(format!(r#"{{"ok":false,"error":"{error}"}}"#)) + .create_async() + .await; + + command(&server, &temp) + .args(["reactions", subcommand, CHANNEL, TS, "thumbsup"]) + .assert() + .code(1) + .stdout(predicate::str::contains(error)); + reaction.assert_async().await; + } +} + +#[tokio::test] +async fn reactions_reject_invalid_and_unpaired_browser_token_overrides() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let no_request = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + for token in ["not-a-slack-token", "xoxc-browser-token"] { + let mut cmd = command(&server, &temp); + cmd.env_remove("SLACK_TOKEN"); + cmd.args(["--token", token, "reactions", "list", CHANNEL, TS]) + .assert() + .code(1) + .stdout(predicate::str::contains("invalid_token")); + } + no_request.assert_async().await; +} + +#[tokio::test] +async fn reactions_missing_token_is_auth_required_without_api_io() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let no_request = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + let mut cmd = command(&server, &temp); + cmd.env_remove("SLACK_TOKEN"); + cmd.args(["reactions", "list", CHANNEL, TS]) + .assert() + .code(1) + .stdout(predicate::str::contains("auth_required")); + no_request.assert_async().await; +} diff --git a/tests/cli_status_ops.rs b/tests/cli_status_ops.rs new file mode 100644 index 0000000..6c47829 --- /dev/null +++ b/tests/cli_status_ops.rs @@ -0,0 +1,335 @@ +use assert_cmd::cargo::cargo_bin_cmd; +use chrono::{Duration, Local, NaiveTime}; +use mockito::{Matcher, ServerGuard}; +use predicates::prelude::*; +use serde_json::{json, Value}; +use tempfile::TempDir; + +const TOKEN: &str = "xoxp-test-token-123456789"; + +fn command(server: &ServerGuard, temp: &TempDir) -> assert_cmd::Command { + let mut cmd = cargo_bin_cmd!("slack"); + cmd.env("SLACK_API_BASE_URL", server.url()) + .env("SLACK_TOKEN_STORE_PATH", temp.path().join("tokens.json")) + .env("SLACK_TOKEN", TOKEN) + .env_remove("SLACK_WORKSPACE") + .env_remove("SLACK_PLAIN"); + cmd +} + +fn profile(text: &str, emoji: Option<&str>, expiration: i64) -> String { + let mut value = json!({ + "status_text": text, + "status_expiration": expiration, + }); + if let Some(emoji) = emoji { + value["status_emoji"] = json!(emoji); + } + value.to_string() +} + +fn profile_expiration_matcher(text: &str, emoji: Option<&str>, expirations: &[i64]) -> Matcher { + Matcher::AnyOf( + expirations + .iter() + .map(|expiration| { + Matcher::UrlEncoded("profile".into(), profile(text, emoji, *expiration)) + }) + .collect(), + ) +} + +fn end_of_day(days_from_today: i64) -> i64 { + let date = Local::now().date_naive() + Duration::days(days_from_today); + date.and_time(NaiveTime::from_hms_opt(23, 59, 59).unwrap()) + .and_local_timezone(Local) + .single() + .unwrap() + .timestamp() +} + +fn write_workspace_store(temp: &TempDir) { + let data = json!({ + "tokens": { + "T12345678": { + "token_type": "user_o_auth", + "access_token": "xoxp-workspace-token-1234567890", + "team_id": "T12345678", + "team_name": "Workspace One", + "team_domain": "workspace-one", + "user_id": "U12345678", + "created_at": "2024-01-01T00:00:00Z", + "scopes": [] + } + }, + "default": "T12345678", + "workspaces": ["T12345678"] + }); + std::fs::write(temp.path().join("tokens.json"), data.to_string()).unwrap(); +} + +#[tokio::test] +async fn status_get_outputs_json_profile_and_presence() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let profile = server + .mock("POST", "/users.profile.get") + .with_body( + r#"{"ok":true,"profile":{"status_text":"Heads down","status_emoji":":hammer:","status_expiration":1700000000}}"#, + ) + .create_async() + .await; + let presence = server + .mock("POST", "/users.getPresence") + .with_body(r#"{"ok":true,"presence":"away","auto_away":true,"manual_away":false}"#) + .create_async() + .await; + + let output = command(&server, &temp) + .args(["status", "get"]) + .output() + .unwrap(); + assert!(output.status.success()); + assert_eq!( + serde_json::from_slice::<Value>(&output.stdout).unwrap(), + json!({ + "status_text": "Heads down", + "status_emoji": ":hammer:", + "status_expiration": 1700000000_i64, + "presence": "away", + "auto_away": true, + "manual_away": false, + }) + ); + profile.assert_async().await; + presence.assert_async().await; +} + +#[tokio::test] +async fn status_get_plain_prints_all_nonempty_fields_and_away_flags() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let profile = server + .mock("POST", "/users.profile.get") + .with_body( + r#"{"ok":true,"profile":{"status_text":"In a call","status_emoji":":telephone_receiver:","status_expiration":1700000001}}"#, + ) + .create_async() + .await; + let presence = server + .mock("POST", "/users.getPresence") + .with_body(r#"{"ok":true,"presence":"away","auto_away":true,"manual_away":true}"#) + .create_async() + .await; + + command(&server, &temp) + .args(["--plain", "status", "get"]) + .assert() + .success() + .stdout( + "status_text\tIn a call\nstatus_emoji\t:telephone_receiver:\nstatus_expiration\t1700000001\npresence\taway\nauto_away\ttrue\nmanual_away\ttrue\n", + ); + profile.assert_async().await; + presence.assert_async().await; +} + +#[tokio::test] +async fn status_get_plain_omits_empty_fields_and_uses_named_workspace_auth() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + write_workspace_store(&temp); + let profile = server + .mock("POST", "/users.profile.get") + .with_body( + r#"{"ok":true,"profile":{"status_text":"","status_emoji":"","status_expiration":0}}"#, + ) + .create_async() + .await; + let presence = server + .mock("POST", "/users.getPresence") + .with_body(r#"{"ok":true,"presence":"active"}"#) + .create_async() + .await; + + let mut cmd = command(&server, &temp); + cmd.env_remove("SLACK_TOKEN"); + cmd.args(["--workspace", "workspace-one", "--plain", "status", "get"]) + .assert() + .success() + .stdout("presence\tactive\n"); + profile.assert_async().await; + presence.assert_async().await; +} + +#[tokio::test] +async fn status_set_normalizes_emoji_and_supports_documented_expirations() { + for (expires, expirations) in [ + ("1h", { + let now = Local::now().timestamp(); + vec![now + 3599, now + 3600, now + 3601] + }), + ("today", vec![end_of_day(0)]), + ("tomorrow", vec![end_of_day(1)]), + ] { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let set = server + .mock("POST", "/users.profile.set") + .match_body(profile_expiration_matcher( + "Deep work", + Some(":coffee:"), + &expirations, + )) + .with_body(r#"{"ok":true,"profile":{}}"#) + .create_async() + .await; + + command(&server, &temp) + .args([ + "status", + "set", + "Deep work", + "--emoji", + ":coffee:", + "--expires", + expires, + ]) + .assert() + .success() + .stdout("") + .stderr("Status updated\n"); + set.assert_async().await; + } +} + +#[tokio::test] +async fn status_set_supports_custom_minute_and_hour_expirations() { + for (expires, seconds) in [("15m", 15 * 60), ("2h", 2 * 60 * 60)] { + let now = Local::now().timestamp(); + let expirations = [now + seconds - 1, now + seconds, now + seconds + 1]; + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let set = server + .mock("POST", "/users.profile.set") + .match_body(profile_expiration_matcher("Custom", None, &expirations)) + .with_body(r#"{"ok":true,"profile":{}}"#) + .create_async() + .await; + + command(&server, &temp) + .args(["status", "set", "Custom", "--expires", expires]) + .assert() + .success(); + set.assert_async().await; + } +} + +#[tokio::test] +async fn status_clear_posts_empty_profile_with_zero_expiration() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let clear = server + .mock("POST", "/users.profile.set") + .match_body(Matcher::UrlEncoded( + "profile".into(), + json!({ + "status_emoji": "", + "status_expiration": 0, + "status_text": "", + }) + .to_string(), + )) + .with_body(r#"{"ok":true,"profile":{}}"#) + .create_async() + .await; + + command(&server, &temp) + .args(["status", "clear"]) + .assert() + .success() + .stdout("") + .stderr("Status cleared\n"); + clear.assert_async().await; +} + +#[tokio::test] +async fn status_presence_supports_away_and_auto() { + for expected in ["away", "auto"] { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let presence = server + .mock("POST", "/users.setPresence") + .match_body(Matcher::UrlEncoded("presence".into(), expected.into())) + .with_body(r#"{"ok":true}"#) + .create_async() + .await; + + command(&server, &temp) + .args(["status", "presence", expected]) + .assert() + .success() + .stdout("") + .stderr(format!("Presence set to {expected}\n")); + presence.assert_async().await; + } +} + +#[tokio::test] +async fn bad_status_expirations_are_usage_errors_without_api_io() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let no_set = server + .mock("POST", "/users.profile.set") + .expect(0) + .create_async() + .await; + + for expires in ["someday", "xm", "xh"] { + command(&server, &temp) + .args(["status", "set", "Busy", "--expires", expires]) + .assert() + .code(2) + .stdout(predicate::str::contains("usage_error")); + } + no_set.assert_async().await; +} + +#[tokio::test] +async fn status_rejects_invalid_and_unpaired_browser_token_overrides() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let no_request = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + for token in ["not-a-slack-token", "xoxc-browser-token"] { + let mut cmd = command(&server, &temp); + cmd.env_remove("SLACK_TOKEN"); + cmd.args(["--token", token, "status", "get"]) + .assert() + .code(1) + .stdout(predicate::str::contains("invalid_token")); + } + no_request.assert_async().await; +} + +#[tokio::test] +async fn status_missing_token_is_auth_required_without_api_io() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let no_request = server + .mock("POST", Matcher::Any) + .expect(0) + .create_async() + .await; + + let mut cmd = command(&server, &temp); + cmd.env_remove("SLACK_TOKEN"); + cmd.args(["status", "get"]) + .assert() + .code(1) + .stdout(predicate::str::contains("auth_required")); + no_request.assert_async().await; +} From 7da0310d832f9214ae75e27e7d254f1340dc2923 Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 07:09:03 +1000 Subject: [PATCH 17/22] test(oauth): Tests for the OAuth flow --- src/auth/oauth.rs | 315 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 297 insertions(+), 18 deletions(-) diff --git a/src/auth/oauth.rs b/src/auth/oauth.rs index e94bd89..50f1bc0 100644 --- a/src/auth/oauth.rs +++ b/src/auth/oauth.rs @@ -221,30 +221,58 @@ impl OAuthFlow { /// 5. Exchange code for tokens pub fn authorize_manual(&self) -> Result<TokenSet> { let state = Self::generate_state(); - let auth_url = self.build_auth_url(&state)?; + let stdin = io::stdin(); + self.authorize_manual_with(stdin.lock(), &state, |_| Ok(())) + } + + /// Run the manual flow with injectable input and URL handling. + /// + /// The callback is deliberately a no-op in the production wrapper: manual mode prints the + /// authorization URL but never opens a browser automatically. Keeping it injectable lets the + /// flow be exercised without touching a real browser. + fn authorize_manual_with<R, F>( + &self, + mut reader: R, + state: &str, + mut browser_opener: F, + ) -> Result<TokenSet> + where + R: BufRead, + F: FnMut(&str) -> Result<()>, + { + let auth_url = self.build_auth_url(state)?; eprintln!("\n=== Manual OAuth Flow ===\n"); eprintln!("1. Open this URL in your browser:\n"); eprintln!(" {}\n", auth_url); eprintln!("2. Authorize the application"); eprintln!("3. You'll be redirected to a localhost URL (may show an error page)"); - eprintln!("4. Copy the FULL URL from your browser's address bar"); + eprintln!("4. Copy the FULL URL from your browser's address bar (or just the code)"); eprintln!("\nPaste the redirect URL here:"); + browser_opener(&auth_url)?; io::stdout().flush().map_err(SlackError::Io)?; - let mut redirect_url = String::new(); - io::stdin() - .lock() - .read_line(&mut redirect_url) - .map_err(SlackError::Io)?; + let mut input = String::new(); + reader.read_line(&mut input).map_err(SlackError::Io)?; - let redirect_url = redirect_url.trim(); - if redirect_url.is_empty() { - return Err(SlackError::Usage("No URL provided".into())); + let input = input.trim(); + if input.is_empty() { + return Err(SlackError::Usage("No URL or code provided".into())); } - // Parse the URL and extract code and state + // Slack may display the code separately when localhost cannot be reached. Accept that + // value directly; full redirect URLs retain state verification and OAuth error handling. + let code = if input.starts_with("http://") || input.starts_with("https://") { + Self::code_from_redirect_url(input, state)? + } else { + input.to_string() + }; + + self.exchange_code(&code) + } + + fn code_from_redirect_url(redirect_url: &str, expected_state: &str) -> Result<String> { let url = Url::parse(redirect_url) .map_err(|e| SlackError::Usage(format!("Invalid URL: {}", e)))?; @@ -270,9 +298,8 @@ impl OAuthFlow { } } - // Verify state match returned_state { - Some(ref s) if s == &state => {} + Some(ref state) if state == expected_state => {} Some(_) => { return Err(SlackError::Other( "State mismatch - possible CSRF attack".into(), @@ -285,11 +312,7 @@ impl OAuthFlow { } } - let code = - code.ok_or_else(|| SlackError::Usage("No authorization code in redirect URL".into()))?; - - // Exchange the code for tokens - self.exchange_code(&code) + code.ok_or_else(|| SlackError::Usage("No authorization code in redirect URL".into())) } /// Exchange authorization code for access tokens @@ -716,4 +739,260 @@ mod tests { _ => panic!("Expected SlackError::Other"), } } + + fn test_flow(token_url: String, port: u16) -> OAuthFlow { + OAuthFlow::new( + OAuthConfig::new("client-id".into(), "client-secret".into()) + .with_port(port) + .with_token_url(token_url), + ) + } + + #[test] + fn exchange_code_posts_form_and_maps_success() { + use mockito::Matcher; + + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/oauth.v2.access") + .match_header("content-type", "application/x-www-form-urlencoded") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("client_id".into(), "client-id".into()), + Matcher::UrlEncoded("client_secret".into(), "client-secret".into()), + Matcher::UrlEncoded("code".into(), "oauth-code".into()), + Matcher::UrlEncoded( + "redirect_uri".into(), + "http://localhost:9123/callback".into(), + ), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "ok": true, + "access_token": "xoxb-test-access-token", + "scope": "channels:read,chat:write", + "bot_user_id": "UBOT", + "team": {"id": "TTEAM", "name": "OAuth Team"} + }"#, + ) + .create(); + let flow = test_flow(format!("{}/oauth.v2.access", server.url()), 9123); + + let token = flow.exchange_code("oauth-code").unwrap(); + + mock.assert(); + assert_eq!(token.access_token, "xoxb-test-access-token"); + assert_eq!(token.team_id, "TTEAM"); + assert_eq!(token.team_name, "OAuth Team"); + assert_eq!(token.user_id, "UBOT"); + assert_eq!(token.scopes, ["channels:read", "chat:write"]); + } + + #[test] + fn exchange_code_maps_slack_error() { + use mockito::Matcher; + + let mut server = mockito::Server::new(); + let mock = server + .mock("POST", "/oauth.v2.access") + .match_body(Matcher::UrlEncoded("code".into(), "bad-code".into())) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":false,"error":"invalid_code"}"#) + .create(); + let flow = test_flow(format!("{}/oauth.v2.access", server.url()), 8765); + + let error = flow.exchange_code("bad-code").unwrap_err(); + + mock.assert(); + match error { + SlackError::Api { error, detail } => { + assert_eq!(error, "invalid_code"); + assert_eq!(detail, None); + } + other => panic!("expected API error, got {other:?}"), + } + } + + #[test] + fn malformed_oauth_response_uses_safe_error() { + let error = parse_oauth_response(&serde_json::json!({"unexpected": true})).unwrap_err(); + match error { + SlackError::Api { error, detail } => { + assert_eq!(error, "unknown_error"); + assert_eq!(detail, None); + } + other => panic!("expected API error, got {other:?}"), + } + } + + fn unused_local_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() + } + + fn request_callback(path: &str, expected_state: &str) -> (u16, String, Result<String>) { + let port = unused_local_port(); + let (tx, rx) = mpsc::channel(); + let expected_state = expected_state.to_string(); + let handle = thread::spawn(move || start_callback_server(port, &expected_state, tx)); + let url = format!("http://127.0.0.1:{port}{path}"); + let client = reqwest::blocking::Client::new(); + + let response = (0..20) + .find_map(|_| match client.get(&url).send() { + Ok(response) => Some(response), + Err(_) => { + thread::sleep(Duration::from_millis(10)); + None + } + }) + .expect("callback server did not start"); + let status = response.status().as_u16(); + let body = response.text().unwrap(); + let callback = rx.recv_timeout(Duration::from_secs(1)).unwrap(); + handle.join().unwrap().unwrap(); + + (status, body, callback) + } + + #[test] + fn callback_server_returns_code_and_success_html() { + let (status, body, callback) = + request_callback("/callback?code=code-123&state=expected", "expected"); + + assert_eq!(status, 200); + assert!(body.contains("Authorization Successful")); + assert_eq!(callback.unwrap(), "code-123"); + } + + #[test] + fn callback_server_rejects_state_mismatch_with_html() { + let (status, body, callback) = + request_callback("/callback?code=code-123&state=wrong", "expected"); + + assert_eq!(status, 400); + assert!(body.contains("State mismatch")); + match callback.unwrap_err() { + SlackError::Other(message) => assert_eq!(message, "State mismatch"), + other => panic!("expected state error, got {other:?}"), + } + } + + #[test] + fn callback_server_returns_oauth_denial_and_html() { + let (status, body, callback) = request_callback( + "/callback?error=access_denied&error_description=User%20declined", + "expected", + ); + + assert_eq!(status, 400); + assert!(body.contains("Authorization failed")); + match callback.unwrap_err() { + SlackError::Api { error, detail } => { + assert_eq!(error, "access_denied"); + assert_eq!(detail.as_deref(), Some("User declined")); + } + other => panic!("expected API error, got {other:?}"), + } + } + + #[test] + fn callback_server_reports_bind_failure() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (tx, _rx) = mpsc::channel(); + + let error = start_callback_server(port, "state", tx).unwrap_err(); + + assert!(error + .to_string() + .contains("Failed to start callback server")); + } + + fn oauth_success_mock(server: &mut mockito::Server, expected_code: &str) -> mockito::Mock { + use mockito::Matcher; + + server + .mock("POST", "/oauth.v2.access") + .match_body(Matcher::UrlEncoded( + "code".into(), + expected_code.to_string(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{ + "ok": true, + "access_token": "xoxp-manual-access-token", + "scope": "users:read", + "authed_user": {"id": "UMANUAL"}, + "team": {"id": "TMANUAL", "name": "Manual Team"} + }"#, + ) + .create() + } + + #[test] + fn manual_authorization_accepts_pasted_code_without_opening_browser() { + let mut server = mockito::Server::new(); + let mock = oauth_success_mock(&mut server, "pasted-code"); + let flow = test_flow(format!("{}/oauth.v2.access", server.url()), 8765); + let input = io::Cursor::new(b"pasted-code\n"); + let mut presented_url = None; + + let token = flow + .authorize_manual_with(input, "known-state", |url| { + presented_url = Some(url.to_string()); + Ok(()) + }) + .unwrap(); + + mock.assert(); + assert_eq!(token.access_token, "xoxp-manual-access-token"); + assert!(presented_url.unwrap().contains("state=known-state")); + } + + #[test] + fn manual_authorization_accepts_full_redirect_url() { + let mut server = mockito::Server::new(); + let mock = oauth_success_mock(&mut server, "url-code"); + let flow = test_flow(format!("{}/oauth.v2.access", server.url()), 8765); + let input = + io::Cursor::new(b"http://localhost:8765/callback?code=url-code&state=known-state\n"); + + let token = flow + .authorize_manual_with(input, "known-state", |_| Ok(())) + .unwrap(); + + mock.assert(); + assert_eq!(token.team_id, "TMANUAL"); + assert_eq!(token.user_id, "UMANUAL"); + } + + #[test] + fn manual_redirect_validation_errors_are_preserved() { + let missing_state = + OAuthFlow::code_from_redirect_url("http://localhost/callback?code=code", "expected") + .unwrap_err(); + assert!(matches!(missing_state, SlackError::Usage(_))); + + let wrong_state = OAuthFlow::code_from_redirect_url( + "http://localhost/callback?code=code&state=wrong", + "expected", + ) + .unwrap_err(); + assert!(matches!(wrong_state, SlackError::Other(_))); + + let denied = OAuthFlow::code_from_redirect_url( + "http://localhost/callback?error=access_denied&error_description=Nope&state=expected", + "expected", + ) + .unwrap_err(); + assert!(matches!(denied, SlackError::Api { .. })); + } } From b97d4bef8ca9267a7ffdd4c219a0e239ebe09d5b Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 07:13:29 +1000 Subject: [PATCH 18/22] test(extract-fixtures): Fixture-based tests for browser/desktop credential extraction parsers --- src/auth/extract/chromium.rs | 182 ++++++++++++++++++++- src/auth/extract/cookies.rs | 73 +++++++++ src/auth/extract/crypto.rs | 130 +++++++++++---- tests/fixtures/extract/local_config.log | Bin 0 -> 127 bytes tests/fixtures/extract/minimal_sstable.ldb | Bin 0 -> 180 bytes 5 files changed, 351 insertions(+), 34 deletions(-) create mode 100644 tests/fixtures/extract/local_config.log create mode 100644 tests/fixtures/extract/minimal_sstable.ldb diff --git a/src/auth/extract/chromium.rs b/src/auth/extract/chromium.rs index 68f28f6..4468298 100644 --- a/src/auth/extract/chromium.rs +++ b/src/auth/extract/chromium.rs @@ -740,6 +740,15 @@ mod tests { let truncated = [0x80]; // continuation bit set, no follow-up byte let mut pos = 0; assert_eq!(read_varint(&truncated, &mut pos), None); + + let overflow = [0x80; 10]; + let mut pos = 0; + assert_eq!(read_varint(&overflow, &mut pos), None); + + let mut pos = 0; + assert_eq!(read_block_handle(&[7, 9], &mut pos), Some((7, 9))); + let mut pos = 0; + assert_eq!(read_block_handle(&[7], &mut pos), None); } #[test] @@ -750,11 +759,174 @@ mod tests { } #[test] - fn non_sstable_ldb_returns_none() { + fn fixture_log_and_sstable_extract_the_same_team() { + const LOG: &[u8] = include_bytes!("../../../tests/fixtures/extract/local_config.log"); + const SSTABLE: &[u8] = + include_bytes!("../../../tests/fixtures/extract/minimal_sstable.ldb"); + const PAYLOAD: &[u8] = b"localConfig_v2{\"teams\":{\"T1234567\":{\"name\":\"Fixture\",\"domain\":\"fixture\",\"token\":\"xoxc-fixture-1234567890\"}}}"; + + let mut decoded = sstable_data_bytes(SSTABLE).expect("valid fixture sstable"); + assert_eq!(decoded.pop(), Some(b'\n')); + assert_eq!(decoded, PAYLOAD); + + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("000001.log"), LOG).unwrap(); + fs::write(dir.path().join("000002.LDB"), SSTABLE).unwrap(); + fs::write(dir.path().join("CURRENT"), b"ignored xoxc-not-read-1234").unwrap(); + fs::create_dir(dir.path().join("000003.log")).unwrap(); + + let tokens = extract_tokens_from_leveldb(dir.path()).unwrap(); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].team_id.as_deref(), Some("T1234567")); + assert_eq!(tokens[0].domain.as_deref(), Some("fixture")); + assert_eq!(tokens[0].name.as_deref(), Some("Fixture")); + } + + #[test] + fn reads_uncompressed_snappy_and_unknown_block_types() { + let plain = b"block bytes"; + let mut uncompressed = plain.to_vec(); + uncompressed.push(0); + assert_eq!(read_block(&uncompressed, 0, plain.len()).unwrap(), plain); + + let compressed = snap::raw::Encoder::new().compress_vec(plain).unwrap(); + let mut snappy_block = compressed.clone(); + snappy_block.push(1); + assert_eq!( + read_block(&snappy_block, 0, compressed.len()).unwrap(), + plain + ); + + let mut unknown = plain.to_vec(); + unknown.push(7); + assert_eq!(read_block(&unknown, 0, plain.len()).unwrap(), plain); + + assert!(read_block(&[1, 2, 3], 0, 3).is_none()); + assert!(read_block(&[1, 2, 3], usize::MAX, 2).is_none()); + assert!(read_block(&[0xff, 1], 0, 1).is_none()); + } + + #[test] + fn whole_file_snappy_fallback_is_scanned() { + let payload = b"noise xoxc-whole-snappy-1234567890 trailer"; + let compressed = snap::raw::Encoder::new().compress_vec(payload).unwrap(); + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("000010.ldb"), compressed).unwrap(); + + let tokens = extract_tokens_from_leveldb(dir.path()).unwrap(); + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].xoxc, "xoxc-whole-snappy-1234567890"); + } + + #[test] + fn parses_index_handles_and_rejects_malformed_entries() { + // shared=0, key delta="k", value=BlockHandle(offset=7,size=9), + // followed by one restart offset and num_restarts=1. + let valid = [0, 1, 2, b'k', 7, 9, 0, 0, 0, 0, 1, 0, 0, 0]; + assert_eq!(parse_index_handles(&valid), vec![(7, 9)]); + assert!(parse_index_handles(&[]).is_empty()); + assert!(parse_index_handles(&u32::MAX.to_le_bytes()).is_empty()); + + let trailer = [0, 0, 0, 0, 1, 0, 0, 0]; + for entries in [ + vec![0x80], + vec![0, 0x80], + vec![0, 1, 0x80], + vec![0, 9, 0], + vec![0, 0, 9], + vec![0, 0, 1, 0x80], + ] { + let mut block = entries; + block.extend_from_slice(&trailer); + assert!(parse_index_handles(&block).is_empty()); + } + } + + #[test] + fn malformed_sstable_footers_and_handles_fail_safely() { assert!(sstable_data_bytes(b"too short").is_none()); - let mut buf = vec![0u8; 64]; - // Wrong magic tail. - buf.extend_from_slice(&[0u8; 8]); - assert!(sstable_data_bytes(&buf).is_none()); + let mut wrong_magic = vec![0u8; 64]; + wrong_magic.extend_from_slice(&[0u8; 8]); + assert!(sstable_data_bytes(&wrong_magic).is_none()); + + let mut truncated_handle = vec![0u8; 48]; + truncated_handle[..10].fill(0x80); + truncated_handle[40..].copy_from_slice(&SSTABLE_MAGIC); + assert!(sstable_data_bytes(&truncated_handle).is_none()); + + // Valid footer handles whose index offset points outside the file. + let mut bad_index = vec![0u8; 48]; + bad_index[0..4].copy_from_slice(&[0, 0, 127, 1]); + bad_index[40..].copy_from_slice(&SSTABLE_MAGIC); + assert!(sstable_data_bytes(&bad_index).is_none()); + } + + #[test] + fn strict_and_fallback_parsers_cover_partial_records() { + assert!(parse_local_config("localConfig_v2 no object").is_empty()); + assert!(parse_local_config("localConfig_v2{broken}").is_empty()); + assert!(parse_local_config( + "localConfig_v2{\"teams\":{\"T1234567\":{\"token\":\"xoxp-not-client\"},\"T7654321\":{}}}}" + ) + .is_empty()); + + let bare = "localConfig_v2{\"workspace\":{\"id\":\"TEXPLICIT\",\"token\":\"xoxc-bare-map-123456\"}}"; + let tokens = parse_local_config(bare); + assert_eq!(tokens[0].team_id.as_deref(), Some("TEXPLICIT")); + assert_eq!(tokens[0].domain, None); + + assert!(scan_teams("xoxc-tiny").is_empty()); + assert!(extract_json_object("prefix {unterminated", 0).is_none()); + } + + #[test] + fn token_merge_and_loose_metadata_fill_missing_fields() { + let mut tokens = vec![TeamToken { + team_id: None, + domain: None, + name: None, + xoxc: "xoxc-merge-fixture-1234".into(), + }]; + merge_token( + &mut tokens, + TeamToken { + team_id: Some("T1234567".into()), + domain: Some("fixture".into()), + name: Some("Fixture".into()), + xoxc: "xoxc-merge-fixture-1234".into(), + }, + ); + assert_eq!(tokens[0].team_id.as_deref(), Some("T1234567")); + assert_eq!(tokens[0].domain.as_deref(), Some("fixture")); + assert_eq!(tokens[0].name.as_deref(), Some("Fixture")); + + assert_eq!( + loose_string_after_key(r#"\"name\":\"line\nslash\/tab\tend\""#, "name"), + Some("line\nslash/tab\tend".into()) + ); + assert_eq!( + unescape(concat!(r#"a\/b\\c\"d\ne\tf\q"#, "\\")), + "a/b\\c\"d\ne\tf\\q\\" + ); + assert_eq!(loose_string_after_key("{}", "name"), None); + assert_eq!(loose_string_after_key("name no colon", "name"), None); + assert_eq!(loose_string_after_key("name: no quote", "name"), None); + assert_eq!(loose_string_after_key("name:\"unterminated", "name"), None); + } + + #[test] + fn nested_object_and_team_id_scans_are_bounded() { + let text = r#"{"outer":{"closed":{}} ,"T1234567":{"token":"xoxc-nested-object-1234"}}"#; + let pos = text.find("xoxc-").unwrap(); + let (start, end) = enclosing_object(text.as_bytes(), pos); + assert_eq!(&text[start..=end], r#"{"token":"xoxc-nested-object-1234"}"#); + assert_eq!( + find_team_id_before(text, start).as_deref(), + Some("T1234567") + ); + + assert_eq!(find_team_id_before("not-quoted-T1234567", 19), None); + assert_eq!(find_team_id_before(r#"{"T1":{}"#, 8), None); + assert_eq!(find_team_id_before(r#"{"T12345678901234567":{}"#, 22), None); } } diff --git a/src/auth/extract/cookies.rs b/src/auth/extract/cookies.rs index 6c3de74..26788b0 100644 --- a/src/auth/extract/cookies.rs +++ b/src/auth/extract/cookies.rs @@ -188,6 +188,47 @@ mod tests { } } + #[test] + fn copies_database_and_sidecars_then_removes_scratch_directory() { + let source_dir = tempdir().unwrap(); + let db = source_dir.path().join("Cookies Fixture"); + fs::write(&db, b"sqlite fixture bytes").unwrap(); + fs::write(source_dir.path().join("Cookies Fixture-wal"), b"wal").unwrap(); + fs::write(source_dir.path().join("Cookies Fixture-shm"), b"shm").unwrap(); + + let guard = copy_db_to_temp(&db).unwrap(); + let scratch = guard.dir.clone(); + assert_eq!(fs::read(&guard.db).unwrap(), b"sqlite fixture bytes"); + assert_eq!( + fs::read(scratch.join("Cookies Fixture-wal")).unwrap(), + b"wal" + ); + assert_eq!( + fs::read(scratch.join("Cookies Fixture-shm")).unwrap(), + b"shm" + ); + + drop(guard); + assert!(!scratch.exists()); + } + + #[test] + fn copy_rejects_path_without_a_file_name() { + let err = copy_db_to_temp(Path::new("/")).err().unwrap(); + assert!(err.to_string().contains("invalid cookies database path")); + } + + #[test] + fn classifies_only_busy_and_locked_sqlite_failures() { + let sqlite_error = + |code| rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(code), None); + + assert!(is_locked(&sqlite_error(rusqlite::ffi::SQLITE_BUSY))); + assert!(is_locked(&sqlite_error(rusqlite::ffi::SQLITE_LOCKED))); + assert!(!is_locked(&sqlite_error(rusqlite::ffi::SQLITE_READONLY))); + assert!(!is_locked(&rusqlite::Error::InvalidQuery)); + } + #[test] fn query_returns_encrypted_bytes_for_matching_row() { let dir = tempdir().unwrap(); @@ -222,6 +263,38 @@ mod tests { assert!(got.is_none()); } + #[test] + fn locked_database_is_read_from_a_temporary_copy() { + let dir = tempdir().unwrap(); + let db = dir.path().join("Cookies"); + let payload: &[u8] = b"v10locked-fixture"; + make_cookies_db(&db, &[("app.slack.com", "d", payload)]); + + let writer = Connection::open(&db).unwrap(); + writer.execute_batch("BEGIN EXCLUSIVE").unwrap(); + let got = read_encrypted_d_value(&db).expect("copy bypasses exclusive lock"); + assert_eq!(got.as_deref(), Some(payload)); + writer.execute_batch("ROLLBACK").unwrap(); + } + + #[test] + fn unreadable_database_errors_are_mapped() { + let dir = tempdir().unwrap(); + let missing = dir.path().join("missing-cookies-db"); + let err = read_slack_d_cookie(&missing, &[]).unwrap_err(); + assert!(err.to_string().contains("failed to read cookies database")); + } + + #[test] + fn matching_cookie_without_keys_has_a_specific_error() { + let dir = tempdir().unwrap(); + let db = dir.path().join("Cookies"); + make_cookies_db(&db, &[("app.slack.com", "d", b"v10ciphertext")]); + + let err = read_slack_d_cookie(&db, &[]).unwrap_err(); + assert!(err.to_string().contains("no Safe Storage keys")); + } + #[test] fn read_slack_d_cookie_returns_none_when_absent() { let dir = tempdir().unwrap(); diff --git a/src/auth/extract/crypto.rs b/src/auth/extract/crypto.rs index 665bdf7..d95e9f5 100644 --- a/src/auth/extract/crypto.rs +++ b/src/auth/extract/crypto.rs @@ -24,9 +24,9 @@ use crate::error::{Result, SlackError}; use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit}; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] use pbkdf2::pbkdf2_hmac; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] use sha1::Sha1; #[cfg(target_os = "macos")] use std::process::Command; @@ -76,33 +76,15 @@ const AES_KEY_LEN: usize = 16; /// non-macOS platform, where Keychain access is not yet implemented. #[cfg(target_os = "macos")] pub fn safe_storage_keys(safe_storage_service: &str) -> Result<Vec<Vec<u8>>> { - let mut passwords: Vec<Vec<u8>> = Vec::new(); - let mut last_error = String::new(); - // Try the account-less lookup first (covers non-Slack browsers with a // single item), then each well-known Slack account name. let mut attempts: Vec<Option<&str>> = vec![None]; attempts.extend(KNOWN_SAFE_STORAGE_ACCOUNTS.iter().map(|a| Some(*a))); - for account in attempts { - match read_keychain_password(safe_storage_service, account) { - Ok(pw) if !pw.is_empty() => { - if !passwords.contains(&pw) { - passwords.push(pw); - } - } - Ok(_) => {} - Err(e) => last_error = e, - } - } - - if passwords.is_empty() { - return Err(SlackError::Other(format!( - "failed to read any Keychain password for service '{safe_storage_service}': {last_error}" - ))); - } - - Ok(passwords.iter().map(|pw| derive_key(pw)).collect()) + let results = attempts + .into_iter() + .map(|account| read_keychain_password(safe_storage_service, account)); + derive_safe_storage_keys(safe_storage_service, results) } /// Read one Keychain generic password, optionally scoped to `account`. @@ -153,8 +135,44 @@ pub fn safe_storage_keys(_safe_storage_service: &str) -> Result<Vec<Vec<u8>>> { )) } +/// De-duplicate successful Keychain reads and derive one key per password. +/// +/// Keeping this separate from the platform command makes candidate handling +/// deterministic and testable without accessing a user's Keychain. +#[cfg(any(target_os = "macos", test))] +fn derive_safe_storage_keys<I>(service: &str, results: I) -> Result<Vec<Vec<u8>>> +where + I: IntoIterator<Item = std::result::Result<Vec<u8>, String>>, +{ + let mut passwords: Vec<Vec<u8>> = Vec::new(); + let mut last_error = String::new(); + + for result in results { + match result { + Ok(password) if !password.is_empty() => { + if !passwords.contains(&password) { + passwords.push(password); + } + } + Ok(_) => {} + Err(error) => last_error = error, + } + } + + if passwords.is_empty() { + return Err(SlackError::Other(format!( + "failed to read any Keychain password for service '{service}': {last_error}" + ))); + } + + Ok(passwords + .iter() + .map(|password| derive_key(password)) + .collect()) +} + /// Stretch a raw Keychain password into the 16-byte AES cookie key. -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", test))] fn derive_key(password: &[u8]) -> Vec<u8> { let mut key = vec![0u8; AES_KEY_LEN]; pbkdf2_hmac::<Sha1>(password, b"saltysalt", 1003, &mut key); @@ -262,6 +280,42 @@ mod tests { out } + #[test] + fn derives_known_chromium_key_vector() { + assert_eq!( + derive_key(b"peanuts"), + [ + 0xd9, 0xa0, 0x9d, 0x49, 0x9b, 0x4e, 0x1b, 0x74, 0x61, 0xf2, 0x8e, 0x67, 0x97, 0x2c, + 0x6d, 0xbd, + ] + ); + } + + #[test] + fn derives_distinct_keys_from_successful_password_results() { + let results = vec![ + Err("first lookup failed".to_string()), + Ok(Vec::new()), + Ok(b"peanuts".to_vec()), + Ok(b"peanuts".to_vec()), + Ok(b"different".to_vec()), + ]; + let keys = derive_safe_storage_keys("Fixture Safe Storage", results).unwrap(); + + assert_eq!(keys.len(), 2); + assert_eq!(keys[0], derive_key(b"peanuts")); + assert_eq!(keys[1], derive_key(b"different")); + } + + #[test] + fn key_derivation_reports_last_lookup_error_when_no_password_exists() { + let results = vec![Ok(Vec::new()), Err("access denied".to_string())]; + let err = derive_safe_storage_keys("Fixture Safe Storage", results).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("Fixture Safe Storage")); + assert!(message.contains("access denied")); + } + #[test] fn decrypts_plain_xoxd_value_with_pkcs7_padding() { let key = [0x11u8; AES_KEY_LEN]; @@ -320,11 +374,29 @@ mod tests { } #[test] - fn non_block_aligned_ciphertext_errors() { + fn malformed_ciphertexts_error() { let key = [0x66u8; AES_KEY_LEN]; + + assert!(decrypt_cookie_value(V10_PREFIX, &key).is_err()); + // v10 + 5 bytes that are not a multiple of the AES block size. - let mut blob = V10_PREFIX.to_vec(); - blob.extend_from_slice(b"12345"); - assert!(decrypt_cookie_value(&blob, &key).is_err()); + let mut unaligned = V10_PREFIX.to_vec(); + unaligned.extend_from_slice(b"12345"); + assert!(decrypt_cookie_value(&unaligned, &key).is_err()); + + // One aligned block with invalid PKCS#7 padding reaches the AES error. + let mut bad_padding = V10_PREFIX.to_vec(); + bad_padding.extend_from_slice(&[0u8; AES_KEY_LEN]); + let err = decrypt_cookie_value(&bad_padding, &key).unwrap_err(); + assert!(err.to_string().contains("AES-CBC")); + } + + #[test] + fn rejects_long_plaintext_without_a_valid_token_after_domain_hash() { + assert!(finalize_cookie_plaintext(vec![b'a'; DOMAIN_HASH_LEN + 8]).is_err()); + + let mut invalid_utf8_tail = vec![b'a'; DOMAIN_HASH_LEN]; + invalid_utf8_tail.extend_from_slice(&[0xff; 8]); + assert!(finalize_cookie_plaintext(invalid_utf8_tail).is_err()); } } diff --git a/tests/fixtures/extract/local_config.log b/tests/fixtures/extract/local_config.log new file mode 100644 index 0000000000000000000000000000000000000000..ef0bce7f76f92b102bcc9df27b64eec8331664b0 GIT binary patch literal 127 zcmZQ%NXx7!DJ@FXEhtI_G8uC6lM{2C^YhX&)8osGs+CGo6LX7|tg4kl42_IUOwG(e z?7YO>R3$4VH;7gx9i^1~+{Da0pb*qFpioJEb}C4uBEKS87iJ063=2yGrP|tBhJOH# Cg(=<u literal 0 HcmV?d00001 diff --git a/tests/fixtures/extract/minimal_sstable.ldb b/tests/fixtures/extract/minimal_sstable.ldb new file mode 100644 index 0000000000000000000000000000000000000000..0902e70c4bc858b01985945d5da98e50a1279d65 GIT binary patch literal 180 zcmd1FPfpBn&d*EBOph-!s#YpVP0TG;vZ_`JF*GtZF*P#>vGWphQ<bcg+%hXlN{dpJ zbd*x^a}zW3fI?|-p_2UURFFtTenqk_R9F{khJ~epQf+N50~jzeWi#Y}Sd0)ph%Dqo QuyM1(e>Zfil)B#r0NfWSj{pDw literal 0 HcmV?d00001 From 8a977d73be446180eceff573b8ab9bdb9b61bb35 Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 07:13:47 +1000 Subject: [PATCH 19/22] test(keyring-storage): Testable keyring backend and un-ignored storage tests --- src/auth/storage.rs | 797 +++++++++++++++++++++++++++++--------------- 1 file changed, 533 insertions(+), 264 deletions(-) diff --git a/src/auth/storage.rs b/src/auth/storage.rs index 6775e62..c2ec856 100644 --- a/src/auth/storage.rs +++ b/src/auth/storage.rs @@ -15,7 +15,7 @@ //! separate `default` / `workspaces` items, which caused a Keychain prompt for //! every workspace when listing or resolving `-w`. On first access the store //! transparently migrates that legacy layout into the single blob (see -//! [`KeyringStore::migrate_legacy`]). +//! [`KeyringStore::migrate_legacy_with`]). use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; @@ -40,6 +40,63 @@ pub(crate) struct KeyringData { workspaces: Vec<String>, } +/// Minimal credential backend used by the keyring storage logic. +/// +/// Keeping the keyring crate behind this interface lets the state-management +/// and migration paths be exercised without touching a user's OS keyring. +trait SecretStore { + fn get(&self, key: &str) -> Result<Option<String>>; + fn set(&self, key: &str, value: &str) -> Result<()>; + fn delete(&self, key: &str) -> Result<()>; +} + +/// Production [`SecretStore`] backed by the platform keyring. +struct SystemSecretStore; + +impl SystemSecretStore { + /// Create a new keyring entry. + fn entry(key: &str) -> Result<Entry> { + debug!(service = SERVICE_NAME, key = key, "Creating keyring entry"); + Entry::new(SERVICE_NAME, key).map_err(|e| { + error!( + service = SERVICE_NAME, + key = key, + error = %e, + "Failed to create keyring entry" + ); + SlackError::Keyring(e) + }) + } +} + +impl SecretStore for SystemSecretStore { + fn get(&self, key: &str) -> Result<Option<String>> { + match Self::entry(key)?.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(SlackError::Keyring(e)), + } + } + + fn set(&self, key: &str, value: &str) -> Result<()> { + Self::entry(key)?.set_password(value).map_err(|e| { + error!( + service = SERVICE_NAME, + key = key, + error = %e, + "Failed to write keyring entry" + ); + SlackError::Keyring(e) + }) + } + + fn delete(&self, key: &str) -> Result<()> { + Self::entry(key)? + .delete_credential() + .map_err(SlackError::Keyring) + } +} + /// Process-wide cache of the decoded blob so a single command reads the /// keyring at most once. `None` (uninitialized) vs `Some(data)` (loaded). fn cache() -> &'static Mutex<Option<KeyringData>> { @@ -63,6 +120,10 @@ fn backend_unavailable(err: &keyring::Error) -> bool { ) } +fn storage_unavailable(err: &SlackError) -> bool { + matches!(err, SlackError::Keyring(source) if backend_unavailable(source)) +} + /// Service name for keyring entries const SERVICE_NAME: &str = "slack-cli"; @@ -83,52 +144,28 @@ const LEGACY_WORKSPACE_LIST_KEY: &str = "workspaces"; pub struct KeyringStore; impl KeyringStore { - /// Create a new keyring entry - fn entry(key: &str) -> Result<Entry> { - debug!(service = SERVICE_NAME, key = key, "Creating keyring entry"); - Entry::new(SERVICE_NAME, key).map_err(|e| { - error!( - service = SERVICE_NAME, - key = key, - error = %e, - "Failed to create keyring entry" - ); - SlackError::Keyring(e) - }) - } - /// Load the consolidated blob, using the in-process cache when warm. - /// - /// On a cold cache this performs a single keyring read. If the new blob is - /// absent it attempts a one-time migration from the legacy per-workspace - /// layout; if that yields nothing (or the backend is unavailable) it - /// returns an empty [`KeyringData`]. - fn load() -> Result<KeyringData> { - let mut guard = cache() + fn load_with<S: SecretStore>( + store: &S, + data_cache: &Mutex<Option<KeyringData>>, + ) -> Result<KeyringData> { + let mut guard = data_cache .lock() .map_err(|_| SlackError::Other("Failed to lock keyring cache".into()))?; if let Some(data) = guard.as_ref() { return Ok(data.clone()); } - let entry = Self::entry(STORE_KEY)?; - let data = match entry.get_password() { - Ok(json) => serde_json::from_str(&json)?, - Err(keyring::Error::NoEntry) => { + let data = match store.get(STORE_KEY) { + Ok(Some(json)) => serde_json::from_str(&json)?, + Ok(None) => { // No new-format blob yet: migrate any legacy entries once. - let migrated = Self::migrate_legacy().unwrap_or_default(); + let migrated = Self::migrate_legacy_with(store).unwrap_or_default(); if !migrated.workspaces.is_empty() { - // Persist the migrated blob FIRST so we never delete the - // legacy entries without a durable copy. Only on a - // successful write do we clean up the old per-workspace - // items; if the write fails we leave the legacy layout - // intact and retry on the next run. - // - // NOTE: we hold the cache lock here, so we call the - // lock-free `write_entry` directly — `persist` would try to - // re-lock the (non-reentrant) cache mutex and deadlock. - match Self::write_entry(&migrated) { - Ok(()) => Self::delete_legacy_entries(&migrated.workspaces), + // Persist the migrated blob FIRST so legacy credentials are + // never removed without a durable consolidated copy. + match Self::write_entry_with(store, &migrated) { + Ok(()) => Self::delete_legacy_entries_with(store, &migrated.workspaces), Err(e) => { warn!(error = %e, "Failed to persist migrated keyring blob; keeping legacy entries"); } @@ -136,7 +173,7 @@ impl KeyringStore { } migrated } - Err(e) if backend_unavailable(&e) => { + Err(e) if storage_unavailable(&e) => { warn!( service = SERVICE_NAME, key = STORE_KEY, @@ -152,7 +189,7 @@ impl KeyringStore { error = %e, "Failed to read keyring store" ); - return Err(SlackError::Keyring(e)); + return Err(e); } }; @@ -161,63 +198,42 @@ impl KeyringStore { } /// Write the blob to the keyring only (no cache interaction). - /// - /// A write failure is a hard error so a token is never silently dropped. - /// Callers that do not already hold the cache lock should use - /// [`Self::persist`] instead so the in-process cache stays consistent. - fn write_entry(data: &KeyringData) -> Result<()> { - let entry = Self::entry(STORE_KEY)?; + fn write_entry_with<S: SecretStore>(store: &S, data: &KeyringData) -> Result<()> { let json = serde_json::to_string(data)?; - entry.set_password(&json).map_err(|e| { - error!( - service = SERVICE_NAME, - key = STORE_KEY, - error = %e, - "Failed to write keyring store" - ); - SlackError::Keyring(e) - }) + store.set(STORE_KEY, &json) } /// Serialize and write the blob, updating the in-process cache. - /// - /// Must NOT be called while holding the cache lock (the mutex is - /// non-reentrant); the cold-start migration path in [`Self::load`] writes - /// via [`Self::write_entry`] for that reason. - fn persist(data: &KeyringData) -> Result<()> { - Self::write_entry(data)?; - if let Ok(mut guard) = cache().lock() { + fn persist_with<S: SecretStore>( + store: &S, + data_cache: &Mutex<Option<KeyringData>>, + data: &KeyringData, + ) -> Result<()> { + Self::write_entry_with(store, data)?; + if let Ok(mut guard) = data_cache.lock() { *guard = Some(data.clone()); } Ok(()) } - /// Read, mutate, and persist the blob atomically under the cache lock-free - /// contract used elsewhere (load clones, we mutate the clone, then persist). - fn update<F>(f: F) -> Result<()> + /// Read, mutate, and persist the blob. + fn update_with<S, F>(store: &S, data_cache: &Mutex<Option<KeyringData>>, f: F) -> Result<()> where + S: SecretStore, F: FnOnce(&mut KeyringData), { - let mut data = Self::load()?; + let mut data = Self::load_with(store, data_cache)?; f(&mut data); - Self::persist(&data) - } - - /// One-time migration from the legacy per-workspace layout - /// (`token:<team_id>` items plus `default` / `workspaces` items) into a - /// single [`KeyringData`] blob. - /// - /// This is the *only* path that still reads the old per-workspace items, - /// so it triggers the old multi-prompt behavior exactly once; afterwards - /// the consolidated blob is used and the legacy items are best-effort - /// deleted. Returns an empty value when there is nothing to migrate. - fn migrate_legacy() -> Result<KeyringData> { - // Legacy workspace list. - let ids: Vec<String> = match Self::entry(LEGACY_WORKSPACE_LIST_KEY)?.get_password() { - Ok(json) => serde_json::from_str(&json).unwrap_or_default(), - Err(keyring::Error::NoEntry) => Vec::new(), - Err(e) if backend_unavailable(&e) => return Ok(KeyringData::default()), - Err(e) => return Err(SlackError::Keyring(e)), + Self::persist_with(store, data_cache, &data) + } + + /// One-time migration from the legacy per-workspace layout. + fn migrate_legacy_with<S: SecretStore>(store: &S) -> Result<KeyringData> { + let ids: Vec<String> = match store.get(LEGACY_WORKSPACE_LIST_KEY) { + Ok(Some(json)) => serde_json::from_str(&json).unwrap_or_default(), + Ok(None) => Vec::new(), + Err(e) if storage_unavailable(&e) => return Ok(KeyringData::default()), + Err(e) => return Err(e), }; if ids.is_empty() { return Ok(KeyringData::default()); @@ -230,21 +246,21 @@ impl KeyringStore { let mut data = KeyringData::default(); for team_id in &ids { let key = format!("token:{}", team_id); - match Self::entry(&key)?.get_password() { - Ok(json) => { + match store.get(&key) { + Ok(Some(json)) => { if let Ok(token) = serde_json::from_str::<TokenSet>(&json) { data.tokens.insert(team_id.clone(), token); data.workspaces.push(team_id.clone()); } } - Err(keyring::Error::NoEntry) => {} - Err(e) if backend_unavailable(&e) => return Ok(KeyringData::default()), - Err(e) => return Err(SlackError::Keyring(e)), + Ok(None) => {} + Err(e) if storage_unavailable(&e) => return Ok(KeyringData::default()), + Err(e) => return Err(e), } } - // Legacy default. - if let Ok(default) = Self::entry(LEGACY_DEFAULT_KEY)?.get_password() { + // Errors reading the optional legacy default never invalidate tokens. + if let Ok(Some(default)) = store.get(LEGACY_DEFAULT_KEY) { if data.workspaces.contains(&default) { data.default = Some(default); } @@ -253,33 +269,30 @@ impl KeyringStore { Ok(data) } - /// Best-effort deletion of the legacy per-workspace items after the - /// consolidated blob has been durably written. Failures are ignored: a - /// stray legacy item is harmless (the blob is authoritative) and will not - /// be re-migrated once the blob exists. - fn delete_legacy_entries(team_ids: &[String]) { + /// Best-effort deletion of legacy items after the blob has been written. + fn delete_legacy_entries_with<S: SecretStore>(store: &S, team_ids: &[String]) { for team_id in team_ids { - if let Ok(entry) = Self::entry(&format!("token:{}", team_id)) { - let _ = entry.delete_credential(); - } - } - if let Ok(entry) = Self::entry(LEGACY_DEFAULT_KEY) { - let _ = entry.delete_credential(); - } - if let Ok(entry) = Self::entry(LEGACY_WORKSPACE_LIST_KEY) { - let _ = entry.delete_credential(); + let _ = store.delete(&format!("token:{}", team_id)); } + let _ = store.delete(LEGACY_DEFAULT_KEY); + let _ = store.delete(LEGACY_WORKSPACE_LIST_KEY); } - /// Store a token for a workspace - /// - /// Inserts the token into the consolidated blob (appending to the - /// workspace ordering if new) and persists it. + /// Store a token for a workspace. pub fn store_token(team_id: &str, token: &TokenSet) -> Result<()> { + Self::store_token_with(&SystemSecretStore, cache(), team_id, token) + } + + fn store_token_with<S: SecretStore>( + store: &S, + data_cache: &Mutex<Option<KeyringData>>, + team_id: &str, + token: &TokenSet, + ) -> Result<()> { debug!(team_id = team_id, "Storing token in keyring blob"); let token = token.clone(); let team_id_owned = team_id.to_string(); - Self::update(move |data| { + Self::update_with(store, data_cache, move |data| { data.tokens.insert(team_id_owned.clone(), token); if !data.workspaces.contains(&team_id_owned) { data.workspaces.push(team_id_owned); @@ -287,16 +300,35 @@ impl KeyringStore { }) } - /// Get token for a workspace + /// Get token for a workspace. pub fn get_token(team_id: &str) -> Result<Option<TokenSet>> { + Self::get_token_with(&SystemSecretStore, cache(), team_id) + } + + fn get_token_with<S: SecretStore>( + store: &S, + data_cache: &Mutex<Option<KeyringData>>, + team_id: &str, + ) -> Result<Option<TokenSet>> { debug!(team_id = team_id, "Getting token from keyring blob"); - Ok(Self::load()?.tokens.get(team_id).cloned()) + Ok(Self::load_with(store, data_cache)? + .tokens + .get(team_id) + .cloned()) } - /// Delete token for a workspace + /// Delete token for a workspace. pub fn delete_token(team_id: &str) -> Result<()> { + Self::delete_token_with(&SystemSecretStore, cache(), team_id) + } + + fn delete_token_with<S: SecretStore>( + store: &S, + data_cache: &Mutex<Option<KeyringData>>, + team_id: &str, + ) -> Result<()> { debug!(team_id = team_id, "Deleting token from keyring blob"); - Self::update(|data| { + Self::update_with(store, data_cache, |data| { data.tokens.remove(team_id); data.workspaces.retain(|id| id != team_id); if data.default.as_deref() == Some(team_id) { @@ -305,65 +337,101 @@ impl KeyringStore { }) } - /// Set the default workspace + /// Set the default workspace. pub fn set_default(team_id: &str) -> Result<()> { + Self::set_default_with(&SystemSecretStore, cache(), team_id) + } + + fn set_default_with<S: SecretStore>( + store: &S, + data_cache: &Mutex<Option<KeyringData>>, + team_id: &str, + ) -> Result<()> { debug!( team_id = team_id, "Setting default workspace in keyring blob" ); let team_id_owned = team_id.to_string(); - Self::update(move |data| { + Self::update_with(store, data_cache, move |data| { data.default = Some(team_id_owned); }) } - /// Get the default workspace + /// Get the default workspace. pub fn get_default() -> Result<Option<String>> { + Self::get_default_with(&SystemSecretStore, cache()) + } + + fn get_default_with<S: SecretStore>( + store: &S, + data_cache: &Mutex<Option<KeyringData>>, + ) -> Result<Option<String>> { debug!("Getting default workspace from keyring blob"); - Ok(Self::load()?.default) + Ok(Self::load_with(store, data_cache)?.default) } - /// Clear the default workspace + /// Clear the default workspace. pub fn clear_default() -> Result<()> { + Self::clear_default_with(&SystemSecretStore, cache()) + } + + fn clear_default_with<S: SecretStore>( + store: &S, + data_cache: &Mutex<Option<KeyringData>>, + ) -> Result<()> { debug!("Clearing default workspace from keyring blob"); - Self::update(|data| { - data.default = None; - }) + Self::update_with(store, data_cache, |data| data.default = None) } - /// List all stored workspaces - /// - /// Returns the team IDs of all stored workspaces, in insertion order. + /// List all stored workspaces, in insertion order. pub fn list_workspaces() -> Result<Vec<String>> { + Self::list_workspaces_with(&SystemSecretStore, cache()) + } + + fn list_workspaces_with<S: SecretStore>( + store: &S, + data_cache: &Mutex<Option<KeyringData>>, + ) -> Result<Vec<String>> { debug!("Listing workspaces from keyring blob"); - Ok(Self::load()?.workspaces) + Ok(Self::load_with(store, data_cache)?.workspaces) } - /// Get the token for the default workspace, or the first available workspace + /// Get the token for the default workspace, or the first available workspace. pub fn get_default_or_first() -> Result<Option<TokenSet>> { - let data = Self::load()?; + Self::get_default_or_first_with(&SystemSecretStore, cache()) + } - // Try the default first. + fn get_default_or_first_with<S: SecretStore>( + store: &S, + data_cache: &Mutex<Option<KeyringData>>, + ) -> Result<Option<TokenSet>> { + let data = Self::load_with(store, data_cache)?; if let Some(default_id) = data.default.as_ref() { if let Some(token) = data.tokens.get(default_id) { return Ok(Some(token.clone())); } } - - // Fall back to the first workspace in the list. if let Some(first) = data.workspaces.first() { return Ok(data.tokens.get(first).cloned()); } - Ok(None) } - /// Get workspace info (team_id, team_name, domain, type) for all stored - /// workspaces. Reads the blob once — no per-workspace keyring access. + /// Get workspace information for all stored workspaces. pub fn get_workspace_info() -> Result<Vec<WorkspaceInfo>> { - let data = Self::load()?; - let default = data.default.as_ref(); + Self::get_workspace_info_with(&SystemSecretStore, cache()) + } + + fn get_workspace_info_with<S: SecretStore>( + store: &S, + data_cache: &Mutex<Option<KeyringData>>, + ) -> Result<Vec<WorkspaceInfo>> { + let data = Self::load_with(store, data_cache)?; + Self::workspace_info_from_data(&data) + } + fn workspace_info_from_data(data: &KeyringData) -> Result<Vec<WorkspaceInfo>> { + let default = data.default.as_ref(); let mut info = Vec::new(); for team_id in &data.workspaces { if let Some(token) = data.tokens.get(team_id) { @@ -376,7 +444,6 @@ impl KeyringStore { }); } } - Ok(info) } } @@ -397,12 +464,80 @@ pub struct WorkspaceInfo { mod tests { use super::*; use crate::auth::TokenType; + use std::collections::{HashMap, HashSet}; + + #[derive(Default)] + struct MemorySecretStore { + entries: Mutex<HashMap<String, String>>, + operations: Mutex<Vec<String>>, + failing_gets: Mutex<HashSet<String>>, + fail_sets: Mutex<bool>, + } + + impl MemorySecretStore { + fn seed(&self, key: &str, value: impl Into<String>) { + self.entries + .lock() + .unwrap() + .insert(key.to_string(), value.into()); + } + + fn value(&self, key: &str) -> Option<String> { + self.entries.lock().unwrap().get(key).cloned() + } + + fn operations(&self) -> Vec<String> { + self.operations.lock().unwrap().clone() + } + + fn fail_get(&self, key: &str) { + self.failing_gets.lock().unwrap().insert(key.to_string()); + } + + fn fail_sets(&self) { + *self.fail_sets.lock().unwrap() = true; + } + } + + impl SecretStore for MemorySecretStore { + fn get(&self, key: &str) -> Result<Option<String>> { + self.operations.lock().unwrap().push(format!("get:{key}")); + if self.failing_gets.lock().unwrap().contains(key) { + return Err(SlackError::Other(format!("failed get: {key}"))); + } + Ok(self.entries.lock().unwrap().get(key).cloned()) + } + + fn set(&self, key: &str, value: &str) -> Result<()> { + self.operations.lock().unwrap().push(format!("set:{key}")); + if *self.fail_sets.lock().unwrap() { + return Err(SlackError::Other("failed set".into())); + } + self.entries + .lock() + .unwrap() + .insert(key.to_string(), value.to_string()); + Ok(()) + } + + fn delete(&self, key: &str) -> Result<()> { + self.operations + .lock() + .unwrap() + .push(format!("delete:{key}")); + self.entries.lock().unwrap().remove(key); + Ok(()) + } + } + + fn test_cache() -> Mutex<Option<KeyringData>> { + Mutex::new(None) + } - // Helper to create a test token fn create_test_token(team_id: &str, team_name: &str) -> TokenSet { TokenSet { token_type: TokenType::UserOAuth, - access_token: format!("xoxp-test-{}", team_id), + access_token: format!("xoxp-test-{team_id}"), xoxd_cookie: None, team_id: team_id.to_string(), team_name: team_name.to_string(), @@ -420,15 +555,11 @@ mod tests { #[test] fn test_store_key_is_singular() { - // The whole point of the single-blob layout: one keyring item. assert_eq!(STORE_KEY, "store"); } #[test] fn test_keyring_data_blob_roundtrips() { - // A KeyringData blob with multiple workspaces survives a JSON round - // trip with tokens, default, and ordering intact — this is the single - // value read from (and written to) the one keyring item. let mut data = KeyringData::default(); data.tokens .insert("T1".into(), create_test_token("T1", "One")); @@ -461,16 +592,14 @@ mod tests { assert!(json.contains("T12345")); assert!(json.contains("Test Workspace")); assert!(json.contains("true")); + assert!(!json.contains("team_domain")); } - // Test that entry creation works #[test] fn test_entry_creation() { - let result = KeyringStore::entry("test_key"); - assert!(result.is_ok()); + assert!(SystemSecretStore::entry("test_key").is_ok()); } - // Test token serialization/deserialization (the core logic) #[test] fn test_token_serialization_roundtrip() { let token = create_test_token("T12345", "Test Workspace"); @@ -481,7 +610,6 @@ mod tests { assert_eq!(deserialized.access_token, "xoxp-test-T12345"); } - // Test workspace list serialization #[test] fn test_workspace_list_serialization() { let list = vec!["T1".to_string(), "T2".to_string(), "T3".to_string()]; @@ -490,154 +618,295 @@ mod tests { assert_eq!(deserialized, list); } - // ========================================================================= - // Integration tests that use the REAL system keyring - // ========================================================================= - // - // These tests require a real platform keyring backend with cross-Entry - // persistence. They are marked #[ignore] by default because: - // - // 1. They modify system state (store credentials in your keychain) - // 2. They require platform-specific keyring access: - // - macOS: Keychain Access (may prompt for permission) - // - Windows: Credential Manager - // - Linux: Secret Service (e.g., gnome-keyring, KWallet) - // 3. They will FAIL in sandboxed/CI environments without keyring access - // - // To run these tests: - // cargo test --lib -- --ignored - // - // These tests are NOT expected to pass in: - // - Docker containers without keyring setup - // - CI systems without credential storage - // - Sandboxed environments (App Sandbox on macOS) - // - // The mock keyring backend (keyring::mock) does NOT support cross-Entry - // persistence, so it cannot be used for these integration tests. - // ========================================================================= - #[test] - #[ignore] fn test_store_and_get_token() { - let team_id = "T_TEST_001"; - let token = create_test_token(team_id, "Test Workspace 1"); - - // Clean up any existing state first - let _ = KeyringStore::delete_token(team_id); + let store = MemorySecretStore::default(); + let cache = test_cache(); + let token = create_test_token("T_TEST_001", "Test Workspace 1"); - // Store - KeyringStore::store_token(team_id, &token).expect("Failed to store token"); + KeyringStore::store_token_with(&store, &cache, "T_TEST_001", &token).unwrap(); + let retrieved = KeyringStore::get_token_with(&store, &cache, "T_TEST_001") + .unwrap() + .unwrap(); - // Get - let retrieved = KeyringStore::get_token(team_id) - .expect("Failed to get token") - .expect("Token not found"); - - assert_eq!(retrieved.team_id, team_id); + assert_eq!(retrieved.team_id, "T_TEST_001"); assert_eq!(retrieved.team_name, "Test Workspace 1"); - - // Cleanup - KeyringStore::delete_token(team_id).expect("Failed to delete token"); + let persisted: KeyringData = + serde_json::from_str(&store.value(STORE_KEY).unwrap()).unwrap(); + assert_eq!(persisted.workspaces, ["T_TEST_001"]); } #[test] - #[ignore] fn test_get_nonexistent_token() { - // Use a unique ID that definitely doesn't exist - let result = KeyringStore::get_token("T_NONEXISTENT_999_UNIQUE"); - assert!(result.is_ok()); - assert!(result.unwrap().is_none()); + let store = MemorySecretStore::default(); + let cache = test_cache(); + assert!(KeyringStore::get_token_with(&store, &cache, "missing") + .unwrap() + .is_none()); } #[test] - #[ignore] fn test_delete_token() { - let team_id = "T_TEST_002"; - let token = create_test_token(team_id, "Test Workspace 2"); - - // Clean up any existing state first - let _ = KeyringStore::delete_token(team_id); - - // Store then delete - KeyringStore::store_token(team_id, &token).expect("Failed to store token"); - KeyringStore::delete_token(team_id).expect("Failed to delete token"); - - // Verify it's gone - let retrieved = KeyringStore::get_token(team_id).expect("Failed to get token"); - assert!(retrieved.is_none()); + let store = MemorySecretStore::default(); + let cache = test_cache(); + let token = create_test_token("T_TEST_002", "Test Workspace 2"); + KeyringStore::store_token_with(&store, &cache, "T_TEST_002", &token).unwrap(); + KeyringStore::delete_token_with(&store, &cache, "T_TEST_002").unwrap(); + assert!(KeyringStore::get_token_with(&store, &cache, "T_TEST_002") + .unwrap() + .is_none()); } #[test] - #[ignore] fn test_default_workspace() { - let team_id = "T_TEST_003"; - - // Clean up any existing state first - let _ = KeyringStore::clear_default(); - - // Set default - KeyringStore::set_default(team_id).expect("Failed to set default"); - - // Get default - let default = KeyringStore::get_default() - .expect("Failed to get default") - .expect("Default not found"); - assert_eq!(default, team_id); - - // Clear default - KeyringStore::clear_default().expect("Failed to clear default"); - - let default_after = KeyringStore::get_default().expect("Failed to get default"); - assert!(default_after.is_none()); + let store = MemorySecretStore::default(); + let cache = test_cache(); + KeyringStore::set_default_with(&store, &cache, "T1").unwrap(); + assert_eq!( + KeyringStore::get_default_with(&store, &cache) + .unwrap() + .as_deref(), + Some("T1") + ); + KeyringStore::set_default_with(&store, &cache, "T2").unwrap(); + assert_eq!( + KeyringStore::get_default_with(&store, &cache).unwrap(), + Some("T2".into()) + ); + KeyringStore::clear_default_with(&store, &cache).unwrap(); + assert_eq!( + KeyringStore::get_default_with(&store, &cache).unwrap(), + None + ); } #[test] - #[ignore] fn test_list_workspaces() { - let team_id_1 = "T_TEST_LIST_1"; - let team_id_2 = "T_TEST_LIST_2"; + let store = MemorySecretStore::default(); + let cache = test_cache(); + for (id, name) in [("T1", "One"), ("T2", "Two")] { + KeyringStore::store_token_with(&store, &cache, id, &create_test_token(id, name)) + .unwrap(); + } + // Replacing a token must not duplicate or reorder the workspace. + KeyringStore::store_token_with(&store, &cache, "T1", &create_test_token("T1", "Renamed")) + .unwrap(); + assert_eq!( + KeyringStore::list_workspaces_with(&store, &cache).unwrap(), + ["T1", "T2"] + ); + } - // Clean up any existing state first - let _ = KeyringStore::delete_token(team_id_1); - let _ = KeyringStore::delete_token(team_id_2); + #[test] + fn test_get_default_or_first() { + let store = MemorySecretStore::default(); + let cache = test_cache(); + let first = create_test_token("T1", "One"); + let second = create_test_token("T2", "Two"); + KeyringStore::store_token_with(&store, &cache, "T1", &first).unwrap(); + KeyringStore::store_token_with(&store, &cache, "T2", &second).unwrap(); + + assert_eq!( + KeyringStore::get_default_or_first_with(&store, &cache) + .unwrap() + .unwrap() + .team_id, + "T1" + ); + KeyringStore::set_default_with(&store, &cache, "T2").unwrap(); + assert_eq!( + KeyringStore::get_default_or_first_with(&store, &cache) + .unwrap() + .unwrap() + .team_id, + "T2" + ); + // A stale default falls back to the first workspace. + KeyringStore::set_default_with(&store, &cache, "missing").unwrap(); + assert_eq!( + KeyringStore::get_default_or_first_with(&store, &cache) + .unwrap() + .unwrap() + .team_id, + "T1" + ); + } - let token1 = create_test_token(team_id_1, "Test 1"); - let token2 = create_test_token(team_id_2, "Test 2"); + #[test] + fn empty_store_is_cached_and_returns_no_default_token() { + let store = MemorySecretStore::default(); + let cache = test_cache(); + assert!(KeyringStore::get_default_or_first_with(&store, &cache) + .unwrap() + .is_none()); + assert!(KeyringStore::list_workspaces_with(&store, &cache) + .unwrap() + .is_empty()); + assert_eq!( + store + .operations() + .iter() + .filter(|op| op.as_str() == "get:store") + .count(), + 1 + ); + } - // Store both - KeyringStore::store_token(team_id_1, &token1).expect("Failed to store token 1"); - KeyringStore::store_token(team_id_2, &token2).expect("Failed to store token 2"); + #[test] + fn delete_last_workspace_clears_default_and_persists_empty_blob() { + let store = MemorySecretStore::default(); + let cache = test_cache(); + let token = create_test_token("T1", "One"); + KeyringStore::store_token_with(&store, &cache, "T1", &token).unwrap(); + KeyringStore::set_default_with(&store, &cache, "T1").unwrap(); + KeyringStore::delete_token_with(&store, &cache, "T1").unwrap(); + + let data: KeyringData = serde_json::from_str(&store.value(STORE_KEY).unwrap()).unwrap(); + assert!(data.tokens.is_empty()); + assert!(data.workspaces.is_empty()); + assert!(data.default.is_none()); + } - // List - let workspaces = KeyringStore::list_workspaces().expect("Failed to list workspaces"); - assert!(workspaces.contains(&team_id_1.to_string())); - assert!(workspaces.contains(&team_id_2.to_string())); + #[test] + fn workspace_info_uses_order_default_domain_and_skips_missing_tokens() { + let store = MemorySecretStore::default(); + let cache = test_cache(); + let mut token = create_test_token("T1", "One"); + token.team_domain = Some("one".into()); + let mut data = KeyringData::default(); + data.tokens.insert("T1".into(), token); + data.workspaces = vec!["missing".into(), "T1".into()]; + data.default = Some("T1".into()); + store.seed(STORE_KEY, serde_json::to_string(&data).unwrap()); + + let info = KeyringStore::get_workspace_info_with(&store, &cache).unwrap(); + assert_eq!(info.len(), 1); + assert_eq!(info[0].team_id, "T1"); + assert_eq!(info[0].team_domain.as_deref(), Some("one")); + assert!(info[0].is_default); + assert_eq!(info[0].token_type, "UserOAuth"); + } - // Cleanup - KeyringStore::delete_token(team_id_1).expect("Failed to delete token 1"); - KeyringStore::delete_token(team_id_2).expect("Failed to delete token 2"); + #[test] + fn legacy_layout_is_persisted_before_it_is_deleted() { + let store = MemorySecretStore::default(); + let cache = test_cache(); + let one = create_test_token("T1", "One"); + let two = create_test_token("T2", "Two"); + store.seed( + LEGACY_WORKSPACE_LIST_KEY, + serde_json::to_string(&vec!["T1", "T2"]).unwrap(), + ); + store.seed("token:T1", serde_json::to_string(&one).unwrap()); + store.seed("token:T2", serde_json::to_string(&two).unwrap()); + store.seed(LEGACY_DEFAULT_KEY, "T2"); + + let data = KeyringStore::load_with(&store, &cache).unwrap(); + assert_eq!(data.workspaces, ["T1", "T2"]); + assert_eq!(data.default.as_deref(), Some("T2")); + assert!(store.value(STORE_KEY).is_some()); + assert!(store.value("token:T1").is_none()); + assert!(store.value("token:T2").is_none()); + assert!(store.value(LEGACY_DEFAULT_KEY).is_none()); + assert!(store.value(LEGACY_WORKSPACE_LIST_KEY).is_none()); + + let operations = store.operations(); + let persisted = operations.iter().position(|op| op == "set:store").unwrap(); + for key in ["token:T1", "token:T2", "default", "workspaces"] { + let deleted = operations + .iter() + .position(|op| op == &format!("delete:{key}")) + .unwrap(); + assert!(persisted < deleted); + } } #[test] - #[ignore] - fn test_get_default_or_first() { - let team_id = "T_TEST_004"; - let token = create_test_token(team_id, "Test 4"); + fn failed_migration_write_keeps_every_legacy_entry() { + let store = MemorySecretStore::default(); + let cache = test_cache(); + store.seed(LEGACY_WORKSPACE_LIST_KEY, r#"["T1"]"#); + store.seed( + "token:T1", + serde_json::to_string(&create_test_token("T1", "One")).unwrap(), + ); + store.seed(LEGACY_DEFAULT_KEY, "T1"); + store.fail_sets(); + + let data = KeyringStore::load_with(&store, &cache).unwrap(); + assert_eq!(data.workspaces, ["T1"]); + assert!(store.value(STORE_KEY).is_none()); + assert!(store.value("token:T1").is_some()); + assert!(store.value(LEGACY_DEFAULT_KEY).is_some()); + assert!(store.value(LEGACY_WORKSPACE_LIST_KEY).is_some()); + assert!(!store + .operations() + .iter() + .any(|op| op.starts_with("delete:"))); + } - // Clean up any existing state first - let _ = KeyringStore::delete_token(team_id); - let _ = KeyringStore::clear_default(); + #[test] + fn corrupt_blob_json_is_reported_without_populating_cache() { + let store = MemorySecretStore::default(); + let cache = test_cache(); + store.seed(STORE_KEY, "{not-json"); + assert!(KeyringStore::load_with(&store, &cache).is_err()); + assert!(cache.lock().unwrap().is_none()); + } - // Store a token - KeyringStore::store_token(team_id, &token).expect("Failed to store token"); + #[test] + fn malformed_legacy_values_are_ignored() { + let store = MemorySecretStore::default(); + store.seed(LEGACY_WORKSPACE_LIST_KEY, r#"["T1","T2"]"#); + store.seed("token:T1", "not-json"); + store.seed( + "token:T2", + serde_json::to_string(&create_test_token("T2", "Two")).unwrap(), + ); + store.seed(LEGACY_DEFAULT_KEY, "unknown"); + let data = KeyringStore::migrate_legacy_with(&store).unwrap(); + assert_eq!(data.workspaces, ["T2"]); + assert!(data.default.is_none()); + + let malformed_list = MemorySecretStore::default(); + malformed_list.seed(LEGACY_WORKSPACE_LIST_KEY, "not-json"); + assert!(KeyringStore::migrate_legacy_with(&malformed_list) + .unwrap() + .workspaces + .is_empty()); + } - // Should find it as the first available - let retrieved = KeyringStore::get_default_or_first() - .expect("Failed to get default") - .expect("No token found"); - assert_eq!(retrieved.team_id, team_id); + #[test] + fn backend_read_and_write_failures_are_handled() { + let read_failure = MemorySecretStore::default(); + let read_cache = test_cache(); + read_failure.fail_get(STORE_KEY); + assert!(KeyringStore::load_with(&read_failure, &read_cache).is_err()); + + let migration_failure = MemorySecretStore::default(); + migration_failure.fail_get(LEGACY_WORKSPACE_LIST_KEY); + assert!(KeyringStore::migrate_legacy_with(&migration_failure).is_err()); + + let write_failure = MemorySecretStore::default(); + let write_cache = test_cache(); + write_failure.fail_sets(); + assert!(KeyringStore::store_token_with( + &write_failure, + &write_cache, + "T1", + &create_test_token("T1", "One") + ) + .is_err()); + } - // Cleanup - KeyringStore::delete_token(team_id).expect("Failed to delete token"); + #[test] + fn poisoned_cache_is_reported() { + let store = MemorySecretStore::default(); + let cache = test_cache(); + let _ = std::panic::catch_unwind(|| { + let _guard = cache.lock().unwrap(); + panic!("poison cache"); + }); + assert!(KeyringStore::load_with(&store, &cache).is_err()); } } From 5510c8d80dfc44782f88a6137e9a10f76be6fc95 Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 07:14:25 +1000 Subject: [PATCH 20/22] test(auth-cli): CLI tests for auth command paths --- src/auth/extract/mod.rs | 87 +++++ tests/cli_auth.rs | 836 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 923 insertions(+) diff --git a/src/auth/extract/mod.rs b/src/auth/extract/mod.rs index 031a157..bd38500 100644 --- a/src/auth/extract/mod.rs +++ b/src/auth/extract/mod.rs @@ -24,6 +24,60 @@ pub mod profiles; use crate::auth::browser::BrowserTokens; use crate::error::Result; +const EXTRACT_FIXTURE_ENV: &str = "SLACK_EXTRACT_FIXTURE"; + +#[derive(serde::Deserialize)] +struct FixtureData { + workspaces: Vec<FixtureWorkspace>, +} + +#[derive(serde::Deserialize)] +struct FixtureWorkspace { + xoxc: String, + xoxd: String, + #[serde(default)] + team_id: Option<String>, + #[serde(default)] + team_domain: Option<String>, + #[serde(default)] + team_name: Option<String>, + source: String, +} + +/// Load extraction results from `SLACK_EXTRACT_FIXTURE`, when set. +/// +/// This is a test-only seam used by CLI integration tests so they do not scan +/// real browser profiles. The referenced JSON file has the shape +/// `{"workspaces":[{"xoxc":"xoxc-...","xoxd":"xoxd-...", ...}]}`. +fn fixture_workspaces() -> Option<Result<Vec<ExtractedWorkspace>>> { + let path = std::env::var_os(EXTRACT_FIXTURE_ENV)?; + Some((|| { + let contents = std::fs::read_to_string(path)?; + let fixture: FixtureData = serde_json::from_str(&contents)?; + Ok(fixture + .workspaces + .into_iter() + .map(|workspace| ExtractedWorkspace { + tokens: BrowserTokens::new(workspace.xoxc, workspace.xoxd), + team_id: workspace.team_id, + team_domain: workspace.team_domain, + team_name: workspace.team_name, + source: workspace.source, + }) + .collect()) + })()) +} + +fn source_matches_browser(source: &str, browser: Option<&str>) -> bool { + match browser { + None => true, + Some(browser) => source + .split('/') + .next() + .is_some_and(|source_browser| source_browser.eq_ignore_ascii_case(browser)), + } +} + /// A single workspace's credentials discovered on the local machine. pub struct ExtractedWorkspace { /// The paired browser tokens (`xoxc` token + `xoxd` cookie). @@ -91,6 +145,28 @@ pub fn discover_workspaces(browser: Option<&str>) -> Vec<DiscoveredWorkspace> { let mut out: Vec<DiscoveredWorkspace> = Vec::new(); let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new(); + if let Some(fixture) = fixture_workspaces() { + for workspace in fixture.unwrap_or_default() { + if !source_matches_browser(&workspace.source, browser) { + continue; + } + let key = match (&workspace.team_id, &workspace.team_domain) { + (Some(id), _) => format!("id:{id}"), + (None, Some(domain)) => format!("dom:{domain}"), + (None, None) => format!("xoxc:{}", workspace.tokens.xoxc), + }; + if seen.insert(key) { + out.push(DiscoveredWorkspace { + team_id: workspace.team_id, + team_domain: workspace.team_domain, + team_name: workspace.team_name, + source: workspace.source, + }); + } + } + return out; + } + for profile in profiles::discover_profiles(browser) { let teams = match chromium::extract_tokens_from_leveldb(&profile.local_storage_leveldb) { Ok(teams) => teams, @@ -118,6 +194,17 @@ pub fn discover_workspaces(browser: Option<&str>) -> Vec<DiscoveredWorkspace> { } pub fn extract_workspaces(opts: &ExtractOptions) -> Result<Vec<ExtractedWorkspace>> { + if let Some(fixture) = fixture_workspaces() { + let workspaces = fixture? + .into_iter() + .filter(|workspace| source_matches_browser(&workspace.source, opts.browser.as_deref())) + .collect(); + return Ok(filter_by_url( + dedup_workspaces(workspaces), + opts.url.as_deref(), + )); + } + let mut workspaces: Vec<ExtractedWorkspace> = Vec::new(); for profile in profiles::discover_profiles(opts.browser.as_deref()) { diff --git a/tests/cli_auth.rs b/tests/cli_auth.rs index 31ed3db..07df1da 100644 --- a/tests/cli_auth.rs +++ b/tests/cli_auth.rs @@ -2,9 +2,138 @@ //! //! Tests for auth subcommand parsing, flag conflicts, and requirements. +use assert_cmd::cargo::cargo_bin_cmd; use clap::Parser; +use mockito::{Matcher, ServerGuard}; +use predicates::prelude::*; +use serde_json::{json, Value}; use slack_cli::cli::auth::AuthCommands; use slack_cli::cli::{Cli, Commands}; +use std::fs; +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +const USER_TOKEN: &str = "xoxp-auth-cli-user-token-123456789"; +const BOT_TOKEN: &str = "xoxb-auth-cli-bot-token-123456789"; +const XOXC_TOKEN: &str = "xoxc-auth-cli-browser-token-123456789"; +const XOXD_TOKEN: &str = "xoxd-auth-cli-cookie-123456789"; + +fn command(server: &ServerGuard, store_path: &Path) -> assert_cmd::Command { + let mut cmd = cargo_bin_cmd!("slack"); + cmd.env("SLACK_API_BASE_URL", server.url()) + .env("SLACK_TOKEN_STORE_PATH", store_path) + .env_remove("SLACK_TOKEN") + .env_remove("SLACK_WORKSPACE") + .env_remove("SLACK_PLAIN") + .env_remove("SLACK_CLIENT_ID") + .env_remove("SLACK_CLIENT_SECRET") + .env_remove("SLACK_EXTRACT_FIXTURE"); + cmd +} + +fn auth_response(team_id: &str, team: &str, user_id: &str, user: &str, domain: &str) -> String { + json!({ + "ok": true, + "team_id": team_id, + "team": team, + "user_id": user_id, + "user": user, + "url": format!("https://{domain}.slack.com/") + }) + .to_string() +} + +async fn mock_auth(server: &mut ServerGuard, token: &str, response: Value) -> mockito::Mock { + server + .mock("POST", "/auth.test") + .match_header("authorization", format!("Bearer {token}").as_str()) + .match_header( + "content-type", + Matcher::Regex("^application/x-www-form-urlencoded".into()), + ) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(response.to_string()) + .create_async() + .await +} + +fn stored_token(store_path: &Path, team_id: &str) -> Value { + let store: Value = serde_json::from_slice(&fs::read(store_path).unwrap()).unwrap(); + store["tokens"][team_id].clone() +} + +fn seed_store(store_path: &Path) { + fs::write( + store_path, + json!({ + "tokens": { + "TALPHA": { + "token_type": "user_o_auth", + "access_token": USER_TOKEN, + "team_id": "TALPHA", + "team_name": "Alpha Team", + "team_domain": "alpha", + "user_id": "UALPHA", + "created_at": "2024-01-01T00:00:00Z", + "scopes": ["channels:read"] + }, + "TBETA": { + "token_type": "bot_o_auth", + "access_token": BOT_TOKEN, + "team_id": "TBETA", + "team_name": "Beta Team", + "team_domain": "beta", + "user_id": "UBETA", + "created_at": "2024-01-02T00:00:00Z", + "scopes": [] + } + }, + "default": "TALPHA", + "workspaces": ["TALPHA", "TBETA"] + }) + .to_string(), + ) + .unwrap(); +} + +fn write_extract_fixture(temp: &TempDir) -> PathBuf { + let path = temp.path().join("extract.json"); + fs::write( + &path, + json!({ + "workspaces": [ + { + "xoxc": XOXC_TOKEN, + "xoxd": XOXD_TOKEN, + "team_id": "TLOCAL", + "team_domain": "local", + "team_name": "Local Name", + "source": "chrome/Default" + }, + { + "xoxc": "xoxc-expired-browser-token-123456789", + "xoxd": "xoxd-expired-cookie-123456789", + "team_id": "TEXPIRED", + "team_domain": "expired", + "team_name": "Expired Local", + "source": "chrome/Profile 1" + }, + { + "xoxc": "xoxc-other-browser-token-123456789", + "xoxd": "xoxd-other-cookie-123456789", + "team_id": "TOTHER", + "team_domain": "other", + "team_name": "Other Browser", + "source": "slack/Default" + } + ] + }) + .to_string(), + ) + .unwrap(); + path +} // ============================================================================ // Auth Add Command Tests @@ -361,3 +490,710 @@ fn test_auth_alias_status() { panic!("Expected Auth command"); } } +#[tokio::test] +async fn auth_add_direct_user_and_bot_tokens_persists_identity_and_type() { + for (token, team_id, token_type) in [ + (USER_TOKEN, "TUSER", "user_o_auth"), + (BOT_TOKEN, "TBOT", "bot_o_auth"), + ] { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + let auth = mock_auth( + &mut server, + token, + json!({ + "ok": true, + "team_id": team_id, + "team": format!("{team_id} Team"), + "user_id": "U12345678", + "user": "alice", + "url": format!("https://{}.slack.com/", team_id.to_ascii_lowercase()) + }), + ) + .await; + + let output = command(&server, &store_path) + .args(["auth", "add", "--token", token]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["added"], true); + assert_eq!(result["team_id"], team_id); + + let stored = stored_token(&store_path, team_id); + assert_eq!(stored["access_token"], token); + assert_eq!(stored["token_type"], token_type); + assert_eq!(stored["team_domain"], team_id.to_ascii_lowercase()); + let store: Value = serde_json::from_slice(&fs::read(&store_path).unwrap()).unwrap(); + assert_eq!(store["default"], team_id); + auth.assert_async().await; + } +} + +#[tokio::test] +async fn auth_add_direct_plain_and_invalid_tokens_cover_error_paths() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + let auth = mock_auth( + &mut server, + USER_TOKEN, + serde_json::from_str(&auth_response( + "TPLAIN", + "Plain Team", + "UPLAIN", + "plain-user", + "plain", + )) + .unwrap(), + ) + .await; + command(&server, &store_path) + .args(["--plain", "auth", "add", "--token", USER_TOKEN]) + .assert() + .success() + .stdout("Added\tTPLAIN\tPlain Team\tUPLAIN\tplain-user\n"); + auth.assert_async().await; + + command(&server, &store_path) + .args(["auth", "add", "--token", "not-a-slack-token"]) + .assert() + .code(1) + .stdout(predicate::str::contains("invalid_token")); + + command(&server, &store_path) + .args(["auth", "add", "--token", XOXC_TOKEN]) + .assert() + .code(1) + .stdout(predicate::str::contains("require --xoxc and --xoxd")); +} + +#[tokio::test] +async fn auth_add_api_failure_is_not_persisted() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + let auth = mock_auth( + &mut server, + USER_TOKEN, + json!({"ok": false, "error": "invalid_auth"}), + ) + .await; + + command(&server, &store_path) + .args(["auth", "add", "--token", USER_TOKEN]) + .assert() + .code(1) + .stdout(predicate::str::contains("invalid_auth")); + assert!(!store_path.exists()); + auth.assert_async().await; +} + +#[tokio::test] +async fn auth_add_browser_tokens_sends_cookie_and_persists_pair() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + let auth = server + .mock("POST", "/auth.test") + .match_header("authorization", format!("Bearer {XOXC_TOKEN}").as_str()) + .match_header("cookie", format!("d={XOXD_TOKEN}").as_str()) + .with_header("content-type", "application/json") + .with_body(auth_response( + "TBROWSER", + "Browser Team", + "UBROWSER", + "browser-user", + "browser", + )) + .create_async() + .await; + + let output = command(&server, &store_path) + .args(["auth", "add", "--xoxc", XOXC_TOKEN, "--xoxd", XOXD_TOKEN]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["token_type"], "Browser"); + let stored = stored_token(&store_path, "TBROWSER"); + assert_eq!(stored["token_type"], "browser"); + assert_eq!(stored["access_token"], XOXC_TOKEN); + assert_eq!(stored["xoxd_cookie"], XOXD_TOKEN); + assert_eq!(stored["team_domain"], "browser"); + auth.assert_async().await; +} + +#[tokio::test] +async fn auth_list_outputs_stored_workspaces_and_checks_live_state() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + seed_store(&store_path); + + let output = command(&server, &store_path) + .args(["auth", "list"]) + .output() + .unwrap(); + assert!(output.status.success()); + let rows: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(rows.as_array().unwrap().len(), 2); + assert_eq!(rows[0]["team_domain"], "alpha"); + + command(&server, &store_path) + .args(["--plain", "auth", "list"]) + .assert() + .success() + .stdout(predicate::str::contains( + "TALPHA\talpha\tAlpha Team\tUserOAuth\t*", + )) + .stdout(predicate::str::contains( + "TBETA\tbeta\tBeta Team\tBotOAuth\t", + )); + + let alpha = mock_auth( + &mut server, + USER_TOKEN, + json!({"ok": true, "team_id": "TALPHA", "team": "Alpha Team", "user_id": "UALPHA", "user": "alice", "url": "https://alpha.slack.com/"}), + ) + .await; + let beta = mock_auth( + &mut server, + BOT_TOKEN, + json!({"ok": false, "error": "invalid_auth"}), + ) + .await; + let output = command(&server, &store_path) + .args(["auth", "list", "--check"]) + .output() + .unwrap(); + assert!(output.status.success()); + let rows: Value = serde_json::from_slice(&output.stdout).unwrap(); + let alpha_row = rows + .as_array() + .unwrap() + .iter() + .find(|row| row["team_id"] == "TALPHA") + .unwrap(); + let beta_row = rows + .as_array() + .unwrap() + .iter() + .find(|row| row["team_id"] == "TBETA") + .unwrap(); + assert_eq!(alpha_row["live"], true); + assert_eq!(beta_row["live"], false); + alpha.assert_async().await; + beta.assert_async().await; +} + +#[tokio::test] +async fn auth_list_check_plain_prints_live_and_expired() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + seed_store(&store_path); + let alpha = mock_auth( + &mut server, + USER_TOKEN, + json!({"ok": true, "team_id": "TALPHA", "team": "Alpha", "user_id": "U", "user": "u", "url": "https://alpha.slack.com/"}), + ) + .await; + let beta = mock_auth( + &mut server, + BOT_TOKEN, + json!({"ok": false, "error": "token_revoked"}), + ) + .await; + command(&server, &store_path) + .args(["--plain", "auth", "list", "--check"]) + .assert() + .success() + .stdout(predicate::str::contains( + "TALPHA\talpha\tAlpha Team\tUserOAuth\t*\tlive", + )) + .stdout(predicate::str::contains( + "TBETA\tbeta\tBeta Team\tBotOAuth\t\texpired", + )); + alpha.assert_async().await; + beta.assert_async().await; +} + +#[tokio::test] +async fn auth_status_uses_default_domain_selector_and_reports_failures() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + seed_store(&store_path); + + let alpha = mock_auth( + &mut server, + USER_TOKEN, + json!({"ok": true, "team_id": "TALPHA", "team": "Alpha Team", "user_id": "UALPHA", "user": "alice", "url": "https://alpha.slack.com/"}), + ) + .await; + let output = command(&server, &store_path) + .args(["auth", "status"]) + .output() + .unwrap(); + assert!(output.status.success()); + let status: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(status["ok"], true); + assert_eq!(status["token_type"], "UserOAuth"); + alpha.assert_async().await; + + let beta = mock_auth( + &mut server, + BOT_TOKEN, + json!({"ok": true, "team_id": "TBETA", "team": "Beta Team", "user_id": "UBETA", "user": "bob", "url": "https://beta.slack.com/"}), + ) + .await; + command(&server, &store_path) + .args(["--plain", "--workspace", "beta", "auth", "status"]) + .assert() + .success() + .stdout("ok\tTBETA\tBeta Team\tUBETA\tbob\n"); + beta.assert_async().await; + + let failure = mock_auth( + &mut server, + USER_TOKEN, + json!({"ok": false, "error": "invalid_auth"}), + ) + .await; + command(&server, &store_path) + .args(["--plain", "auth", "status"]) + .assert() + .code(1) + .stderr(predicate::str::contains( + "error\tSlack API error: invalid_auth", + )); + failure.assert_async().await; +} + +#[tokio::test] +async fn auth_status_rejects_bad_overrides_and_missing_auth() { + let server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + + command(&server, &store_path) + .args(["--token", "invalid", "auth", "status"]) + .assert() + .code(1) + .stdout(predicate::str::contains("invalid_token")); + command(&server, &store_path) + .args(["--token", XOXC_TOKEN, "auth", "status"]) + .assert() + .code(1) + .stdout(predicate::str::contains("Browser tokens require")); + command(&server, &store_path) + .args(["auth", "status"]) + .assert() + .code(1) + .stdout(predicate::str::contains("auth_required")); +} + +#[tokio::test] +async fn auth_switch_by_team_and_domain_then_remove_persists_changes() { + let server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + seed_store(&store_path); + + command(&server, &store_path) + .args(["auth", "switch", "TBETA"]) + .assert() + .success() + .stdout(predicate::str::contains("\"team_id\": \"TBETA\"")); + let store: Value = serde_json::from_slice(&fs::read(&store_path).unwrap()).unwrap(); + assert_eq!(store["default"], "TBETA"); + + command(&server, &store_path) + .args(["--plain", "auth", "switch", "alpha"]) + .assert() + .success() + .stdout("Switched\tTALPHA\tAlpha Team\n"); + + command(&server, &store_path) + .args(["--plain", "auth", "remove", "beta"]) + .assert() + .success() + .stdout("Removed\tTBETA\tBeta Team\n") + .stderr("Removing workspace: Beta Team (TBETA)\n"); + let store: Value = serde_json::from_slice(&fs::read(&store_path).unwrap()).unwrap(); + assert!(store["tokens"].get("TBETA").is_none()); + assert_eq!(store["workspaces"], json!(["TALPHA"])); +} + +#[tokio::test] +async fn auth_remove_json_yes_and_unknown_workspace_paths() { + let server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + seed_store(&store_path); + let output = command(&server, &store_path) + .args(["auth", "remove", "TALPHA", "--yes"]) + .output() + .unwrap(); + assert!(output.status.success()); + let removed: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(removed["removed"], true); + assert_eq!(removed["team_name"], "Alpha Team"); + + command(&server, &store_path) + .args(["auth", "switch", "missing"]) + .assert() + .code(1) + .stdout(predicate::str::contains("workspace_not_found")); + command(&server, &store_path) + .args(["auth", "remove", "missing", "--yes"]) + .assert() + .code(1) + .stdout(predicate::str::contains("workspace_not_found")); +} + +#[tokio::test] +async fn auth_browser_help_and_oauth_configuration_hint() { + let server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + command(&server, &store_path) + .args(["auth", "browser-help"]) + .assert() + .success() + .stdout(predicate::str::contains("BROWSER TOKEN EXTRACTION GUIDE")) + .stdout(predicate::str::contains("slack auth add --xoxc")); + command(&server, &store_path) + .args(["auth", "add"]) + .assert() + .code(1) + .stdout(predicate::str::contains("SLACK_CLIENT_ID")); +} + +#[tokio::test] +async fn auth_discover_uses_fixture_and_browser_filter() { + let server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + let fixture = write_extract_fixture(&temp); + let output = command(&server, &store_path) + .env("SLACK_EXTRACT_FIXTURE", &fixture) + .args(["auth", "discover", "--browser", "slack"]) + .output() + .unwrap(); + assert!(output.status.success()); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["count"], 1); + assert_eq!(result["workspaces"][0]["team_id"], "TOTHER"); + assert!(result["workspaces"][0].get("live").is_none()); + + command(&server, &store_path) + .env("SLACK_EXTRACT_FIXTURE", &fixture) + .args(["--plain", "auth", "discover", "--browser", "slack"]) + .assert() + .success() + .stdout("other\tTOTHER\tOther Browser\tslack/Default\n"); +} + +#[tokio::test] +async fn auth_discover_check_marks_fixture_sessions_live_or_expired() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + let fixture = write_extract_fixture(&temp); + let live = mock_auth( + &mut server, + XOXC_TOKEN, + json!({"ok": true, "team_id": "TLOCAL", "team": "Authoritative Name", "user_id": "ULOCAL", "user": "local-user", "url": "https://local.slack.com/"}), + ) + .await; + let expired = mock_auth( + &mut server, + "xoxc-expired-browser-token-123456789", + json!({"ok": false, "error": "invalid_auth"}), + ) + .await; + let output = command(&server, &store_path) + .env("SLACK_EXTRACT_FIXTURE", &fixture) + .args(["auth", "discover", "--browser", "chrome", "--check"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["count"], 2); + assert_eq!(result["workspaces"][0]["team_name"], "Authoritative Name"); + assert_eq!(result["workspaces"][0]["live"], true); + assert_eq!(result["workspaces"][1]["live"], false); + assert_eq!(stored_token(&store_path, "TLOCAL")["token_type"], "browser"); + live.assert_async().await; + expired.assert_async().await; +} + +#[tokio::test] +async fn auth_add_subdomain_imports_only_matching_fixture_workspace() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + let fixture = write_extract_fixture(&temp); + let auth = mock_auth( + &mut server, + XOXC_TOKEN, + json!({"ok": true, "team_id": "TLOCAL", "team": "Imported Team", "user_id": "ULOCAL", "user": "imported-user", "url": "https://local.slack.com/"}), + ) + .await; + let output = command(&server, &store_path) + .env("SLACK_EXTRACT_FIXTURE", &fixture) + .args(["auth", "add", "local.slack.com"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + let result: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(result["added_count"], 1); + assert_eq!(result["errored_count"], 0); + assert_eq!(result["added"][0]["source"], "chrome/Default"); + assert_eq!( + stored_token(&store_path, "TLOCAL")["team_name"], + "Imported Team" + ); + auth.assert_async().await; +} + +#[tokio::test] +async fn auth_add_from_browser_reports_partial_and_total_failures() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + let fixture_path = temp.path().join("one-fixture.json"); + fs::write( + &fixture_path, + json!({"workspaces": [{ + "xoxc": XOXC_TOKEN, + "xoxd": XOXD_TOKEN, + "team_id": "TLOCAL", + "team_domain": "local", + "team_name": "Local Name", + "source": "chrome/Default" + }]}) + .to_string(), + ) + .unwrap(); + let failed = mock_auth( + &mut server, + XOXC_TOKEN, + json!({"ok": false, "error": "invalid_auth"}), + ) + .await; + command(&server, &store_path) + .env("SLACK_EXTRACT_FIXTURE", &fixture_path) + .args(["--plain", "auth", "add", "--from-browser"]) + .assert() + .code(1) + .stderr(predicate::str::contains( + "Error\tLocal Name\tchrome/Default", + )) + .stderr(predicate::str::contains("none could be validated")); + failed.assert_async().await; + + command(&server, &store_path) + .env("SLACK_EXTRACT_FIXTURE", &fixture_path) + .args(["auth", "add", "missing"]) + .assert() + .code(1) + .stdout(predicate::str::contains("matching 'missing' was found")); +} + +#[tokio::test] +async fn auth_fixture_parse_error_and_storage_failure_are_reported() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let bad_fixture = temp.path().join("bad.json"); + fs::write(&bad_fixture, "not json").unwrap(); + let store_path = temp.path().join("tokens.json"); + command(&server, &store_path) + .env("SLACK_EXTRACT_FIXTURE", &bad_fixture) + .args(["auth", "add", "--from-browser"]) + .assert() + .code(1) + .stdout(predicate::str::contains("serialization_error")); + + let blocked_parent = temp.path().join("blocked"); + fs::write(&blocked_parent, "file, not directory").unwrap(); + let blocked_store = blocked_parent.join("tokens.json"); + let auth = mock_auth( + &mut server, + USER_TOKEN, + json!({"ok": true, "team_id": "TBLOCKED", "team": "Blocked", "user_id": "U", "user": "u", "url": "https://blocked.slack.com/"}), + ) + .await; + command(&server, &blocked_store) + .args(["auth", "add", "--token", USER_TOKEN]) + .assert() + .code(1) + .stderr(predicate::str::contains("Troubleshooting:")) + .stdout(predicate::str::contains("io_error")); + auth.assert_async().await; +} + +#[tokio::test] +async fn auth_status_token_override_and_json_api_failure() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + let ok = mock_auth( + &mut server, + USER_TOKEN, + json!({"ok": true, "team_id": "TOVERRIDE", "team": "Override", "user_id": "UOVERRIDE", "user": "override", "url": "https://override.slack.com/"}), + ) + .await; + command(&server, &store_path) + .args(["--token", USER_TOKEN, "auth", "status"]) + .assert() + .success() + .stdout(predicate::str::contains("TOVERRIDE")); + ok.assert_async().await; + + seed_store(&store_path); + let failed = mock_auth( + &mut server, + USER_TOKEN, + json!({"ok": false, "error": "account_inactive"}), + ) + .await; + command(&server, &store_path) + .args(["auth", "status"]) + .assert() + .code(1) + .stdout(predicate::str::contains("\"ok\": false")) + .stdout(predicate::str::contains("account_inactive")); + failed.assert_async().await; + + command(&server, &store_path) + .args(["--workspace", "missing", "auth", "status"]) + .assert() + .code(1) + .stdout(predicate::str::contains("workspace_not_found")); +} + +#[tokio::test] +async fn auth_browser_plain_and_import_plain_success() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let first_store = temp.path().join("manual-tokens.json"); + let manual = mock_auth( + &mut server, + XOXC_TOKEN, + json!({"ok": true, "team_id": "TMANUAL", "team": "Manual Browser", "user_id": "UMANUAL", "user": "manual", "url": "https://manual.slack.com/"}), + ) + .await; + command(&server, &first_store) + .args([ + "--plain", "auth", "add", "--xoxc", XOXC_TOKEN, "--xoxd", XOXD_TOKEN, + ]) + .assert() + .success() + .stdout("Added\tTMANUAL\tManual Browser\tUMANUAL\tmanual\n"); + manual.assert_async().await; + + let fixture = write_extract_fixture(&temp); + let import_store = temp.path().join("import-tokens.json"); + let imported = mock_auth( + &mut server, + XOXC_TOKEN, + json!({"ok": true, "team_id": "TLOCAL", "team": "Imported", "user_id": "ULOCAL", "user": "local", "url": "https://local.slack.com/"}), + ) + .await; + command(&server, &import_store) + .env("SLACK_EXTRACT_FIXTURE", fixture) + .args(["--plain", "auth", "add", "local"]) + .assert() + .success() + .stdout("Added\tTLOCAL\tImported\tULOCAL\tlocal\n"); + imported.assert_async().await; +} + +#[tokio::test] +async fn auth_discover_check_plain_and_empty_import_without_url() { + let mut server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + let fixture = write_extract_fixture(&temp); + let live = mock_auth( + &mut server, + XOXC_TOKEN, + json!({"ok": true, "team_id": "TLOCAL", "team": "Checked Name", "user_id": "U", "user": "u", "url": "https://local.slack.com/"}), + ) + .await; + let expired = mock_auth( + &mut server, + "xoxc-expired-browser-token-123456789", + json!({"ok": false, "error": "invalid_auth"}), + ) + .await; + command(&server, &store_path) + .env("SLACK_EXTRACT_FIXTURE", fixture) + .args([ + "--plain", + "auth", + "discover", + "--browser", + "chrome", + "--check", + ]) + .assert() + .success() + .stdout(predicate::str::contains( + "local\tTLOCAL\tChecked Name\tchrome/Default\tlive", + )) + .stdout(predicate::str::contains( + "expired\tTEXPIRED\tExpired Local\tchrome/Profile 1\texpired", + )); + live.assert_async().await; + expired.assert_async().await; + + let empty_fixture = temp.path().join("empty.json"); + fs::write(&empty_fixture, r#"{"workspaces":[]}"#).unwrap(); + command(&server, &store_path) + .env("SLACK_EXTRACT_FIXTURE", empty_fixture) + .args(["auth", "add", "--from-browser"]) + .assert() + .code(1) + .stdout(predicate::str::contains( + "No locally logged-in Slack workspaces were found", + )); +} + +#[tokio::test] +async fn auth_add_rejects_malformed_prefixed_tokens_before_network_io() { + let server = mockito::Server::new_async().await; + let temp = TempDir::new().unwrap(); + let store_path = temp.path().join("tokens.json"); + for args in [ + vec!["auth", "add", "--token", "xoxp-a"], + vec!["auth", "add", "--xoxc", "xoxc-a", "--xoxd", XOXD_TOKEN], + ] { + command(&server, &store_path) + .args(args) + .assert() + .code(1) + .stdout(predicate::str::contains("invalid_token")); + } +} From 9234cb76504ec3578be229d77e6e16eebae73d6f Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Thu, 10 Sep 2026 08:31:39 +1000 Subject: [PATCH 21/22] fix(edge): reject non-2xx responses before decoding; docs: exit-code table matches actual behaviour --- .gitignore | 1 + AGENTS.md | 13 +++++----- CHANGELOG.md | 12 +++++++++ README.md | 18 ++++++++----- src/api/edge.rs | 39 ++++++++++++++++++---------- tests/api_edge.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++-- 6 files changed, 121 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index a15a0b2..37eb6b7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ run-ralph-loop.sh /.claude /.codex /.pi +/.aislop diff --git a/AGENTS.md b/AGENTS.md index 964a591..9a594da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -73,12 +73,13 @@ CI runs on every push/PR to main. These are the exact checks: | Code | Meaning | |------|---------| | 0 | Success | -| 1 | General error | -| 2 | Authentication required | -| 3 | Invalid arguments | -| 4 | API error | -| 5 | Rate limited | -| 6 | Network error | +| 1 | Any runtime failure (auth required, API `ok: false`, rate limited, network, not found, …) | +| 2 | Usage error (invalid arguments/flags, including clap parse errors) | + +Exit codes are defined in `SlackError::exit_code` (`src/error/types.rs`). The +JSON error object's `code` field (`SlackError::code`) is the stable, +machine-readable discriminator — do not add new exit codes without updating +this table, README.md, and the CLI tests that assert them. ## Release diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c9383..f7fa2d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 app OAuth flow by default, document browser/manual routes, callback setup, credential errors, token storage, and explicit scope replacement. +### Fixed + +- **Edge API HTTP status handling**: `EdgeClient` decoded the response body + before checking the HTTP status, so a gateway/proxy error carrying a + plausible `ok: true` payload could be accepted as success. Non-2xx responses + are now reported as `api_error` with `HTTP <status>` and the (truncated) body + as `detail`, matching the Web API client. +- **Exit-code documentation**: README/AGENTS.md documented exit codes 2–6 for + auth/usage/API/rate-limit/network failures that the CLI never emitted. The + CLI exits `1` for any runtime failure and `2` for usage errors; the JSON + `code` field is the machine-readable discriminator. Docs now say so. + ## [0.2.1] - 2026-09-08 ### Fixed diff --git a/README.md b/README.md index 2b4c3b0..a12a55f 100644 --- a/README.md +++ b/README.md @@ -705,12 +705,18 @@ slack channels list --plain | Code | Meaning | |------|---------| | 0 | Success | -| 1 | General error | -| 2 | Authentication required | -| 3 | Invalid arguments | -| 4 | API error | -| 5 | Rate limited | -| 6 | Network error | +| 1 | Any runtime failure: authentication required, API error (`ok: false`), rate limited, network error, not found, etc. | +| 2 | Usage error: invalid arguments or flags (including errors reported by the argument parser) | + +The exit code only distinguishes usage errors from runtime failures. To find +out *what* failed, read the JSON error object on stdout — its `code` field is +stable (`auth_required`, `api_error`, `rate_limited`, `network_error`, +`channel_not_found`, `user_not_found`, `search_not_available`, `usage_error`, +…) and `detail` carries the Slack error string when there is one: + +```json +{"error": true, "code": "auth_required", "message": "Authentication required. Run: slack auth add", "detail": null} +``` ## Development diff --git a/src/api/edge.rs b/src/api/edge.rs index f24fb76..3bff9e0 100644 --- a/src/api/edge.rs +++ b/src/api/edge.rs @@ -119,10 +119,7 @@ impl EdgeClient { .await .map_err(SlackError::Network)?; - let body: EdgeApiResponse<ClientBootResponse> = - response.json().await.map_err(SlackError::Network)?; - - body.into_result() + decode_response::<ClientBootResponse>(response).await } /// Get conversation/channel information via Edge API @@ -145,10 +142,7 @@ impl EdgeClient { .await .map_err(SlackError::Network)?; - let body: EdgeApiResponse<ConversationViewResponse> = - response.json().await.map_err(SlackError::Network)?; - - body.into_result() + decode_response::<ConversationViewResponse>(response).await } /// Search channels via Edge API @@ -170,10 +164,7 @@ impl EdgeClient { .await .map_err(SlackError::Network)?; - let body: EdgeApiResponse<SearchChannelsResponse> = - response.json().await.map_err(SlackError::Network)?; - - body.into_result() + decode_response::<SearchChannelsResponse>(response).await } /// Make a generic Edge API request @@ -194,10 +185,30 @@ impl EdgeClient { .await .map_err(SlackError::Network)?; - let body: EdgeApiResponse<T> = response.json().await.map_err(SlackError::Network)?; + decode_response::<T>(response).await + } +} - body.into_result() +/// Decode an Edge API HTTP response. +/// +/// A non-2xx status is reported as an API error *before* the body is +/// decoded, so a gateway/proxy error page (or a stale cached payload) is +/// never mistaken for a successful Edge response. +async fn decode_response<T>(response: reqwest::Response) -> Result<T> +where + T: for<'de> Deserialize<'de>, +{ + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let detail = body.trim(); + return Err(SlackError::Api { + error: format!("HTTP {}", status), + detail: (!detail.is_empty()).then(|| detail.chars().take(300).collect()), + }); } + let body: EdgeApiResponse<T> = response.json().await.map_err(SlackError::Network)?; + body.into_result() } /// Generic Edge API response wrapper diff --git a/tests/api_edge.rs b/tests/api_edge.rs index c517f51..ab49a9b 100644 --- a/tests/api_edge.rs +++ b/tests/api_edge.rs @@ -211,7 +211,7 @@ async fn non_json_response_maps_to_network_error() { } #[tokio::test] -async fn non_success_http_status_with_non_edge_body_maps_to_network_error() { +async fn non_success_http_status_is_an_api_error_with_status_and_body_detail() { let mut server = Server::new_async().await; let request = authenticated_mock( &mut server, @@ -228,6 +228,68 @@ async fn non_success_http_status_with_non_edge_body_maps_to_network_error() { .search_channels("rust", 10) .await .unwrap_err(); - assert!(matches!(error, SlackError::Network(_)), "got {error:?}"); + match error { + SlackError::Api { error, detail } => { + assert_eq!(error, "HTTP 503 Service Unavailable"); + assert_eq!( + detail.as_deref(), + Some(r#"{"message":"service unavailable"}"#) + ); + } + other => panic!("expected API error, got {other:?}"), + } + request.assert_async().await; +} + +#[tokio::test] +async fn non_success_http_status_rejects_an_otherwise_valid_ok_true_body() { + // A gateway/proxy or stale cache can return a 2xx-looking Edge payload + // under a failing status; the status must win. + let mut server = Server::new_async().await; + let request = authenticated_mock( + &mut server, + "channels.search", + json!({"query":"rust","count":10}), + ) + .with_status(502) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true,"results":[]}"#) + .create_async() + .await; + + let error = edge_client(&server) + .search_channels("rust", 10) + .await + .unwrap_err(); + assert!( + matches!(error, SlackError::Api { ref error, .. } if error.starts_with("HTTP 502")), + "got {error:?}" + ); + request.assert_async().await; +} + +#[tokio::test] +async fn non_success_http_status_with_empty_body_has_no_detail() { + let mut server = Server::new_async().await; + let request = authenticated_mock( + &mut server, + "channels.search", + json!({"query":"rust","count":10}), + ) + .with_status(500) + .create_async() + .await; + + let error = edge_client(&server) + .search_channels("rust", 10) + .await + .unwrap_err(); + match error { + SlackError::Api { error, detail } => { + assert_eq!(error, "HTTP 500 Internal Server Error"); + assert_eq!(detail, None); + } + other => panic!("expected API error, got {other:?}"), + } request.assert_async().await; } From 8875ff18c0e65726c989ef78a6a830d21e18c18b Mon Sep 17 00:00:00 2001 From: Chris Raethke <chris@codesoda.com> Date: Fri, 11 Sep 2026 12:10:19 +1000 Subject: [PATCH 22/22] fix(files): validate upload paths before opening --- src/cli/files.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/cli/files.rs b/src/cli/files.rs index e4df0f4..e08bb55 100644 --- a/src/cli/files.rs +++ b/src/cli/files.rs @@ -333,13 +333,16 @@ async fn upload_file( } let filename = upload_filename(path, filename_override)?; - let mut file = std::fs::File::open(path)?; - if !file.metadata()?.is_file() { + // Check the path before opening it: on Windows, opening a directory returns + // an access-denied error instead of a file handle whose metadata we can + // inspect. + if !std::fs::metadata(path)?.is_file() { return Err(SlackError::Usage(format!( "upload path is not a regular file: {}", path.display() ))); } + let mut file = std::fs::File::open(path)?; let mut bytes = Vec::new(); file.read_to_end(&mut bytes)?;