diff --git a/cmd/api/api.go b/cmd/api/api.go new file mode 100644 index 00000000..d757edd6 --- /dev/null +++ b/cmd/api/api.go @@ -0,0 +1,304 @@ +// 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" + "sort" + "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 + +// 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]", + 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 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) Select installed app and use bot token", + "", + "See all methods at: https://docs.slack.dev/reference/methods", + }, "\n"), + Example: style.ExampleCommandsf([]style.ExampleCommand{ + {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 { + 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 +} + +// 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] + 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 + + // 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" + 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) + 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) + } + } + 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 +} + +// 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 + } + + if clients.Config.AppFlag != "" { + if sdkConfigExists, _ := clients.SDKConfig.Exists(); sdkConfigExists { + selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly) + if err != nil { + return "", err + } + if 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 + } + + if sdkConfigExists, _ := clients.SDKConfig.Exists(); sdkConfigExists { + selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly) + if err != nil { + return "", err + } + if 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") +} + +// 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 + 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..976cf175 --- /dev/null +++ b/cmd/api/api_test.go @@ -0,0 +1,554 @@ +// 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" + + 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_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) { + 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_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) + 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_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_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") + + 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, mock.Anything).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_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" + + _, err := resolveToken(ctx, clients) + assert.Error(t, err) + assert.Contains(t, err.Error(), "app_not_found") +} + +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_NoTokenFound(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + _, err := resolveToken(ctx, clients) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no token found") +} 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/raw_request.go b/internal/api/raw_request.go new file mode 100644 index 00000000..a08a2fc4 --- /dev/null +++ b/internal/api/raw_request.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/raw_request_test.go b/internal/api/raw_request_test.go new file mode 100644 index 00000000..ddccc2ad --- /dev/null +++ b/internal/api/raw_request_test.go @@ -0,0 +1,164 @@ +// 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)) +} + +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)) +}