From bf28b898aeb3f6fc2be491ba51466c36c168f590 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Sun, 10 May 2026 16:38:17 -0700 Subject: [PATCH 01/10] feat: add `slack api` command for calling Slack API methods directly Adds a new top-level `slack api [key=value ...] [flags]` command that calls any Slack API method with automatic token resolution, body format detection, and response formatting. Token resolution priority: --token flag, --app/--team flags (via AppSelectPrompt in project), SLACK_BOT_TOKEN env, SLACK_USER_TOKEN env, interactive prompt fallback. Supports form-encoded key=value params, JSON auto-detection, --json and --data flags, custom headers (-H), HTTP method override (-X), response header display (--include), and TTY-aware pretty printing. --- cmd/api/api.go | 284 +++++++++++++++ cmd/api/api_test.go | 649 +++++++++++++++++++++++++++++++++++ cmd/root.go | 2 + internal/api/generic.go | 127 +++++++ internal/api/generic_test.go | 139 ++++++++ 5 files changed, 1201 insertions(+) create mode 100644 cmd/api/api.go create mode 100644 cmd/api/api_test.go create mode 100644 internal/api/generic.go create mode 100644 internal/api/generic_test.go diff --git a/cmd/api/api.go b/cmd/api/api.go new file mode 100644 index 00000000..1291aac4 --- /dev/null +++ b/cmd/api/api.go @@ -0,0 +1,284 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "strings" + + "github.com/slackapi/slack-cli/internal/api" + "github.com/slackapi/slack-cli/internal/config" + "github.com/slackapi/slack-cli/internal/prompts" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +type cmdFlags struct { + method string + json string + data string + headers []string + include bool +} + +var flags cmdFlags + +func NewCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "api [key=value ...] [flags]", + Short: "Call any Slack API method", + Long: strings.Join([]string{ + "Call any Slack API method directly.", + "", + "The method argument is the Slack API method name (e.g., \"chat.postMessage\").", + "Parameters are passed as key=value pairs, a JSON body, or via flags.", + "", + "Body format is auto-detected from positional arguments:", + " - Multiple key=value args: form-encoded (token in request body)", + " - Single arg starting with { or [: JSON (Bearer token in header)", + " - No args: token sent in Authorization header", + "", + "Use --json to explicitly send a JSON body, or --data for a form-encoded body string.", + "", + "Token resolution (in priority order):", + " 1. --token flag Explicit token value", + " 2. --app / --team flags Install app and use bot token (in project)", + " 3. SLACK_BOT_TOKEN env var Bot token (set during slack deploy)", + " 4. SLACK_USER_TOKEN env var User token", + " 5. Interactive prompt Select from stored workspaces (CLI tooling token)", + "", + "See all methods at: https://docs.slack.dev/reference/methods", + "", + "Common methods:", + " api.test Test your API connection", + " auth.test Check authentication", + " chat.postMessage Send a message to a channel", + " chat.update Update a message", + " chat.delete Delete a message", + " conversations.list List channels", + " conversations.history Fetch messages from a channel", + " conversations.info Get channel details", + " conversations.members List members in a channel", + " conversations.create Create a channel", + " users.list List workspace members", + " users.info Get user details", + " files.upload Upload a file", + " reactions.add Add an emoji reaction", + " reactions.list List reactions for a user", + " bookmarks.add Add a bookmark to a channel", + " pins.add Pin a message", + " views.open Open a modal view", + " views.update Update a modal view", + }, "\n"), + Example: style.ExampleCommandsf([]style.ExampleCommand{ + {Command: "api auth.test", Meaning: "Test authentication with the current workspace"}, + {Command: "api chat.postMessage channel=C0123456 text=\"Hello\"", Meaning: "Post a message"}, + {Command: "api users.list --team myworkspace", Meaning: "List users in a specific workspace"}, + {Command: `api chat.postMessage --json '{"channel":"C0123456","text":"Hello"}'`, Meaning: "Send a JSON body"}, + {Command: "api auth.test --include", Meaning: "Show HTTP status and response headers"}, + {Command: "api conversations.history -X GET channel=C0123456", Meaning: "Use GET method"}, + }), + Args: cobra.MinimumNArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + clients.Config.SetFlags(cmd) + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runAPICommand(cmd, clients, args) + }, + } + + cmd.Flags().StringVarP(&flags.method, "method", "X", "POST", "HTTP method for the request") + cmd.Flags().StringVar(&flags.json, "json", "", "JSON request body (uses Bearer token in Authorization header)") + cmd.Flags().StringVar(&flags.data, "data", "", "form-encoded request body string (e.g. \"key1=val1&key2=val2\")") + cmd.Flags().StringSliceVarP(&flags.headers, "header", "H", nil, "additional HTTP headers (format: \"Key: Value\")") + cmd.Flags().BoolVarP(&flags.include, "include", "i", false, "include HTTP status code and response headers in output") + cmd.MarkFlagsMutuallyExclusive("json", "data") + + return cmd +} + +func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []string) error { + ctx := cmd.Context() + method := args[0] + params := args[1:] + + token, err := resolveToken(ctx, clients) + if err != nil { + return err + } + + apiHost := clients.Config.APIHostResolved + if apiHost == "" { + apiHost = "https://slack.com" + } + apiClient := api.NewClient(nil, apiHost, clients.IO) + + var bodyReader *strings.Reader + var contentType string + + switch { + case flags.json != "": + contentType = "application/json; charset=utf-8" + bodyReader = strings.NewReader(flags.json) + case flags.data != "": + contentType = "application/x-www-form-urlencoded" + formData := flags.data + if !strings.Contains(formData, "token=") { + if formData != "" { + formData = formData + "&token=" + url.QueryEscape(token) + } else { + formData = "token=" + url.QueryEscape(token) + } + } + bodyReader = strings.NewReader(formData) + token = "" + case len(params) == 1 && (strings.HasPrefix(params[0], "{") || strings.HasPrefix(params[0], "[")): + contentType = "application/json; charset=utf-8" + bodyReader = strings.NewReader(params[0]) + case len(params) > 0: + contentType = "application/x-www-form-urlencoded" + values := url.Values{} + values.Set("token", token) + for _, param := range params { + key, value, ok := strings.Cut(param, "=") + if !ok { + return slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("invalid parameter %q: must be in key=value format", param) + } + values.Set(key, value) + } + bodyReader = strings.NewReader(values.Encode()) + token = "" + default: + contentType = "application/x-www-form-urlencoded" + values := url.Values{} + values.Set("token", token) + bodyReader = strings.NewReader(values.Encode()) + token = "" + } + + customHeaders := map[string]string{} + for _, h := range flags.headers { + key, value, ok := strings.Cut(h, ":") + if !ok { + return slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("invalid header %q: must be in \"Key: Value\" format", h) + } + customHeaders[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + + resp, err := apiClient.RawRequest(ctx, flags.method, method, token, bodyReader, contentType, customHeaders) + if err != nil { + return err + } + + if flags.include { + fmt.Fprintf(cmd.OutOrStdout(), "HTTP %d\n", resp.StatusCode) + for key, values := range resp.Header { + for _, v := range values { + fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", key, v) + } + } + fmt.Fprintln(cmd.OutOrStdout()) + } + + output := resp.Body + // Pretty-print for interactive terminals, compact for piped output (gh/git convention) + if clients.IO.IsTTY() { + var indented bytes.Buffer + if json.Indent(&indented, resp.Body, "", " ") == nil { + output = indented.Bytes() + } + } + fmt.Fprint(cmd.OutOrStdout(), string(output)) + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return slackerror.New("api_request_failed"). + WithMessage("API request failed with status %d", resp.StatusCode) + } + + return nil +} + +func resolveToken(ctx context.Context, clients *shared.ClientFactory) (string, error) { + if clients.Config.TokenFlag != "" { + return clients.Config.TokenFlag, nil + } + + if sdkConfigExists, _ := clients.SDKConfig.Exists(); sdkConfigExists { + selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly) + if err == nil && selected.App.AppID != "" { + token, err := installAndGetBotToken(ctx, clients, selected) + if err == nil && token != "" { + return token, nil + } + } + } + + if token := os.Getenv("SLACK_BOT_TOKEN"); token != "" { + return token, nil + } + + if token := os.Getenv("SLACK_USER_TOKEN"); token != "" { + return token, nil + } + + clients.IO.PrintDebug(ctx, "Using CLI tooling token which has limited API scopes. Set SLACK_BOT_TOKEN or use --token for full access.") + auth, err := prompts.PromptTeamSlackAuth(ctx, clients, "Select a workspace") + if err != nil { + return "", err + } + return auth.Token, nil +} + +func installAndGetBotToken(ctx context.Context, clients *shared.ClientFactory, selected prompts.SelectedApp) (string, error) { + manifestSource, _ := clients.Config.ProjectConfig.GetManifestSource(ctx) + var slackManifest types.SlackYaml + var err error + if manifestSource.Equals(config.ManifestSourceRemote) { + slackManifest, err = clients.AppClient().Manifest.GetManifestRemote(ctx, selected.Auth.Token, selected.App.AppID) + } else { + slackManifest, err = clients.AppClient().Manifest.GetManifestLocal(ctx, clients.SDKConfig, clients.HookExecutor) + } + if err != nil { + return "", err + } + + manifest := slackManifest.AppManifest + botScopes := []string{} + if manifest.OAuthConfig != nil && manifest.OAuthConfig.Scopes != nil { + botScopes = manifest.OAuthConfig.Scopes.Bot + } + outgoingDomains := []string{} + if manifest.OutgoingDomains != nil { + outgoingDomains = *manifest.OutgoingDomains + } + + result, _, err := clients.API().DeveloperAppInstall(ctx, clients.IO, selected.Auth.Token, selected.App, botScopes, outgoingDomains, "", false) + if err != nil { + return "", err + } + + return result.APIAccessTokens.Bot, nil +} diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go new file mode 100644 index 00000000..ef339cd3 --- /dev/null +++ b/cmd/api/api_test.go @@ -0,0 +1,649 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + internalapi "github.com/slackapi/slack-cli/internal/api" + "github.com/slackapi/slack-cli/internal/app" + "github.com/slackapi/slack-cli/internal/iostreams" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackcontext" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func Test_NewCommand(t *testing.T) { + clientsMock := shared.NewClientsMock() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + cmd := NewCommand(clients) + + assert.Equal(t, "api [key=value ...] [flags]", cmd.Use) + assert.Equal(t, "Call any Slack API method", cmd.Short) +} + +func Test_runAPICommand_FormEncoded(t *testing.T) { + var receivedContentType string + var receivedBody string + var receivedMethod string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMethod = r.Method + receivedContentType = r.Header.Get("Content-Type") + body, _ := io.ReadAll(r.Body) + receivedBody = string(body) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clientsMock.Config.TokenFlag = "xoxb-test-token" + clientsMock.Config.APIHostResolved = server.URL + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + testutil.MockCmdIO(clients.IO, cmd) + + flags = cmdFlags{method: "POST"} + cmd.SetArgs([]string{"chat.postMessage", "channel=C123", "text=hello"}) + err := cmd.ExecuteContext(ctx) + + assert.NoError(t, err) + assert.Equal(t, "POST", receivedMethod) + assert.Equal(t, "application/x-www-form-urlencoded", receivedContentType) + assert.Contains(t, receivedBody, "channel=C123") + assert.Contains(t, receivedBody, "text=hello") + assert.Contains(t, receivedBody, "token=xoxb-test-token") +} + +func Test_runAPICommand_JSONAutoDetect(t *testing.T) { + var receivedContentType string + var receivedBody string + var receivedAuth string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedContentType = r.Header.Get("Content-Type") + receivedAuth = r.Header.Get("Authorization") + body, _ := io.ReadAll(r.Body) + receivedBody = string(body) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clientsMock.Config.TokenFlag = "xoxb-test-token" + clientsMock.Config.APIHostResolved = server.URL + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + testutil.MockCmdIO(clients.IO, cmd) + + flags = cmdFlags{method: "POST"} + cmd.SetArgs([]string{"chat.postMessage", `{"channel":"C123","text":"hello"}`}) + err := cmd.ExecuteContext(ctx) + + assert.NoError(t, err) + assert.Equal(t, "application/json; charset=utf-8", receivedContentType) + assert.Equal(t, "Bearer xoxb-test-token", receivedAuth) + + var bodyJSON map[string]string + err = json.Unmarshal([]byte(receivedBody), &bodyJSON) + assert.NoError(t, err) + assert.Equal(t, "C123", bodyJSON["channel"]) + assert.Equal(t, "hello", bodyJSON["text"]) +} + +func Test_runAPICommand_JSONFlag(t *testing.T) { + var receivedContentType string + var receivedAuth string + var receivedBody string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedContentType = r.Header.Get("Content-Type") + receivedAuth = r.Header.Get("Authorization") + body, _ := io.ReadAll(r.Body) + receivedBody = string(body) + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clientsMock.Config.TokenFlag = "xoxb-test-token" + clientsMock.Config.APIHostResolved = server.URL + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + testutil.MockCmdIO(clients.IO, cmd) + + flags = cmdFlags{method: "POST", json: `{"channel":"C123"}`} + cmd.SetArgs([]string{"auth.test"}) + err := cmd.ExecuteContext(ctx) + + assert.NoError(t, err) + assert.Equal(t, "application/json; charset=utf-8", receivedContentType) + assert.Equal(t, "Bearer xoxb-test-token", receivedAuth) + assert.Equal(t, `{"channel":"C123"}`, receivedBody) +} + +func Test_runAPICommand_DataFlag(t *testing.T) { + var receivedBody string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + receivedBody = string(body) + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clientsMock.Config.TokenFlag = "xoxb-test-token" + clientsMock.Config.APIHostResolved = server.URL + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + testutil.MockCmdIO(clients.IO, cmd) + + flags = cmdFlags{method: "POST", data: "channel=C123&text=hello"} + cmd.SetArgs([]string{"chat.postMessage"}) + err := cmd.ExecuteContext(ctx) + + assert.NoError(t, err) + assert.Contains(t, receivedBody, "channel=C123") + assert.Contains(t, receivedBody, "text=hello") + assert.Contains(t, receivedBody, "token=xoxb-test-token") +} + +func Test_runAPICommand_GETMethod(t *testing.T) { + var receivedMethod string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMethod = r.Method + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clientsMock.Config.TokenFlag = "xoxb-test-token" + clientsMock.Config.APIHostResolved = server.URL + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + testutil.MockCmdIO(clients.IO, cmd) + + flags = cmdFlags{method: "GET"} + cmd.SetArgs([]string{"auth.test"}) + err := cmd.ExecuteContext(ctx) + + assert.NoError(t, err) + assert.Equal(t, "GET", receivedMethod) +} + +func Test_runAPICommand_IncludeHeaders(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Custom", "test-value") + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clientsMock.Config.TokenFlag = "xoxb-test-token" + clientsMock.Config.APIHostResolved = server.URL + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + testutil.MockCmdIO(clients.IO, cmd) + + flags = cmdFlags{method: "POST", include: true} + cmd.SetArgs([]string{"auth.test"}) + err := cmd.ExecuteContext(ctx) + + assert.NoError(t, err) + output := clientsMock.GetStdoutOutput() + assert.Contains(t, output, "HTTP 200") + assert.Contains(t, output, "X-Custom: test-value") + assert.Contains(t, output, `"ok":true`) +} + +func Test_runAPICommand_NonOKStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"ok":false,"error":"method_not_found"}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clientsMock.Config.TokenFlag = "xoxb-test-token" + clientsMock.Config.APIHostResolved = server.URL + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + testutil.MockCmdIO(clients.IO, cmd) + + flags = cmdFlags{method: "POST"} + cmd.SetArgs([]string{"nonexistent.method"}) + err := cmd.ExecuteContext(ctx) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "status 404") + output := clientsMock.GetStdoutOutput() + assert.Contains(t, output, `"ok":false`) +} + +func Test_runAPICommand_InvalidParam(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.Config.TokenFlag = "xoxb-test-token" + clientsMock.Config.APIHostResolved = "https://slack.com" + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + testutil.MockCmdIO(clients.IO, cmd) + + flags = cmdFlags{method: "POST"} + cmd.SetArgs([]string{"auth.test", "not-a-key-value"}) + err := cmd.ExecuteContext(ctx) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "key=value") +} + +func Test_runAPICommand_CustomHeaders(t *testing.T) { + var receivedHeaders http.Header + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clientsMock.Config.TokenFlag = "xoxb-test-token" + clientsMock.Config.APIHostResolved = server.URL + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + testutil.MockCmdIO(clients.IO, cmd) + + flags = cmdFlags{method: "POST", headers: []string{"X-Custom: my-value"}} + cmd.SetArgs([]string{"auth.test"}) + err := cmd.ExecuteContext(ctx) + + assert.NoError(t, err) + assert.Equal(t, "my-value", receivedHeaders.Get("X-Custom")) +} + +func Test_resolveToken_TokenFlag(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.Config.TokenFlag = "xoxb-direct-token" + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Equal(t, "xoxb-direct-token", token) +} + +func Test_resolveToken_TeamFlag(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) + clientsMock.Config.TeamFlag = "T12345" + clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxb-team-token", TeamID: "T12345", TeamDomain: "myteam"}, + }, nil) + clientsMock.Auth.On("SetSelectedAuth", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + clientsMock.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Flag: true, Option: "T12345"}, nil) + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Equal(t, "xoxb-team-token", token) +} + +func Test_resolveToken_SingleAuth(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) + clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{{Token: "xoxb-only-token"}}, nil) + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Equal(t, "xoxb-only-token", token) +} + +func Test_resolveToken_EnvBotToken(t *testing.T) { + t.Setenv("SLACK_BOT_TOKEN", "xoxb-env-bot-token") + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Equal(t, "xoxb-env-bot-token", token) +} + +func Test_resolveToken_EnvUserToken(t *testing.T) { + t.Setenv("SLACK_USER_TOKEN", "xoxp-env-user-token") + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Equal(t, "xoxp-env-user-token", token) +} + +func Test_resolveToken_TeamOverridesEnv(t *testing.T) { + t.Setenv("SLACK_BOT_TOKEN", "xoxb-env-bot-token") + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.Os.AddDefaultMocks() + clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) + clientsMock.Config.TeamFlag = "T12345" + + clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-tooling", TeamID: "T12345", TeamDomain: "myteam"}, + }, nil) + clientsMock.Auth.On("SetSelectedAuth", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + + appClientMock := &app.AppClientMock{} + appClientMock.On("GetDeployedAll", mock.Anything).Return([]types.App{ + {AppID: "A111", TeamID: "T12345", TeamDomain: "myteam"}, + }, "", nil) + appClientMock.On("GetLocalAll", mock.Anything).Return([]types.App{}, nil) + appClientMock.On("GetDeployed", mock.Anything, "T12345").Return(types.App{AppID: "A111", TeamID: "T12345", TeamDomain: "myteam"}, nil) + appClientMock.On("GetLocal", mock.Anything, "T12345").Return(types.App{}, nil) + clientsMock.AppClient.AppClientInterface = appClientMock + + clientsMock.API.On("GetAppStatus", mock.Anything, "xoxp-tooling", []string{"A111"}, "T12345"). + Return(internalapi.GetAppStatusResult{Apps: []internalapi.AppStatusResultAppInfo{{AppID: "A111", Installed: true}}}, nil) + clientsMock.API.On("ValidateSession", mock.Anything, mock.Anything).Return(internalapi.AuthSession{}, nil) + clientsMock.IO.On("SelectPrompt", mock.Anything, "Select an app", mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Index: 0, Prompt: true}, nil) + + manifestMock := clientsMock.AppClient.Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + OAuthConfig: &types.OAuthConfig{Scopes: &types.ManifestScopes{Bot: []string{"chat:write"}}}, + }, + }, nil) + + clientsMock.API.On("DeveloperAppInstall", mock.Anything, mock.Anything, "xoxp-tooling", mock.Anything, []string{"chat:write"}, []string{}, "", false). + Return(internalapi.DeveloperAppInstallResult{APIAccessTokens: struct { + Bot string `json:"bot,omitempty"` + AppLevel string `json:"app_level,omitempty"` + User string `json:"user,omitempty"` + }{Bot: "xoxb-team-bot-token"}}, types.InstallSuccess, nil) + + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + clients.SDKConfig.WorkingDirectory = "/fake/project" + + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Equal(t, "xoxb-team-bot-token", token) +} + +func Test_resolveToken_MultipleAuths_SelectsViaPrompt(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) + clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxb-token-1", TeamDomain: "team-a", TeamID: "T111"}, + {Token: "xoxb-token-2", TeamDomain: "team-b", TeamID: "T222"}, + }, nil) + clientsMock.Auth.On("SetSelectedAuth", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + clientsMock.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Index: 0, Prompt: true}, nil) + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Equal(t, "xoxb-token-1", token) +} + +func Test_resolveToken_AppFlag_ByID(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.Os.AddDefaultMocks() + clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) + clientsMock.Config.AppFlag = "A111" + + clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-tooling", TeamID: "T111", TeamDomain: "team-a"}, + }, nil) + clientsMock.Auth.On("SetSelectedAuth", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + + appClientMock := &app.AppClientMock{} + appClientMock.On("GetDeployedAll", mock.Anything).Return([]types.App{ + {AppID: "A111", TeamID: "T111", TeamDomain: "team-a"}, + }, "", nil) + appClientMock.On("GetLocalAll", mock.Anything).Return([]types.App{}, nil) + appClientMock.On("GetDeployed", mock.Anything, "T111").Return(types.App{AppID: "A111", TeamID: "T111", TeamDomain: "team-a"}, nil) + appClientMock.On("GetLocal", mock.Anything, "T111").Return(types.App{}, nil) + clientsMock.AppClient.AppClientInterface = appClientMock + + clientsMock.API.On("GetAppStatus", mock.Anything, "xoxp-tooling", []string{"A111"}, "T111"). + Return(internalapi.GetAppStatusResult{Apps: []internalapi.AppStatusResultAppInfo{{AppID: "A111", Installed: true}}}, nil) + clientsMock.API.On("ValidateSession", mock.Anything, "xoxp-tooling").Return(internalapi.AuthSession{}, nil) + + manifestMock := clientsMock.AppClient.Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + OAuthConfig: &types.OAuthConfig{Scopes: &types.ManifestScopes{Bot: []string{"chat:write"}}}, + }, + }, nil) + + clientsMock.API.On("DeveloperAppInstall", mock.Anything, mock.Anything, "xoxp-tooling", mock.Anything, []string{"chat:write"}, []string{}, "", false). + Return(internalapi.DeveloperAppInstallResult{APIAccessTokens: struct { + Bot string `json:"bot,omitempty"` + AppLevel string `json:"app_level,omitempty"` + User string `json:"user,omitempty"` + }{Bot: "xoxb-app-bot-token"}}, types.InstallSuccess, nil) + + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + clients.SDKConfig.WorkingDirectory = "/fake/project" + + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Equal(t, "xoxb-app-bot-token", token) +} + +func Test_resolveToken_AppFlag_Local(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.Os.AddDefaultMocks() + clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) + clientsMock.Config.AppFlag = "local" + + clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-tooling", TeamID: "T111", TeamDomain: "team-a"}, + }, nil) + clientsMock.Auth.On("SetSelectedAuth", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + + appClientMock := &app.AppClientMock{} + appClientMock.On("GetDeployedAll", mock.Anything).Return([]types.App{}, "", nil) + appClientMock.On("GetLocalAll", mock.Anything).Return([]types.App{ + {AppID: "A111", TeamID: "T111", TeamDomain: "team-a", IsDev: true}, + }, nil) + appClientMock.On("GetDeployed", mock.Anything, "T111").Return(types.App{}, nil) + appClientMock.On("GetLocal", mock.Anything, "T111").Return(types.App{AppID: "A111", TeamID: "T111", TeamDomain: "team-a", IsDev: true}, nil) + clientsMock.AppClient.AppClientInterface = appClientMock + + clientsMock.API.On("GetAppStatus", mock.Anything, "xoxp-tooling", []string{"A111"}, "T111"). + Return(internalapi.GetAppStatusResult{Apps: []internalapi.AppStatusResultAppInfo{{AppID: "A111", Installed: true}}}, nil) + clientsMock.API.On("ValidateSession", mock.Anything, mock.Anything).Return(internalapi.AuthSession{}, nil) + clientsMock.IO.On("SelectPrompt", mock.Anything, "Select an app", mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Index: 0, Prompt: true}, nil) + + manifestMock := clientsMock.AppClient.Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + OAuthConfig: &types.OAuthConfig{Scopes: &types.ManifestScopes{Bot: []string{"commands"}}}, + }, + }, nil) + + clientsMock.API.On("DeveloperAppInstall", mock.Anything, mock.Anything, "xoxp-tooling", mock.Anything, []string{"commands"}, []string{}, "", false). + Return(internalapi.DeveloperAppInstallResult{APIAccessTokens: struct { + Bot string `json:"bot,omitempty"` + AppLevel string `json:"app_level,omitempty"` + User string `json:"user,omitempty"` + }{Bot: "xoxb-local-bot-token"}}, types.InstallSuccess, nil) + + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + clients.SDKConfig.WorkingDirectory = "/fake/project" + + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Equal(t, "xoxb-local-bot-token", token) +} + +func Test_resolveToken_AppFlag_NotFound(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.Os.AddDefaultMocks() + clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) + clientsMock.Config.AppFlag = "A999" + + clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-tooling", TeamID: "T111", TeamDomain: "team-a"}, + }, nil) + + appClientMock := &app.AppClientMock{} + appClientMock.On("GetDeployedAll", mock.Anything).Return([]types.App{ + {AppID: "A111", TeamID: "T111", TeamDomain: "team-a"}, + }, "", nil) + appClientMock.On("GetLocalAll", mock.Anything).Return([]types.App{}, nil) + appClientMock.On("GetDeployed", mock.Anything, "T111").Return(types.App{AppID: "A111", TeamID: "T111", TeamDomain: "team-a"}, nil) + appClientMock.On("GetLocal", mock.Anything, "T111").Return(types.App{}, nil) + clientsMock.AppClient.AppClientInterface = appClientMock + + clientsMock.API.On("GetAppStatus", mock.Anything, "xoxp-tooling", []string{"A111"}, "T111"). + Return(internalapi.GetAppStatusResult{Apps: []internalapi.AppStatusResultAppInfo{{AppID: "A111", Installed: true}}}, nil) + + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + clients.SDKConfig.WorkingDirectory = "/fake/project" + + // AppSelectPrompt returns ErrAppNotFound for A999, resolveToken falls through to team prompt + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Equal(t, "xoxp-tooling", token) +} + +func Test_resolveToken_AppSelection(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.Os.AddDefaultMocks() + clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) + + clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-tooling", TeamID: "T111", TeamDomain: "team-a"}, + }, nil) + clientsMock.Auth.On("SetSelectedAuth", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + + appClientMock := &app.AppClientMock{} + appClientMock.On("GetDeployedAll", mock.Anything).Return([]types.App{ + {AppID: "A111", TeamID: "T111", TeamDomain: "team-a"}, + }, "", nil) + appClientMock.On("GetLocalAll", mock.Anything).Return([]types.App{}, nil) + appClientMock.On("GetDeployed", mock.Anything, "T111").Return(types.App{AppID: "A111", TeamID: "T111", TeamDomain: "team-a"}, nil) + appClientMock.On("GetLocal", mock.Anything, "T111").Return(types.App{}, nil) + clientsMock.AppClient.AppClientInterface = appClientMock + + clientsMock.API.On("GetAppStatus", mock.Anything, "xoxp-tooling", []string{"A111"}, "T111"). + Return(internalapi.GetAppStatusResult{Apps: []internalapi.AppStatusResultAppInfo{{AppID: "A111", Installed: true}}}, nil) + clientsMock.API.On("ValidateSession", mock.Anything, "xoxp-tooling").Return(internalapi.AuthSession{}, nil) + + clientsMock.IO.On("SelectPrompt", mock.Anything, "Select an app", mock.Anything, mock.Anything). + Return(iostreams.SelectPromptResponse{Index: 0, Prompt: true}, nil) + + manifestMock := clientsMock.AppClient.Manifest.(*app.ManifestMockObject) + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{ + AppManifest: types.AppManifest{ + OAuthConfig: &types.OAuthConfig{Scopes: &types.ManifestScopes{Bot: []string{"chat:write"}}}, + }, + }, nil) + + clientsMock.API.On("DeveloperAppInstall", mock.Anything, mock.Anything, "xoxp-tooling", mock.Anything, []string{"chat:write"}, []string{}, "", false). + Return(internalapi.DeveloperAppInstallResult{APIAccessTokens: struct { + Bot string `json:"bot,omitempty"` + AppLevel string `json:"app_level,omitempty"` + User string `json:"user,omitempty"` + }{Bot: "xoxb-app-bot-token"}}, types.InstallSuccess, nil) + + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + clients.SDKConfig.WorkingDirectory = "/fake/project" + + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Equal(t, "xoxb-app-bot-token", token) +} + +func Test_resolveToken_AppSelection_FallsThrough(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.Os.AddDefaultMocks() + clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) + + clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ + {Token: "xoxp-tooling", TeamID: "T111", TeamDomain: "team-a"}, + }, nil) + + appClientMock := &app.AppClientMock{} + appClientMock.On("GetDeployedAll", mock.Anything).Return([]types.App{}, "", nil) + appClientMock.On("GetLocalAll", mock.Anything).Return([]types.App{}, nil) + appClientMock.On("GetDeployed", mock.Anything, "T111").Return(types.App{}, nil) + appClientMock.On("GetLocal", mock.Anything, "T111").Return(types.App{}, nil) + clientsMock.AppClient.AppClientInterface = appClientMock + + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + clients.SDKConfig.WorkingDirectory = "/fake/project" + + // No installed apps found, AppSelectPrompt returns ErrInstallationRequired, falls through to team prompt + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Equal(t, "xoxp-tooling", token) +} diff --git a/cmd/root.go b/cmd/root.go index a01a6830..b638502b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,6 +22,7 @@ import ( "strings" "syscall" + apicmd "github.com/slackapi/slack-cli/cmd/api" "github.com/slackapi/slack-cli/cmd/app" "github.com/slackapi/slack-cli/cmd/auth" "github.com/slackapi/slack-cli/cmd/collaborators" @@ -159,6 +160,7 @@ func Init(ctx context.Context) (*cobra.Command, *shared.ClientFactory) { // Add subcommands (each subcommand may add their own child subcommands) // Please keep these sorted subCommands := []*cobra.Command{ + apicmd.NewCommand(clients), app.NewCommand(clients), auth.NewCommand(clients), collaborators.NewCommand(clients), diff --git a/internal/api/generic.go b/internal/api/generic.go new file mode 100644 index 00000000..a08a2fc4 --- /dev/null +++ b/internal/api/generic.go @@ -0,0 +1,127 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "runtime" + "time" + + "github.com/opentracing/opentracing-go" + "github.com/slackapi/slack-cli/internal/slackcontext" + "github.com/uber/jaeger-client-go" +) + +// RawResponse holds the full HTTP response from a generic API call. +type RawResponse struct { + StatusCode int + Header http.Header + Body []byte +} + +// RawRequest makes a generic HTTP request to a Slack API endpoint and returns the +// full response without interpreting the body. Unlike the typed methods on Client, +// this does not error on non-200 status codes — it always returns the response body. +// Retries are still performed for 429/503 with Retry-After headers. +func (c *Client) RawRequest(ctx context.Context, httpMethod, endpoint, token string, body io.Reader, contentType string, headers map[string]string) (*RawResponse, error) { + var span opentracing.Span + span, _ = opentracing.StartSpanFromContext(ctx, "RawRequest") + defer span.Finish() + + sURL, err := url.Parse(fmt.Sprintf("%s/api/%s", c.host, endpoint)) + if err != nil { + return nil, err + } + span.SetTag("request_url", sURL.String()) + + request, err := http.NewRequest(httpMethod, sURL.String(), body) + if err != nil { + return nil, err + } + + if contentType != "" { + request.Header.Set("Content-Type", contentType) + } + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + + cliVersion, err := slackcontext.Version(ctx) + if err != nil { + return nil, err + } + request.Header.Set("User-Agent", fmt.Sprintf("slack-cli/%s (os: %s)", cliVersion, runtime.GOOS)) + + if jaegerSpanContext, ok := span.Context().(jaeger.SpanContext); ok { + request.Header.Set("x-b3-sampled", "0") + request.Header.Set("x-b3-spanid", jaegerSpanContext.SpanID().String()) + request.Header.Set("x-b3-traceid", jaegerSpanContext.TraceID().String()) + request.Header.Set("x-b3-parentspanid", jaegerSpanContext.ParentID().String()) + } + + for k, v := range headers { + request.Header.Set(k, v) + } + + c.printRequest(ctx, request, false) + + var data []byte + if request.Body != nil { + data, err = io.ReadAll(request.Body) + if err != nil { + return nil, err + } + if err = request.Body.Close(); err != nil { + return nil, err + } + } + + var r *http.Response + for { + request.Body = io.NopCloser(bytes.NewReader(data)) + r, err = c.httpClient.Do(request) + if err != nil { + return nil, err + } + + delay, ok := getRetryAfter(r) + if !ok { + break + } + r.Body.Close() + c.io.PrintDebug(ctx, "%s responded with status %d. Retrying request in %s...", sURL.Path, r.StatusCode, delay) + time.Sleep(delay) + } + defer r.Body.Close() + + c.printResponse(ctx, r, false) + span.SetTag("status_code", r.StatusCode) + + respBody, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + + return &RawResponse{ + StatusCode: r.StatusCode, + Header: r.Header, + Body: respBody, + }, nil +} diff --git a/internal/api/generic_test.go b/internal/api/generic_test.go new file mode 100644 index 00000000..c69e05c0 --- /dev/null +++ b/internal/api/generic_test.go @@ -0,0 +1,139 @@ +// Copyright 2022-2026 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package api + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/slackapi/slack-cli/internal/config" + "github.com/slackapi/slack-cli/internal/iostreams" + "github.com/slackapi/slack-cli/internal/slackcontext" + "github.com/slackapi/slack-cli/internal/slackdeps" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func newTestIO() *iostreams.IOStreamsMock { + fs := slackdeps.NewFsMock() + os := slackdeps.NewOsMock() + cfg := config.NewConfig(fs, os) + m := iostreams.NewIOStreamsMock(cfg, fs, os) + m.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) + return m +} + +func Test_RawRequest_BasicPOST(t *testing.T) { + var receivedMethod string + var receivedPath string + var receivedAuth string + var receivedBody string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMethod = r.Method + receivedPath = r.URL.Path + receivedAuth = r.Header.Get("Authorization") + body, _ := io.ReadAll(r.Body) + receivedBody = string(body) + w.Header().Set("X-Test", "value") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + io := newTestIO() + client := NewClient(nil, server.URL, io) + + resp, err := client.RawRequest(ctx, "POST", "auth.test", "xoxb-token", strings.NewReader("hello=world"), "application/x-www-form-urlencoded", nil) + + assert.NoError(t, err) + assert.Equal(t, "POST", receivedMethod) + assert.Equal(t, "/api/auth.test", receivedPath) + assert.Equal(t, "Bearer xoxb-token", receivedAuth) + assert.Equal(t, "hello=world", receivedBody) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "value", resp.Header.Get("X-Test")) + assert.Equal(t, `{"ok":true}`, string(resp.Body)) +} + +func Test_RawRequest_NonOKStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, `{"ok":false,"error":"invalid_auth"}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + io := newTestIO() + client := NewClient(nil, server.URL, io) + + resp, err := client.RawRequest(ctx, "POST", "auth.test", "bad-token", nil, "", nil) + + assert.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) + assert.Equal(t, `{"ok":false,"error":"invalid_auth"}`, string(resp.Body)) +} + +func Test_RawRequest_CustomHeaders(t *testing.T) { + var receivedHeaders http.Header + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + io := newTestIO() + client := NewClient(nil, server.URL, io) + + headers := map[string]string{"X-Custom": "my-value", "X-Another": "other"} + resp, err := client.RawRequest(ctx, "GET", "api.test", "", nil, "", headers) + + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "my-value", receivedHeaders.Get("X-Custom")) + assert.Equal(t, "other", receivedHeaders.Get("X-Another")) +} + +func Test_RawRequest_RetryOnTooManyRequests(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts == 1 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusTooManyRequests) + return + } + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + io := newTestIO() + client := NewClient(nil, server.URL, io) + + resp, err := client.RawRequest(ctx, "POST", "auth.test", "token", nil, "", nil) + + assert.NoError(t, err) + assert.Equal(t, 2, attempts) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, `{"ok":true}`, string(resp.Body)) +} From d6ad5f744039396afa80861b8b7b7b11227e6423 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Sun, 10 May 2026 16:46:10 -0700 Subject: [PATCH 02/10] chore: rename internal/api/generic.go to raw_request.go --- internal/api/{generic.go => raw_request.go} | 0 internal/api/{generic_test.go => raw_request_test.go} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename internal/api/{generic.go => raw_request.go} (100%) rename internal/api/{generic_test.go => raw_request_test.go} (100%) diff --git a/internal/api/generic.go b/internal/api/raw_request.go similarity index 100% rename from internal/api/generic.go rename to internal/api/raw_request.go diff --git a/internal/api/generic_test.go b/internal/api/raw_request_test.go similarity index 100% rename from internal/api/generic_test.go rename to internal/api/raw_request_test.go From a0c7589e4b0df6905a82d4e6a45acb526992e4cd Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Sun, 10 May 2026 16:54:38 -0700 Subject: [PATCH 03/10] chore: move common methods to examples, sort alphabetically --- cmd/api/api.go | 47 ++++++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index 1291aac4..dbef7277 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -68,35 +68,28 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { " 5. Interactive prompt Select from stored workspaces (CLI tooling token)", "", "See all methods at: https://docs.slack.dev/reference/methods", - "", - "Common methods:", - " api.test Test your API connection", - " auth.test Check authentication", - " chat.postMessage Send a message to a channel", - " chat.update Update a message", - " chat.delete Delete a message", - " conversations.list List channels", - " conversations.history Fetch messages from a channel", - " conversations.info Get channel details", - " conversations.members List members in a channel", - " conversations.create Create a channel", - " users.list List workspace members", - " users.info Get user details", - " files.upload Upload a file", - " reactions.add Add an emoji reaction", - " reactions.list List reactions for a user", - " bookmarks.add Add a bookmark to a channel", - " pins.add Pin a message", - " views.open Open a modal view", - " views.update Update a modal view", }, "\n"), Example: style.ExampleCommandsf([]style.ExampleCommand{ - {Command: "api auth.test", Meaning: "Test authentication with the current workspace"}, - {Command: "api chat.postMessage channel=C0123456 text=\"Hello\"", Meaning: "Post a message"}, - {Command: "api users.list --team myworkspace", Meaning: "List users in a specific workspace"}, - {Command: `api chat.postMessage --json '{"channel":"C0123456","text":"Hello"}'`, Meaning: "Send a JSON body"}, - {Command: "api auth.test --include", Meaning: "Show HTTP status and response headers"}, - {Command: "api conversations.history -X GET channel=C0123456", Meaning: "Use GET method"}, + {Command: "api api.test", Meaning: "Test your API connection"}, + {Command: "api auth.test", Meaning: "Check authentication"}, + {Command: "api bookmarks.add channel_id=C0123456 title=Docs link=https://example.com", Meaning: "Add a bookmark to a channel"}, + {Command: "api chat.postMessage channel=C0123456 text=\"Hello\"", Meaning: "Send a message to a channel using form-encoded string"}, + {Command: `api chat.postMessage --json '{"channel":"C0123456","text":"Hello"}'`, Meaning: "Send a message to a channel using JSON"}, + {Command: "api chat.update channel=C0123456 ts=1234567890.123456 text=\"Updated\"", Meaning: "Update a message"}, + {Command: "api conversations.create name=new-channel", Meaning: "Create a channel"}, + {Command: "api conversations.history channel=C0123456", Meaning: "Fetch messages from a channel"}, + {Command: "api conversations.info channel=C0123456", Meaning: "Get channel details"}, + {Command: "api conversations.list", Meaning: "List channels"}, + {Command: "api conversations.members channel=C0123456", Meaning: "List members in a channel"}, + {Command: "api files.upload channels=C0123456 filename=report.csv", Meaning: "Upload a file"}, + {Command: "api pins.add channel=C0123456 timestamp=1234567890.123456", Meaning: "Pin a message"}, + {Command: "api reactions.add channel=C0123456 timestamp=1234567890.123456 name=thumbsup", Meaning: "Add an emoji reaction"}, + {Command: "api reactions.list user=U0123456", Meaning: "List reactions for a user"}, + {Command: "api users.info user=U0123456", Meaning: "Get user details"}, + {Command: "api users.list", Meaning: "List workspace members"}, + {Command: "api users.profile.get user=U0123456", Meaning: "Get a user's profile"}, + {Command: "api views.open trigger_id=T0123456 view={...}", Meaning: "Open a modal view"}, + {Command: "api views.update view_id=V0123456 view={...}", Meaning: "Update a modal view"}, }), Args: cobra.MinimumNArgs(1), PreRunE: func(cmd *cobra.Command, args []string) error { From 1e84583435a48f0890c911e59b5ad83d06343e9e Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Sun, 10 May 2026 17:13:34 -0700 Subject: [PATCH 04/10] fix: remove workspace/team prompt and --team flag from slack api Token resolution now errors if no token is found rather than falling back to a workspace selection prompt. Users must provide a token via --token, --app, or SLACK_BOT_TOKEN/SLACK_USER_TOKEN env vars. --- cmd/api/api.go | 13 +++--- cmd/api/api_test.go | 99 ++++++++------------------------------------- 2 files changed, 21 insertions(+), 91 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index dbef7277..3a08f653 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -62,10 +62,10 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { "", "Token resolution (in priority order):", " 1. --token flag Explicit token value", - " 2. --app / --team flags Install app and use bot token (in project)", + " 2. --app flag Install app and use bot token (in project)", " 3. SLACK_BOT_TOKEN env var Bot token (set during slack deploy)", " 4. SLACK_USER_TOKEN env var User token", - " 5. Interactive prompt Select from stored workspaces (CLI tooling token)", + " 5. App prompt (in project) Install app and use bot token", "", "See all methods at: https://docs.slack.dev/reference/methods", }, "\n"), @@ -237,12 +237,9 @@ func resolveToken(ctx context.Context, clients *shared.ClientFactory) (string, e return token, nil } - clients.IO.PrintDebug(ctx, "Using CLI tooling token which has limited API scopes. Set SLACK_BOT_TOKEN or use --token for full access.") - auth, err := prompts.PromptTeamSlackAuth(ctx, clients, "Select a workspace") - if err != nil { - return "", err - } - return auth.Token, nil + return "", slackerror.New(slackerror.ErrNotAuthed). + WithMessage("no token found"). + WithRemediation("Provide a token with --token, --app, or set SLACK_BOT_TOKEN") } func installAndGetBotToken(ctx context.Context, clients *shared.ClientFactory, selected prompts.SelectedApp) (string, error) { diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index ef339cd3..b2aa0a8f 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -322,36 +322,6 @@ func Test_resolveToken_TokenFlag(t *testing.T) { assert.Equal(t, "xoxb-direct-token", token) } -func Test_resolveToken_TeamFlag(t *testing.T) { - ctx := slackcontext.MockContext(t.Context()) - clientsMock := shared.NewClientsMock() - clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) - clientsMock.Config.TeamFlag = "T12345" - clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxb-team-token", TeamID: "T12345", TeamDomain: "myteam"}, - }, nil) - clientsMock.Auth.On("SetSelectedAuth", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - clientsMock.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Flag: true, Option: "T12345"}, nil) - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - - token, err := resolveToken(ctx, clients) - assert.NoError(t, err) - assert.Equal(t, "xoxb-team-token", token) -} - -func Test_resolveToken_SingleAuth(t *testing.T) { - ctx := slackcontext.MockContext(t.Context()) - clientsMock := shared.NewClientsMock() - clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) - clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{{Token: "xoxb-only-token"}}, nil) - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - - token, err := resolveToken(ctx, clients) - assert.NoError(t, err) - assert.Equal(t, "xoxb-only-token", token) -} - func Test_resolveToken_EnvBotToken(t *testing.T) { t.Setenv("SLACK_BOT_TOKEN", "xoxb-env-bot-token") @@ -376,34 +346,32 @@ func Test_resolveToken_EnvUserToken(t *testing.T) { assert.Equal(t, "xoxp-env-user-token", token) } -func Test_resolveToken_TeamOverridesEnv(t *testing.T) { +func Test_resolveToken_AppOverridesEnv(t *testing.T) { t.Setenv("SLACK_BOT_TOKEN", "xoxb-env-bot-token") ctx := slackcontext.MockContext(t.Context()) clientsMock := shared.NewClientsMock() clientsMock.Os.AddDefaultMocks() clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) - clientsMock.Config.TeamFlag = "T12345" + clientsMock.Config.AppFlag = "A111" clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-tooling", TeamID: "T12345", TeamDomain: "myteam"}, + {Token: "xoxp-tooling", TeamID: "T111", TeamDomain: "team-a"}, }, nil) clientsMock.Auth.On("SetSelectedAuth", mock.Anything, mock.Anything, mock.Anything, mock.Anything) appClientMock := &app.AppClientMock{} appClientMock.On("GetDeployedAll", mock.Anything).Return([]types.App{ - {AppID: "A111", TeamID: "T12345", TeamDomain: "myteam"}, + {AppID: "A111", TeamID: "T111", TeamDomain: "team-a"}, }, "", nil) appClientMock.On("GetLocalAll", mock.Anything).Return([]types.App{}, nil) - appClientMock.On("GetDeployed", mock.Anything, "T12345").Return(types.App{AppID: "A111", TeamID: "T12345", TeamDomain: "myteam"}, nil) - appClientMock.On("GetLocal", mock.Anything, "T12345").Return(types.App{}, nil) + appClientMock.On("GetDeployed", mock.Anything, "T111").Return(types.App{AppID: "A111", TeamID: "T111", TeamDomain: "team-a"}, nil) + appClientMock.On("GetLocal", mock.Anything, "T111").Return(types.App{}, nil) clientsMock.AppClient.AppClientInterface = appClientMock - clientsMock.API.On("GetAppStatus", mock.Anything, "xoxp-tooling", []string{"A111"}, "T12345"). + clientsMock.API.On("GetAppStatus", mock.Anything, "xoxp-tooling", []string{"A111"}, "T111"). Return(internalapi.GetAppStatusResult{Apps: []internalapi.AppStatusResultAppInfo{{AppID: "A111", Installed: true}}}, nil) clientsMock.API.On("ValidateSession", mock.Anything, mock.Anything).Return(internalapi.AuthSession{}, nil) - clientsMock.IO.On("SelectPrompt", mock.Anything, "Select an app", mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Index: 0, Prompt: true}, nil) manifestMock := clientsMock.AppClient.Manifest.(*app.ManifestMockObject) manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(types.SlackYaml{ @@ -417,32 +385,14 @@ func Test_resolveToken_TeamOverridesEnv(t *testing.T) { Bot string `json:"bot,omitempty"` AppLevel string `json:"app_level,omitempty"` User string `json:"user,omitempty"` - }{Bot: "xoxb-team-bot-token"}}, types.InstallSuccess, nil) + }{Bot: "xoxb-app-bot-token"}}, types.InstallSuccess, nil) clients := shared.NewClientFactory(clientsMock.MockClientFactory()) clients.SDKConfig.WorkingDirectory = "/fake/project" token, err := resolveToken(ctx, clients) assert.NoError(t, err) - assert.Equal(t, "xoxb-team-bot-token", token) -} - -func Test_resolveToken_MultipleAuths_SelectsViaPrompt(t *testing.T) { - ctx := slackcontext.MockContext(t.Context()) - clientsMock := shared.NewClientsMock() - clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) - clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxb-token-1", TeamDomain: "team-a", TeamID: "T111"}, - {Token: "xoxb-token-2", TeamDomain: "team-b", TeamID: "T222"}, - }, nil) - clientsMock.Auth.On("SetSelectedAuth", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - clientsMock.IO.On("SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything). - Return(iostreams.SelectPromptResponse{Index: 0, Prompt: true}, nil) - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - - token, err := resolveToken(ctx, clients) - assert.NoError(t, err) - assert.Equal(t, "xoxb-token-1", token) + assert.Equal(t, "xoxb-app-bot-token", token) } func Test_resolveToken_AppFlag_ByID(t *testing.T) { @@ -567,10 +517,9 @@ func Test_resolveToken_AppFlag_NotFound(t *testing.T) { clients := shared.NewClientFactory(clientsMock.MockClientFactory()) clients.SDKConfig.WorkingDirectory = "/fake/project" - // AppSelectPrompt returns ErrAppNotFound for A999, resolveToken falls through to team prompt - token, err := resolveToken(ctx, clients) - assert.NoError(t, err) - assert.Equal(t, "xoxp-tooling", token) + _, err := resolveToken(ctx, clients) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no token found") } func Test_resolveToken_AppSelection(t *testing.T) { @@ -622,28 +571,12 @@ func Test_resolveToken_AppSelection(t *testing.T) { assert.Equal(t, "xoxb-app-bot-token", token) } -func Test_resolveToken_AppSelection_FallsThrough(t *testing.T) { +func Test_resolveToken_NoTokenFound(t *testing.T) { ctx := slackcontext.MockContext(t.Context()) clientsMock := shared.NewClientsMock() - clientsMock.Os.AddDefaultMocks() - clientsMock.IO.On("PrintDebug", mock.Anything, mock.Anything, mock.MatchedBy(func(args ...any) bool { return true })) - - clientsMock.Auth.On("Auths", mock.Anything).Return([]types.SlackAuth{ - {Token: "xoxp-tooling", TeamID: "T111", TeamDomain: "team-a"}, - }, nil) - - appClientMock := &app.AppClientMock{} - appClientMock.On("GetDeployedAll", mock.Anything).Return([]types.App{}, "", nil) - appClientMock.On("GetLocalAll", mock.Anything).Return([]types.App{}, nil) - appClientMock.On("GetDeployed", mock.Anything, "T111").Return(types.App{}, nil) - appClientMock.On("GetLocal", mock.Anything, "T111").Return(types.App{}, nil) - clientsMock.AppClient.AppClientInterface = appClientMock - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - clients.SDKConfig.WorkingDirectory = "/fake/project" - // No installed apps found, AppSelectPrompt returns ErrInstallationRequired, falls through to team prompt - token, err := resolveToken(ctx, clients) - assert.NoError(t, err) - assert.Equal(t, "xoxp-tooling", token) + _, err := resolveToken(ctx, clients) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no token found") } From 9116f08d149e63fd73b4c4d77e6e7bf804e69507 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Sun, 10 May 2026 17:53:59 -0700 Subject: [PATCH 05/10] docs: add function comments to cmd/api/api.go --- cmd/api/api.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/api/api.go b/cmd/api/api.go index 3a08f653..cce3427b 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -43,6 +43,7 @@ type cmdFlags struct { var flags cmdFlags +// NewCommand returns a new Cobra command for calling Slack API methods func NewCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "api [key=value ...] [flags]", @@ -111,6 +112,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { return cmd } +// runAPICommand resolves a token, builds the request body, and sends a raw HTTP request to the Slack API func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []string) error { ctx := cmd.Context() method := args[0] @@ -130,6 +132,8 @@ func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []str var bodyReader *strings.Reader var contentType string + // When the token is placed in the request body (form-encoded), clear it so + // RawRequest does not also send it in the Authorization header. switch { case flags.json != "": contentType = "application/json; charset=utf-8" @@ -214,6 +218,7 @@ func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []str return nil } +// resolveToken determines the API token to use for the request func resolveToken(ctx context.Context, clients *shared.ClientFactory) (string, error) { if clients.Config.TokenFlag != "" { return clients.Config.TokenFlag, nil @@ -242,6 +247,7 @@ func resolveToken(ctx context.Context, clients *shared.ClientFactory) (string, e WithRemediation("Provide a token with --token, --app, or set SLACK_BOT_TOKEN") } +// installAndGetBotToken installs the selected app and returns its bot token func installAndGetBotToken(ctx context.Context, clients *shared.ClientFactory, selected prompts.SelectedApp) (string, error) { manifestSource, _ := clients.Config.ProjectConfig.GetManifestSource(ctx) var slackManifest types.SlackYaml From 12630c8a2411978e0076b90b1e6dee6fb56f5e95 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 12 May 2026 16:29:18 -0700 Subject: [PATCH 06/10] test: add 503 retry coverage for RawRequest --- internal/api/raw_request_test.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/internal/api/raw_request_test.go b/internal/api/raw_request_test.go index c69e05c0..ddccc2ad 100644 --- a/internal/api/raw_request_test.go +++ b/internal/api/raw_request_test.go @@ -137,3 +137,28 @@ func Test_RawRequest_RetryOnTooManyRequests(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, `{"ok":true}`, string(resp.Body)) } + +func Test_RawRequest_RetryOnServiceUnavailable(t *testing.T) { + attempts := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + attempts++ + if attempts == 1 { + w.Header().Set("Retry-After", "1") + w.WriteHeader(http.StatusServiceUnavailable) + return + } + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + io := newTestIO() + client := NewClient(nil, server.URL, io) + + resp, err := client.RawRequest(ctx, "POST", "auth.test", "token", nil, "", nil) + + assert.NoError(t, err) + assert.Equal(t, 2, attempts) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, `{"ok":true}`, string(resp.Body)) +} From 6891a56990bfacb55d1b990d7848538dc1ef6ced Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 12 May 2026 16:38:54 -0700 Subject: [PATCH 07/10] fix: check env vars before app prompt in token resolution Environment variables (SLACK_BOT_TOKEN, SLACK_USER_TOKEN) now take priority over the interactive app selection prompt, preventing unexpected blocking prompts when a token is already available. --- cmd/api/api.go | 26 +++++++++++++++++++------- cmd/api/api_test.go | 14 ++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index cce3427b..52f9cae8 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -66,7 +66,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { " 2. --app flag Install app and use bot token (in project)", " 3. SLACK_BOT_TOKEN env var Bot token (set during slack deploy)", " 4. SLACK_USER_TOKEN env var User token", - " 5. App prompt (in project) Install app and use bot token", + " 5. App prompt (in project) Select installed app and use bot token", "", "See all methods at: https://docs.slack.dev/reference/methods", }, "\n"), @@ -224,12 +224,14 @@ func resolveToken(ctx context.Context, clients *shared.ClientFactory) (string, e return clients.Config.TokenFlag, nil } - if sdkConfigExists, _ := clients.SDKConfig.Exists(); sdkConfigExists { - selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly) - if err == nil && selected.App.AppID != "" { - token, err := installAndGetBotToken(ctx, clients, selected) - if err == nil && token != "" { - return token, nil + if clients.Config.AppFlag != "" { + if sdkConfigExists, _ := clients.SDKConfig.Exists(); sdkConfigExists { + selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly) + if err == nil && selected.App.AppID != "" { + token, err := installAndGetBotToken(ctx, clients, selected) + if err == nil && token != "" { + return token, nil + } } } } @@ -242,6 +244,16 @@ func resolveToken(ctx context.Context, clients *shared.ClientFactory) (string, e return token, nil } + if sdkConfigExists, _ := clients.SDKConfig.Exists(); sdkConfigExists { + selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly) + if err == nil && selected.App.AppID != "" { + token, err := installAndGetBotToken(ctx, clients, selected) + if err == nil && token != "" { + return token, nil + } + } + } + return "", slackerror.New(slackerror.ErrNotAuthed). WithMessage("no token found"). WithRemediation("Provide a token with --token, --app, or set SLACK_BOT_TOKEN") diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index b2aa0a8f..65f3c147 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -346,6 +346,20 @@ func Test_resolveToken_EnvUserToken(t *testing.T) { assert.Equal(t, "xoxp-env-user-token", token) } +func Test_resolveToken_EnvOverridesAppPrompt(t *testing.T) { + t.Setenv("SLACK_BOT_TOKEN", "xoxb-env-bot-token") + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + clients.SDKConfig.WorkingDirectory = "/fake/project" + + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Equal(t, "xoxb-env-bot-token", token) + clientsMock.IO.AssertNotCalled(t, "SelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + func Test_resolveToken_AppOverridesEnv(t *testing.T) { t.Setenv("SLACK_BOT_TOKEN", "xoxb-env-bot-token") From ab3a387559245433a2257a120c3e973c2c16a30f Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 12 May 2026 16:47:42 -0700 Subject: [PATCH 08/10] fix: propagate app prompt errors instead of swallowing them Interrupts (Ctrl+C) and app-not-found errors from the app selection prompt now surface immediately rather than falling through to a generic "no token found" message. --- cmd/api/api.go | 10 ++++++++-- cmd/api/api_test.go | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index 52f9cae8..d951c644 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -227,7 +227,10 @@ func resolveToken(ctx context.Context, clients *shared.ClientFactory) (string, e if clients.Config.AppFlag != "" { if sdkConfigExists, _ := clients.SDKConfig.Exists(); sdkConfigExists { selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly) - if err == nil && selected.App.AppID != "" { + if err != nil { + return "", err + } + if selected.App.AppID != "" { token, err := installAndGetBotToken(ctx, clients, selected) if err == nil && token != "" { return token, nil @@ -246,7 +249,10 @@ func resolveToken(ctx context.Context, clients *shared.ClientFactory) (string, e if sdkConfigExists, _ := clients.SDKConfig.Exists(); sdkConfigExists { selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly) - if err == nil && selected.App.AppID != "" { + if err != nil { + return "", err + } + if selected.App.AppID != "" { token, err := installAndGetBotToken(ctx, clients, selected) if err == nil && token != "" { return token, nil diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 65f3c147..64e48548 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -533,7 +533,7 @@ func Test_resolveToken_AppFlag_NotFound(t *testing.T) { _, err := resolveToken(ctx, clients) assert.Error(t, err) - assert.Contains(t, err.Error(), "no token found") + assert.Contains(t, err.Error(), "app_not_found") } func Test_resolveToken_AppSelection(t *testing.T) { From ad581a7698fa218a0b0736d925b2c2dfa0330728 Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 12 May 2026 16:55:23 -0700 Subject: [PATCH 09/10] fix: sort response headers in --include output Go map iteration is non-deterministic, so headers printed with --include would appear in a different order on each run. Sort them alphabetically for consistent output. --- cmd/api/api.go | 10 ++++++++-- cmd/api/api_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/cmd/api/api.go b/cmd/api/api.go index d951c644..d757edd6 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -21,6 +21,7 @@ import ( "fmt" "net/url" "os" + "sort" "strings" "github.com/slackapi/slack-cli/internal/api" @@ -192,8 +193,13 @@ func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []str if flags.include { fmt.Fprintf(cmd.OutOrStdout(), "HTTP %d\n", resp.StatusCode) - for key, values := range resp.Header { - for _, v := range values { + keys := make([]string, 0, len(resp.Header)) + for key := range resp.Header { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + for _, v := range resp.Header[key] { fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", key, v) } } diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 64e48548..9ba97b16 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -20,6 +20,7 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" internalapi "github.com/slackapi/slack-cli/internal/api" @@ -239,6 +240,39 @@ func Test_runAPICommand_IncludeHeaders(t *testing.T) { assert.Contains(t, output, `"ok":true`) } +func Test_runAPICommand_IncludeHeadersSorted(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Zebra", "last") + w.Header().Set("X-Alpha", "first") + w.Header().Set("X-Middle", "middle") + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clientsMock.Config.TokenFlag = "xoxb-test-token" + clientsMock.Config.APIHostResolved = server.URL + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + testutil.MockCmdIO(clients.IO, cmd) + + flags = cmdFlags{method: "POST", include: true} + cmd.SetArgs([]string{"auth.test"}) + err := cmd.ExecuteContext(ctx) + + assert.NoError(t, err) + output := clientsMock.GetStdoutOutput() + alphaIdx := strings.Index(output, "X-Alpha:") + middleIdx := strings.Index(output, "X-Middle:") + zebraIdx := strings.Index(output, "X-Zebra:") + assert.Greater(t, alphaIdx, -1) + assert.Greater(t, middleIdx, alphaIdx) + assert.Greater(t, zebraIdx, middleIdx) +} + func Test_runAPICommand_NonOKStatus(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) From e69776c8deef9bbd9842b0dfc33854a92047c27f Mon Sep 17 00:00:00 2001 From: Michael Brooks Date: Tue, 12 May 2026 17:00:00 -0700 Subject: [PATCH 10/10] refactor: consolidate body format tests into table-driven test Combines FormEncoded, JSONAutoDetect, JSONFlag, DataFlag, and GETMethod tests into a single Test_runAPICommand_BodyFormats table test with shared setup. --- cmd/api/api_test.go | 260 ++++++++++++++++---------------------------- 1 file changed, 92 insertions(+), 168 deletions(-) diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 9ba97b16..976cf175 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -15,7 +15,6 @@ package api import ( - "encoding/json" "fmt" "io" "net/http" @@ -43,173 +42,98 @@ func Test_NewCommand(t *testing.T) { assert.Equal(t, "Call any Slack API method", cmd.Short) } -func Test_runAPICommand_FormEncoded(t *testing.T) { - var receivedContentType string - var receivedBody string - var receivedMethod string - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - receivedMethod = r.Method - receivedContentType = r.Header.Get("Content-Type") - body, _ := io.ReadAll(r.Body) - receivedBody = string(body) - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"ok":true}`) - })) - defer server.Close() - - ctx := slackcontext.MockContext(t.Context()) - clientsMock := shared.NewClientsMock() - clientsMock.AddDefaultMocks() - clientsMock.Config.TokenFlag = "xoxb-test-token" - clientsMock.Config.APIHostResolved = server.URL - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - - cmd := NewCommand(clients) - testutil.MockCmdIO(clients.IO, cmd) - - flags = cmdFlags{method: "POST"} - cmd.SetArgs([]string{"chat.postMessage", "channel=C123", "text=hello"}) - err := cmd.ExecuteContext(ctx) - - assert.NoError(t, err) - assert.Equal(t, "POST", receivedMethod) - assert.Equal(t, "application/x-www-form-urlencoded", receivedContentType) - assert.Contains(t, receivedBody, "channel=C123") - assert.Contains(t, receivedBody, "text=hello") - assert.Contains(t, receivedBody, "token=xoxb-test-token") -} - -func Test_runAPICommand_JSONAutoDetect(t *testing.T) { - var receivedContentType string - var receivedBody string - var receivedAuth string - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - receivedContentType = r.Header.Get("Content-Type") - receivedAuth = r.Header.Get("Authorization") - body, _ := io.ReadAll(r.Body) - receivedBody = string(body) - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"ok":true}`) - })) - defer server.Close() - - ctx := slackcontext.MockContext(t.Context()) - clientsMock := shared.NewClientsMock() - clientsMock.AddDefaultMocks() - clientsMock.Config.TokenFlag = "xoxb-test-token" - clientsMock.Config.APIHostResolved = server.URL - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - - cmd := NewCommand(clients) - testutil.MockCmdIO(clients.IO, cmd) - - flags = cmdFlags{method: "POST"} - cmd.SetArgs([]string{"chat.postMessage", `{"channel":"C123","text":"hello"}`}) - err := cmd.ExecuteContext(ctx) - - assert.NoError(t, err) - assert.Equal(t, "application/json; charset=utf-8", receivedContentType) - assert.Equal(t, "Bearer xoxb-test-token", receivedAuth) - - var bodyJSON map[string]string - err = json.Unmarshal([]byte(receivedBody), &bodyJSON) - assert.NoError(t, err) - assert.Equal(t, "C123", bodyJSON["channel"]) - assert.Equal(t, "hello", bodyJSON["text"]) -} - -func Test_runAPICommand_JSONFlag(t *testing.T) { - var receivedContentType string - var receivedAuth string - var receivedBody string - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - receivedContentType = r.Header.Get("Content-Type") - receivedAuth = r.Header.Get("Authorization") - body, _ := io.ReadAll(r.Body) - receivedBody = string(body) - w.Header().Set("Content-Type", "application/json") - fmt.Fprint(w, `{"ok":true}`) - })) - defer server.Close() - - ctx := slackcontext.MockContext(t.Context()) - clientsMock := shared.NewClientsMock() - clientsMock.AddDefaultMocks() - clientsMock.Config.TokenFlag = "xoxb-test-token" - clientsMock.Config.APIHostResolved = server.URL - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - - cmd := NewCommand(clients) - testutil.MockCmdIO(clients.IO, cmd) - - flags = cmdFlags{method: "POST", json: `{"channel":"C123"}`} - cmd.SetArgs([]string{"auth.test"}) - err := cmd.ExecuteContext(ctx) - - assert.NoError(t, err) - assert.Equal(t, "application/json; charset=utf-8", receivedContentType) - assert.Equal(t, "Bearer xoxb-test-token", receivedAuth) - assert.Equal(t, `{"channel":"C123"}`, receivedBody) -} - -func Test_runAPICommand_DataFlag(t *testing.T) { - var receivedBody string - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - receivedBody = string(body) - fmt.Fprint(w, `{"ok":true}`) - })) - defer server.Close() - - ctx := slackcontext.MockContext(t.Context()) - clientsMock := shared.NewClientsMock() - clientsMock.AddDefaultMocks() - clientsMock.Config.TokenFlag = "xoxb-test-token" - clientsMock.Config.APIHostResolved = server.URL - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - - cmd := NewCommand(clients) - testutil.MockCmdIO(clients.IO, cmd) - - flags = cmdFlags{method: "POST", data: "channel=C123&text=hello"} - cmd.SetArgs([]string{"chat.postMessage"}) - err := cmd.ExecuteContext(ctx) - - assert.NoError(t, err) - assert.Contains(t, receivedBody, "channel=C123") - assert.Contains(t, receivedBody, "text=hello") - assert.Contains(t, receivedBody, "token=xoxb-test-token") -} - -func Test_runAPICommand_GETMethod(t *testing.T) { - var receivedMethod string - - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - receivedMethod = r.Method - fmt.Fprint(w, `{"ok":true}`) - })) - defer server.Close() - - ctx := slackcontext.MockContext(t.Context()) - clientsMock := shared.NewClientsMock() - clientsMock.AddDefaultMocks() - clientsMock.Config.TokenFlag = "xoxb-test-token" - clientsMock.Config.APIHostResolved = server.URL - clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - - cmd := NewCommand(clients) - testutil.MockCmdIO(clients.IO, cmd) - - flags = cmdFlags{method: "GET"} - cmd.SetArgs([]string{"auth.test"}) - err := cmd.ExecuteContext(ctx) - - assert.NoError(t, err) - assert.Equal(t, "GET", receivedMethod) +func Test_runAPICommand_BodyFormats(t *testing.T) { + tests := map[string]struct { + flags cmdFlags + args []string + expectedMethod string + expectedCT string + expectedAuth string + bodyContains []string + bodyEquals string + }{ + "form-encoded key=value params": { + flags: cmdFlags{method: "POST"}, + args: []string{"chat.postMessage", "channel=C123", "text=hello"}, + expectedMethod: "POST", + expectedCT: "application/x-www-form-urlencoded", + bodyContains: []string{"channel=C123", "text=hello", "token=xoxb-test-token"}, + }, + "JSON auto-detect from arg": { + flags: cmdFlags{method: "POST"}, + args: []string{"chat.postMessage", `{"channel":"C123","text":"hello"}`}, + expectedCT: "application/json; charset=utf-8", + expectedAuth: "Bearer xoxb-test-token", + bodyEquals: `{"channel":"C123","text":"hello"}`, + }, + "JSON via --json flag": { + flags: cmdFlags{method: "POST", json: `{"channel":"C123"}`}, + args: []string{"auth.test"}, + expectedCT: "application/json; charset=utf-8", + expectedAuth: "Bearer xoxb-test-token", + bodyEquals: `{"channel":"C123"}`, + }, + "form-encoded via --data flag": { + flags: cmdFlags{method: "POST", data: "channel=C123&text=hello"}, + args: []string{"chat.postMessage"}, + expectedCT: "application/x-www-form-urlencoded", + bodyContains: []string{"channel=C123", "text=hello", "token=xoxb-test-token"}, + }, + "GET method": { + flags: cmdFlags{method: "GET"}, + args: []string{"auth.test"}, + expectedMethod: "GET", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var receivedMethod string + var receivedContentType string + var receivedAuth string + var receivedBody string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedMethod = r.Method + receivedContentType = r.Header.Get("Content-Type") + receivedAuth = r.Header.Get("Authorization") + body, _ := io.ReadAll(r.Body) + receivedBody = string(body) + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clientsMock.Config.TokenFlag = "xoxb-test-token" + clientsMock.Config.APIHostResolved = server.URL + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + testutil.MockCmdIO(clients.IO, cmd) + + flags = tc.flags + cmd.SetArgs(tc.args) + err := cmd.ExecuteContext(ctx) + + assert.NoError(t, err) + if tc.expectedMethod != "" { + assert.Equal(t, tc.expectedMethod, receivedMethod) + } + if tc.expectedCT != "" { + assert.Equal(t, tc.expectedCT, receivedContentType) + } + if tc.expectedAuth != "" { + assert.Equal(t, tc.expectedAuth, receivedAuth) + } + if tc.bodyEquals != "" { + assert.Equal(t, tc.bodyEquals, receivedBody) + } + for _, s := range tc.bodyContains { + assert.Contains(t, receivedBody, s) + } + }) + } } func Test_runAPICommand_IncludeHeaders(t *testing.T) {