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/.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 4104782..9a594da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,17 +33,17 @@ - Verify linting: `cargo clippy --all-targets --all-features -- -D warnings` - Run targeted tests while iterating: `cargo test ` - 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` 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) @@ -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 2780de2..f7fa2d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **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 + 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 + +- **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. + +### 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 ` 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/Cargo.toml b/Cargo.toml index 6987221..7a49006 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ name = "slack-cli" version = "0.2.1" edition = "2021" rust-version = "1.78" +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/README.md b/README.md index 45139c9..a12a55f 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 @@ -69,6 +70,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 +130,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` / @@ -176,6 +228,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 @@ -210,6 +319,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 @@ -231,6 +363,62 @@ Inline code `` `…` ``, fenced code blocks ```` ``` ````, and existing mrkdwn spans (`<@U…>` mentions, `` 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 +`idchannel_idpost_attext`. + ### Users (`slack users` or `slack u`) ```bash @@ -243,14 +431,29 @@ 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" +slack messages send U123456789 "Hello by user ID" + +# 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 @@ -260,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 @@ -283,6 +505,51 @@ 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. + +### 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 @@ -290,12 +557,12 @@ slack reactions list C123456789 1234567890.123456 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 @@ -312,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 @@ -379,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 @@ -437,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/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 ` 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/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/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/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:<name>` value. Plain output is sorted by +name and uses `name<TAB>value` 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/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/MESSAGES.md b/skills/slack/MESSAGES.md index 4b2c00e..f121586 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,66 @@ 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 messages list "#general" --resolve-users +slack messages thread C1234567890 1234567890.123456 --resolve-users +slack messages search "review" --resolve-users +``` + +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. + +## 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 users info U090BKEQXMH # full user record (JSON) -slack --plain users info U090BKEQXMH # TSV +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 ``` -Note: with browser tokens, `real_name` may be empty — prefer the profile's -`display_name`, falling back to `real_name`. +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/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 +type<TAB>message-ts-or-file-id<TAB>author-id<TAB>text-or-title +``` + +Missing fields are empty, and an empty list produces no plain rows. 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 a56a7d9..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:*) @@ -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) @@ -148,6 +166,27 @@ 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 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 +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 @@ -167,35 +206,121 @@ slack messages search "deploy failed" --in-channel "#ops" slack messages search "from:@alice budget" ``` -### List channels +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 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 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). + +### Manage pins +```bash +slack pins add "#general" 1234567890.123456 +slack pins remove "#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). + +### 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 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/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` | +| [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/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/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 `id<TAB>user_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/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/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/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/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 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/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/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<String, serde_json::Value>, +} + +fn deserialize_user_count<'de, D>(deserializer: D) -> std::result::Result<u64, D::Error> +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<Usergroup>, +} + +/// 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<String>, +} + +impl SlackClient { + /// Open or find a direct-message conversation with one user. + pub async fn conversations_open(&self, user_id: &str) -> Result<ConversationsOpenResponse> { + 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<User> { + 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<UsergroupsListResponse> { + self.request("usergroups.list", ¶ms).await + } + + /// List member IDs for a user group. + pub async fn usergroups_users_list( + &self, + params: UsergroupsUsersListParams<'_>, + ) -> Result<UsergroupsUsersListResponse> { + self.request("usergroups.users.list", ¶ms).await + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 3ddd2ee..8c61c6e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -7,8 +7,14 @@ //! - Response types and API method parameters //! - Name resolution helpers +pub mod bookmark_ops; +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; 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<PinItem>, +} + +/// Response from `emoji.list`. +#[derive(Debug, Serialize, Deserialize)] +pub struct EmojiListResponse { + /// Custom emoji names mapped to image URLs or `alias:<name>` values. + #[serde(default)] + pub emoji: BTreeMap<String, String>, +} + +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<PinsListResponse> { + self.request("pins.list", &ChannelParams { channel }).await + } + + /// List custom emoji for the workspace. + pub async fn emoji_list(&self) -> Result<EmojiListResponse> { + self.request("emoji.list", &EmptyParams {}).await + } +} 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<User> { + 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/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/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/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 { .. })); + } } 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()); } } 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/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/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/files.rs b/src/cli/files.rs index 95812a3..e08bb55 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,190 @@ 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)?; + // 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)?; + + // 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 +622,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/src/cli/messages.rs b/src/cli/messages.rs index 62cb595..ab477c4 100644 --- a/src/cli/messages.rs +++ b/src/cli/messages.rs @@ -14,6 +14,9 @@ use crate::models::Message; 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)] pub struct MessagesCmd { @@ -38,8 +41,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,14 +80,18 @@ 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 Send { - /// Channel name or ID + /// Channel name, channel ID, @user, or user ID channel: String, - /// Message text (optional if using --stdin) + /// Message text (optional if using --stdin or --blocks) text: Option<String>, /// Read message from stdin @@ -88,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 @@ -130,6 +202,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 +223,58 @@ 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 { + /// 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 +304,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 +327,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 { @@ -206,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, @@ -231,6 +410,9 @@ pub async fn run( threads_only, count, page, + sort, + sort_dir, + resolve_users, } => { let query_params = SearchQueryParams { query, @@ -246,6 +428,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,196 +443,293 @@ 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() + } } -/// Send a message -/// -/// If `mark_read` is true, marks the channel as read after sending. -#[allow(clippy::too_many_arguments)] -async fn send_message( +async fn load_user_directory( client: &SlackClient, - channel: &str, - text: Option<String>, + 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?, + ))) +} + +/// Options for sending immediately or scheduling a message. +struct SendOptions<'a> { from_stdin: bool, - thread_ts: Option<&str>, + 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>, + 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(); @@ -525,6 +807,9 @@ struct SearchParams<'a> { query_params: SearchQueryParams<'a>, count: u32, page: u32, + sort: SearchSort, + sort_dir: SearchSortDirection, + resolve_users: bool, } /// Search messages @@ -544,32 +829,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 @@ -592,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() @@ -601,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 { @@ -623,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( @@ -648,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: @@ -665,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(), )); @@ -673,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.) @@ -737,27 +1018,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 +1136,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 +1158,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 +1276,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 +1415,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 +1438,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([ @@ -1160,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/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/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/mod.rs b/src/cli/mod.rs index 649d3f3..b91d9ee 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,22 +4,29 @@ pub mod api; pub mod auth; +pub mod bookmarks; 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; pub mod status; +pub mod usergroups; 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; 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/reminders.rs b/src/cli/reminders.rs index f976d1d..cd12c92 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()) @@ -405,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; @@ -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"); @@ -548,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/src/cli/root.rs b/src/cli/root.rs index 86fbba0..20edc14 100644 --- a/src/cli/root.rs +++ b/src/cli/root.rs @@ -6,10 +6,13 @@ 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; 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 +78,15 @@ pub enum Commands { #[command(alias = "r")] Reactions(ReactionsCmd), + /// Pin operations + Pins(PinsCmd), + + /// Custom emoji operations + Emoji(EmojiCmd), + + /// Channel bookmark operations + Bookmarks(BookmarksCmd), + /// User status/presence #[command(alias = "s")] Status(StatusCmd), 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<String, String> = 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<ResolvedMember> = 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<Vec<User>> { + let mut users = Vec::new(); + let mut cursor: Option<String> = 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<String> { + 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/src/main.rs b/src/main.rs index 6aebf51..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, files, generate_completions, messages, 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; @@ -117,6 +117,33 @@ 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::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/api_edge.rs b/tests/api_edge.rs new file mode 100644 index 0000000..ab49a9b --- /dev/null +++ b/tests/api_edge.rs @@ -0,0 +1,295 @@ +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_is_an_api_error_with_status_and_body_detail() { + 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(); + 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; +} 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; +} 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")); + } +} 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; +} 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; +} 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; +} 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"); +} 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; +} 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; +} 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::<Value>(&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::<Value>(&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::<Value>(&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; +} 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_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; +} 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; +} 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\"", + )); +} diff --git a/tests/fixtures/extract/local_config.log b/tests/fixtures/extract/local_config.log new file mode 100644 index 0000000..ef0bce7 Binary files /dev/null and b/tests/fixtures/extract/local_config.log differ diff --git a/tests/fixtures/extract/minimal_sstable.ldb b/tests/fixtures/extract/minimal_sstable.ldb new file mode 100644 index 0000000..0902e70 Binary files /dev/null and b/tests/fixtures/extract/minimal_sstable.ldb differ