diff --git a/cmd/api/api.go b/cmd/api/api.go index 39e60f2c..7405a0e5 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "net/url" "os" @@ -40,6 +41,7 @@ type cmdFlags struct { data string headers []string include bool + noAuth bool } var flags cmdFlags @@ -67,7 +69,11 @@ 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) Select installed app and use bot token", + " 5. App prompt (in project) Select installed app or \"No app\"", + "", + "If no token is available, the request is sent without authentication.", + "Use --no-auth to skip authentication entirely and send the request without", + "a token.", "", "See all methods at: https://docs.slack.dev/reference/methods", }, "\n"), @@ -90,6 +96,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { {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 blocks.validate --no-auth blocks='[{"type":"section","text":{"type":"mrkdwn","text":"Hello"}}]'`, Meaning: "Validate Block Kit blocks (no auth required)"}, {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"}, }), @@ -108,6 +115,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { 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.Flags().BoolVar(&flags.noAuth, "no-auth", false, "skip authentication (send request without a token)") cmd.MarkFlagsMutuallyExclusive("json", "data") return cmd @@ -119,9 +127,18 @@ func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []str method := args[0] params := args[1:] - token, err := resolveToken(ctx, clients) - if err != nil { - return err + if flags.noAuth && (clients.Config.TokenFlag != "" || clients.Config.AppFlag != "") { + return slackerror.New(slackerror.ErrMismatchedFlags). + WithMessage("--no-auth cannot be used with --token or --app") + } + + var token = "" + if !flags.noAuth { + var err error + token, err = resolveToken(ctx, clients) + if err != nil { + return err + } } apiHost := clients.Config.APIHostResolved @@ -142,7 +159,7 @@ func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []str case flags.data != "": contentType = "application/x-www-form-urlencoded" formData := flags.data - if !strings.Contains(formData, "token=") { + if token != "" && !strings.Contains(formData, "token=") { if formData != "" { formData = formData + "&token=" + url.QueryEscape(token) } else { @@ -157,7 +174,9 @@ func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []str case len(params) > 0: contentType = "application/x-www-form-urlencoded" values := url.Values{} - values.Set("token", token) + if token != "" { + values.Set("token", token) + } for _, param := range params { key, value, ok := strings.Cut(param, "=") if !ok { @@ -171,7 +190,9 @@ func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []str default: contentType = "application/x-www-form-urlencoded" values := url.Values{} - values.Set("token", token) + if token != "" { + values.Set("token", token) + } bodyReader = strings.NewReader(values.Encode()) token = "" } @@ -254,8 +275,11 @@ 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) + selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly, prompts.WithNoAppOption()) if err != nil { + if errors.Is(err, prompts.ErrNoAppSelected) { + return "", nil + } return "", err } if selected.App.AppID != "" { @@ -266,9 +290,7 @@ func resolveToken(ctx context.Context, clients *shared.ClientFactory) (string, e } } - return "", slackerror.New(slackerror.ErrNotAuthed). - WithMessage("no token found"). - WithRemediation("Provide a token with --token, --app, or set SLACK_BOT_TOKEN") + return "", nil } // installAndGetBotToken installs the selected app and returns its bot token diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 976cf175..8f526397 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -44,13 +44,15 @@ func Test_NewCommand(t *testing.T) { 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 + flags cmdFlags + args []string + expectedMethod string + expectedCT string + expectedAuth string + assertNoAuth bool + bodyContains []string + bodyNotContains []string + bodyEquals string }{ "form-encoded key=value params": { flags: cmdFlags{method: "POST"}, @@ -84,6 +86,36 @@ func Test_runAPICommand_BodyFormats(t *testing.T) { args: []string{"auth.test"}, expectedMethod: "GET", }, + "no token with key=value params": { + flags: cmdFlags{method: "POST"}, + args: []string{"blocks.validate", "blocks=[...]"}, + expectedCT: "application/x-www-form-urlencoded", + assertNoAuth: true, + bodyContains: []string{"blocks="}, + bodyNotContains: []string{"token="}, + }, + "no token with --data flag": { + flags: cmdFlags{method: "POST", data: "blocks=[...]"}, + args: []string{"blocks.validate"}, + expectedCT: "application/x-www-form-urlencoded", + assertNoAuth: true, + bodyEquals: "blocks=[...]", + bodyNotContains: []string{"token="}, + }, + "no token with --json flag": { + flags: cmdFlags{method: "POST", json: `{"blocks":[]}`}, + args: []string{"blocks.validate"}, + expectedCT: "application/json; charset=utf-8", + assertNoAuth: true, + bodyEquals: `{"blocks":[]}`, + }, + "no token with no params": { + flags: cmdFlags{method: "POST"}, + args: []string{"api.test"}, + expectedCT: "application/x-www-form-urlencoded", + assertNoAuth: true, + bodyEquals: "", + }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { @@ -105,7 +137,9 @@ func Test_runAPICommand_BodyFormats(t *testing.T) { ctx := slackcontext.MockContext(t.Context()) clientsMock := shared.NewClientsMock() clientsMock.AddDefaultMocks() - clientsMock.Config.TokenFlag = "xoxb-test-token" + if !tc.assertNoAuth { + clientsMock.Config.TokenFlag = "xoxb-test-token" + } clientsMock.Config.APIHostResolved = server.URL clients := shared.NewClientFactory(clientsMock.MockClientFactory()) @@ -126,12 +160,22 @@ func Test_runAPICommand_BodyFormats(t *testing.T) { if tc.expectedAuth != "" { assert.Equal(t, tc.expectedAuth, receivedAuth) } + if tc.assertNoAuth { + assert.Empty(t, receivedAuth) + assert.NotContains(t, receivedBody, "token=") + } else { + assert.True(t, receivedAuth != "" || strings.Contains(receivedBody, "token="), + "expected auth via Authorization header or token in body") + } if tc.bodyEquals != "" { assert.Equal(t, tc.bodyEquals, receivedBody) } for _, s := range tc.bodyContains { assert.Contains(t, receivedBody, s) } + for _, s := range tc.bodyNotContains { + assert.NotContains(t, receivedBody, s) + } }) } } @@ -548,7 +592,63 @@ func Test_resolveToken_NoTokenFound(t *testing.T) { clientsMock := shared.NewClientsMock() clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - _, err := resolveToken(ctx, clients) - assert.Error(t, err) - assert.Contains(t, err.Error(), "no token found") + token, err := resolveToken(ctx, clients) + assert.NoError(t, err) + assert.Empty(t, token) +} + +func Test_runAPICommand_NoAuth_MutualExclusivity(t *testing.T) { + tests := map[string]struct { + tokenFlag string + appFlag string + }{ + "no-auth with --token": { + tokenFlag: "xoxb-test", + }, + "no-auth with --app": { + appFlag: "A123", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.Config.TokenFlag = tc.tokenFlag + clientsMock.Config.AppFlag = tc.appFlag + clientsMock.Config.APIHostResolved = "https://slack.com" + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + testutil.MockCmdIO(clients.IO, cmd) + + flags = cmdFlags{method: "POST", noAuth: true} + cmd.SetArgs([]string{"blocks.validate"}) + err := cmd.ExecuteContext(ctx) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "--no-auth cannot be used with --token or --app") + }) + } +} + +func Test_runAPICommand_NoAuth_SkipsTokenResolution(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"ok":true}`) + })) + defer server.Close() + + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.AddDefaultMocks() + clientsMock.Config.APIHostResolved = server.URL + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + cmd := NewCommand(clients) + testutil.MockCmdIO(clients.IO, cmd) + + flags = cmdFlags{method: "POST", noAuth: true} + cmd.SetArgs([]string{"api.test"}) + err := cmd.ExecuteContext(ctx) + + assert.NoError(t, err) } diff --git a/internal/prompts/app_select.go b/internal/prompts/app_select.go index 81601583..c3ff8125 100644 --- a/internal/prompts/app_select.go +++ b/internal/prompts/app_select.go @@ -91,6 +91,23 @@ var appTransferDisclaimer = style.TextSection{ }, } +// ErrNoAppSelected is returned when the user selects "No app" in the prompt +var ErrNoAppSelected = fmt.Errorf("no app selected") + +// AppSelectOption configures optional behavior of AppSelectPrompt +type AppSelectOption func(*appSelectConfig) + +type appSelectConfig struct { + includeNoApp bool +} + +// WithNoAppOption adds a "No app" choice to the selection prompt +func WithNoAppOption() AppSelectOption { + return func(c *appSelectConfig) { + c.includeNoApp = true + } +} + var SelectTeamPrompt = "Select a team" // getApps returns the apps saved to files with known credentials @@ -388,17 +405,22 @@ func AppSelectPrompt( clients *shared.ClientFactory, environment AppEnvironmentType, status AppInstallStatus, + opts ...AppSelectOption, ) ( selected SelectedApp, err error, ) { + var cfg appSelectConfig + for _, opt := range opts { + opt(&cfg) + } switch { case environment.Equals(ShowAllEnvironments) && types.IsAppFlagEnvironment(clients.Config.AppFlag): switch { case types.IsAppFlagDeploy(clients.Config.AppFlag): - return AppSelectPrompt(ctx, clients, ShowHostedOnly, status) + return AppSelectPrompt(ctx, clients, ShowHostedOnly, status, opts...) case types.IsAppFlagLocal(clients.Config.AppFlag): - return AppSelectPrompt(ctx, clients, ShowLocalOnly, status) + return AppSelectPrompt(ctx, clients, ShowLocalOnly, status, opts...) } case environment.Equals(ShowLocalOnly) && types.IsAppFlagDeploy(clients.Config.AppFlag): return SelectedApp{}, slackerror.New(slackerror.ErrDeployedAppNotSupported) @@ -578,6 +600,10 @@ func AppSelectPrompt( return SelectedApp{}, slackerror.New(slackerror.ErrTeamNotFound) } } + noApp := style.Secondary("No app") + if cfg.includeNoApp { + options = append(options, Selection{label: noApp}) + } labels := []string{} for _, label := range options { labels = append(labels, label.label) @@ -621,6 +647,8 @@ func AppSelectPrompt( } creation := style.Secondary("Create a new app") switch { + case selection.Prompt && options[selection.Index].label == noApp: + return SelectedApp{}, ErrNoAppSelected case selection.Prompt && options[selection.Index].label != creation: return options[selection.Index].app, nil case selection.Prompt && options[selection.Index].label == creation: diff --git a/internal/prompts/app_select_mock.go b/internal/prompts/app_select_mock.go index 5353486e..a7fb91f0 100644 --- a/internal/prompts/app_select_mock.go +++ b/internal/prompts/app_select_mock.go @@ -32,7 +32,7 @@ func NewAppSelectMock() *AppSelectMock { } // AppSelectPrompt mocks the app selection prompt -func (m *AppSelectMock) AppSelectPrompt(ctx context.Context, clients *shared.ClientFactory, env AppEnvironmentType, status AppInstallStatus) (SelectedApp, error) { +func (m *AppSelectMock) AppSelectPrompt(ctx context.Context, clients *shared.ClientFactory, env AppEnvironmentType, status AppInstallStatus, opts ...AppSelectOption) (SelectedApp, error) { args := m.Called(ctx, clients, env, status) return args.Get(0).(SelectedApp), args.Error(1) }