Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 33 additions & 11 deletions cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
Expand All @@ -40,6 +41,7 @@ type cmdFlags struct {
data string
headers []string
include bool
noAuth bool
}

var flags cmdFlags
Expand Down Expand Up @@ -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"),
Expand All @@ -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"},
}),
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 = ""
}
Expand Down Expand Up @@ -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 != "" {
Expand All @@ -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
Expand Down
122 changes: 111 additions & 11 deletions cmd/api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down Expand Up @@ -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) {
Expand All @@ -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())

Expand All @@ -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)
}
})
}
}
Expand Down Expand Up @@ -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)
}
32 changes: 30 additions & 2 deletions internal/prompts/app_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Comment on lines +94 to +95
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚧 suggestion: Can we import this from errors.go instead?


// AppSelectOption configures optional behavior of AppSelectPrompt
type AppSelectOption func(*appSelectConfig)

type appSelectConfig struct {
includeNoApp bool
}
Comment on lines +97 to +102
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌟 praise: Excited for what this pattern unlocks in customizations!


// 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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion internal/prompts/app_select_mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading