diff --git a/cmd/api/api.go b/cmd/api/api.go index 39e60f2c..8a023946 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -40,6 +40,7 @@ type cmdFlags struct { data string headers []string include bool + noAuth bool } var flags cmdFlags @@ -69,6 +70,9 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { " 4. SLACK_USER_TOKEN env var User token", " 5. App prompt (in project) Select installed app and use bot token", "", + "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"), Example: style.ExampleCommandsf([]style.ExampleCommand{ @@ -90,6 +94,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 +113,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 +125,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 +157,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 +172,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 +188,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 = "" } diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 976cf175..7029f561 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-auth with key=value params": { + flags: cmdFlags{method: "POST", noAuth: true}, + args: []string{"blocks.validate", "blocks=[...]"}, + expectedCT: "application/x-www-form-urlencoded", + assertNoAuth: true, + bodyContains: []string{"blocks="}, + bodyNotContains: []string{"token="}, + }, + "no-auth with --data flag": { + flags: cmdFlags{method: "POST", noAuth: true, data: "blocks=[...]"}, + args: []string{"blocks.validate"}, + expectedCT: "application/x-www-form-urlencoded", + assertNoAuth: true, + bodyEquals: "blocks=[...]", + bodyNotContains: []string{"token="}, + }, + "no-auth with --json flag": { + flags: cmdFlags{method: "POST", noAuth: true, json: `{"blocks":[]}`}, + args: []string{"blocks.validate"}, + expectedCT: "application/json; charset=utf-8", + assertNoAuth: true, + bodyEquals: `{"blocks":[]}`, + }, + "no-auth with no params": { + flags: cmdFlags{method: "POST", noAuth: true}, + 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.flags.noAuth { + 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) + } }) } } @@ -552,3 +596,59 @@ func Test_resolveToken_NoTokenFound(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "no token found") } + +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) +}