Skip to content

Commit aceb7a4

Browse files
authored
Merge branch 'main' into ale-app-id-flag
2 parents e57f7d5 + ae61a10 commit aceb7a4

6 files changed

Lines changed: 178 additions & 26 deletions

File tree

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,6 @@ jobs:
131131
gh pr edit "$EXISTING_PR" --title "$PR_TITLE" --body "$PR_BODY" --milestone "Next Release" --add-label "release" --add-label "semver:${SEMVER}"
132132
echo "Updated PR #$EXISTING_PR"
133133
else
134-
gh pr create --head rc --base main --title "$PR_TITLE" --body "$PR_BODY" --milestone "Next Release" --add-label "release" --add-label "semver:${SEMVER}"
134+
gh pr create --draft --head rc --base main --title "$PR_TITLE" --body "$PR_BODY" --milestone "Next Release" --add-label "release" --add-label "semver:${SEMVER}"
135135
echo "Created new release PR"
136136
fi

cmd/api/api.go

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ type cmdFlags struct {
4040
data string
4141
headers []string
4242
include bool
43+
noAuth bool
4344
}
4445

4546
var flags cmdFlags
@@ -67,7 +68,11 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
6768
" 2. --app flag Install app and use bot token (in project)",
6869
" 3. SLACK_BOT_TOKEN env var Bot token (set during slack deploy)",
6970
" 4. SLACK_USER_TOKEN env var User token",
70-
" 5. App prompt (in project) Select installed app and use bot token",
71+
" 5. App prompt (in project) Select installed app or \"No app\"",
72+
"",
73+
"If no token is available, the request is sent without authentication.",
74+
"Use --no-auth to skip authentication entirely and send the request without",
75+
"a token.",
7176
"",
7277
"See all methods at: https://docs.slack.dev/reference/methods",
7378
}, "\n"),
@@ -90,6 +95,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
9095
{Command: "api users.info user=U0123456", Meaning: "Get user details"},
9196
{Command: "api users.list", Meaning: "List workspace members"},
9297
{Command: "api users.profile.get user=U0123456", Meaning: "Get a user's profile"},
98+
{Command: `api blocks.validate --no-auth blocks='[{"type":"section","text":{"type":"mrkdwn","text":"Hello"}}]'`, Meaning: "Validate Block Kit blocks (no auth required)"},
9399
{Command: "api views.open trigger_id=T0123456 view={...}", Meaning: "Open a modal view"},
94100
{Command: "api views.update view_id=V0123456 view={...}", Meaning: "Update a modal view"},
95101
}),
@@ -108,6 +114,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
108114
cmd.Flags().StringVar(&flags.data, "data", "", "form-encoded request body string (e.g. \"key1=val1&key2=val2\")")
109115
cmd.Flags().StringSliceVarP(&flags.headers, "header", "H", nil, "additional HTTP headers (format: \"Key: Value\")")
110116
cmd.Flags().BoolVarP(&flags.include, "include", "i", false, "include HTTP status code and response headers in output")
117+
cmd.Flags().BoolVar(&flags.noAuth, "no-auth", false, "skip authentication (send request without a token)")
111118
cmd.MarkFlagsMutuallyExclusive("json", "data")
112119

113120
return cmd
@@ -119,9 +126,18 @@ func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []str
119126
method := args[0]
120127
params := args[1:]
121128

122-
token, err := resolveToken(ctx, clients)
123-
if err != nil {
124-
return err
129+
if flags.noAuth && (clients.Config.TokenFlag != "" || clients.Config.AppFlag != "") {
130+
return slackerror.New(slackerror.ErrMismatchedFlags).
131+
WithMessage("--no-auth cannot be used with --token or --app")
132+
}
133+
134+
var token = ""
135+
if !flags.noAuth {
136+
var err error
137+
token, err = resolveToken(ctx, clients)
138+
if err != nil {
139+
return err
140+
}
125141
}
126142

127143
apiHost := clients.Config.APIHostResolved
@@ -142,7 +158,7 @@ func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []str
142158
case flags.data != "":
143159
contentType = "application/x-www-form-urlencoded"
144160
formData := flags.data
145-
if !strings.Contains(formData, "token=") {
161+
if token != "" && !strings.Contains(formData, "token=") {
146162
if formData != "" {
147163
formData = formData + "&token=" + url.QueryEscape(token)
148164
} else {
@@ -157,7 +173,9 @@ func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []str
157173
case len(params) > 0:
158174
contentType = "application/x-www-form-urlencoded"
159175
values := url.Values{}
160-
values.Set("token", token)
176+
if token != "" {
177+
values.Set("token", token)
178+
}
161179
for _, param := range params {
162180
key, value, ok := strings.Cut(param, "=")
163181
if !ok {
@@ -171,7 +189,9 @@ func runAPICommand(cmd *cobra.Command, clients *shared.ClientFactory, args []str
171189
default:
172190
contentType = "application/x-www-form-urlencoded"
173191
values := url.Values{}
174-
values.Set("token", token)
192+
if token != "" {
193+
values.Set("token", token)
194+
}
175195
bodyReader = strings.NewReader(values.Encode())
176196
token = ""
177197
}
@@ -254,8 +274,11 @@ func resolveToken(ctx context.Context, clients *shared.ClientFactory) (string, e
254274
}
255275

256276
if sdkConfigExists, _ := clients.SDKConfig.Exists(); sdkConfigExists {
257-
selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly)
277+
selected, err := prompts.AppSelectPrompt(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAppsOnly, prompts.WithNoAppOption())
258278
if err != nil {
279+
if slackerror.Is(err, slackerror.ErrNoAppSelected) {
280+
return "", nil
281+
}
259282
return "", err
260283
}
261284
if selected.App.AppID != "" {
@@ -266,9 +289,7 @@ func resolveToken(ctx context.Context, clients *shared.ClientFactory) (string, e
266289
}
267290
}
268291

269-
return "", slackerror.New(slackerror.ErrNotAuthed).
270-
WithMessage("no token found").
271-
WithRemediation("Provide a token with --token, --app, or set SLACK_BOT_TOKEN")
292+
return "", nil
272293
}
273294

274295
// installAndGetBotToken installs the selected app and returns its bot token

cmd/api/api_test.go

Lines changed: 111 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,15 @@ func Test_NewCommand(t *testing.T) {
4444

4545
func Test_runAPICommand_BodyFormats(t *testing.T) {
4646
tests := map[string]struct {
47-
flags cmdFlags
48-
args []string
49-
expectedMethod string
50-
expectedCT string
51-
expectedAuth string
52-
bodyContains []string
53-
bodyEquals string
47+
flags cmdFlags
48+
args []string
49+
expectedMethod string
50+
expectedCT string
51+
expectedAuth string
52+
assertNoAuth bool
53+
bodyContains []string
54+
bodyNotContains []string
55+
bodyEquals string
5456
}{
5557
"form-encoded key=value params": {
5658
flags: cmdFlags{method: "POST"},
@@ -84,6 +86,36 @@ func Test_runAPICommand_BodyFormats(t *testing.T) {
8486
args: []string{"auth.test"},
8587
expectedMethod: "GET",
8688
},
89+
"no token with key=value params": {
90+
flags: cmdFlags{method: "POST"},
91+
args: []string{"blocks.validate", "blocks=[...]"},
92+
expectedCT: "application/x-www-form-urlencoded",
93+
assertNoAuth: true,
94+
bodyContains: []string{"blocks="},
95+
bodyNotContains: []string{"token="},
96+
},
97+
"no token with --data flag": {
98+
flags: cmdFlags{method: "POST", data: "blocks=[...]"},
99+
args: []string{"blocks.validate"},
100+
expectedCT: "application/x-www-form-urlencoded",
101+
assertNoAuth: true,
102+
bodyEquals: "blocks=[...]",
103+
bodyNotContains: []string{"token="},
104+
},
105+
"no token with --json flag": {
106+
flags: cmdFlags{method: "POST", json: `{"blocks":[]}`},
107+
args: []string{"blocks.validate"},
108+
expectedCT: "application/json; charset=utf-8",
109+
assertNoAuth: true,
110+
bodyEquals: `{"blocks":[]}`,
111+
},
112+
"no token with no params": {
113+
flags: cmdFlags{method: "POST"},
114+
args: []string{"api.test"},
115+
expectedCT: "application/x-www-form-urlencoded",
116+
assertNoAuth: true,
117+
bodyEquals: "",
118+
},
87119
}
88120
for name, tc := range tests {
89121
t.Run(name, func(t *testing.T) {
@@ -105,7 +137,9 @@ func Test_runAPICommand_BodyFormats(t *testing.T) {
105137
ctx := slackcontext.MockContext(t.Context())
106138
clientsMock := shared.NewClientsMock()
107139
clientsMock.AddDefaultMocks()
108-
clientsMock.Config.TokenFlag = "xoxb-test-token"
140+
if !tc.assertNoAuth {
141+
clientsMock.Config.TokenFlag = "xoxb-test-token"
142+
}
109143
clientsMock.Config.APIHostResolved = server.URL
110144
clients := shared.NewClientFactory(clientsMock.MockClientFactory())
111145

@@ -126,12 +160,22 @@ func Test_runAPICommand_BodyFormats(t *testing.T) {
126160
if tc.expectedAuth != "" {
127161
assert.Equal(t, tc.expectedAuth, receivedAuth)
128162
}
163+
if tc.assertNoAuth {
164+
assert.Empty(t, receivedAuth)
165+
assert.NotContains(t, receivedBody, "token=")
166+
} else {
167+
assert.True(t, receivedAuth != "" || strings.Contains(receivedBody, "token="),
168+
"expected auth via Authorization header or token in body")
169+
}
129170
if tc.bodyEquals != "" {
130171
assert.Equal(t, tc.bodyEquals, receivedBody)
131172
}
132173
for _, s := range tc.bodyContains {
133174
assert.Contains(t, receivedBody, s)
134175
}
176+
for _, s := range tc.bodyNotContains {
177+
assert.NotContains(t, receivedBody, s)
178+
}
135179
})
136180
}
137181
}
@@ -548,7 +592,63 @@ func Test_resolveToken_NoTokenFound(t *testing.T) {
548592
clientsMock := shared.NewClientsMock()
549593
clients := shared.NewClientFactory(clientsMock.MockClientFactory())
550594

551-
_, err := resolveToken(ctx, clients)
552-
assert.Error(t, err)
553-
assert.Contains(t, err.Error(), "no token found")
595+
token, err := resolveToken(ctx, clients)
596+
assert.NoError(t, err)
597+
assert.Empty(t, token)
598+
}
599+
600+
func Test_runAPICommand_NoAuth_MutualExclusivity(t *testing.T) {
601+
tests := map[string]struct {
602+
tokenFlag string
603+
appFlag string
604+
}{
605+
"no-auth with --token": {
606+
tokenFlag: "xoxb-test",
607+
},
608+
"no-auth with --app": {
609+
appFlag: "A123",
610+
},
611+
}
612+
for name, tc := range tests {
613+
t.Run(name, func(t *testing.T) {
614+
ctx := slackcontext.MockContext(t.Context())
615+
clientsMock := shared.NewClientsMock()
616+
clientsMock.Config.TokenFlag = tc.tokenFlag
617+
clientsMock.Config.AppFlag = tc.appFlag
618+
clientsMock.Config.APIHostResolved = "https://slack.com"
619+
clients := shared.NewClientFactory(clientsMock.MockClientFactory())
620+
621+
cmd := NewCommand(clients)
622+
testutil.MockCmdIO(clients.IO, cmd)
623+
624+
flags = cmdFlags{method: "POST", noAuth: true}
625+
cmd.SetArgs([]string{"blocks.validate"})
626+
err := cmd.ExecuteContext(ctx)
627+
628+
assert.Error(t, err)
629+
assert.Contains(t, err.Error(), "--no-auth cannot be used with --token or --app")
630+
})
631+
}
632+
}
633+
634+
func Test_runAPICommand_NoAuth_SkipsTokenResolution(t *testing.T) {
635+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
636+
fmt.Fprint(w, `{"ok":true}`)
637+
}))
638+
defer server.Close()
639+
640+
ctx := slackcontext.MockContext(t.Context())
641+
clientsMock := shared.NewClientsMock()
642+
clientsMock.AddDefaultMocks()
643+
clientsMock.Config.APIHostResolved = server.URL
644+
clients := shared.NewClientFactory(clientsMock.MockClientFactory())
645+
646+
cmd := NewCommand(clients)
647+
testutil.MockCmdIO(clients.IO, cmd)
648+
649+
flags = cmdFlags{method: "POST", noAuth: true}
650+
cmd.SetArgs([]string{"api.test"})
651+
err := cmd.ExecuteContext(ctx)
652+
653+
assert.NoError(t, err)
554654
}

internal/prompts/app_select.go

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,20 @@ var appTransferDisclaimer = style.TextSection{
9191
},
9292
}
9393

94+
// AppSelectOption configures optional behavior of AppSelectPrompt
95+
type AppSelectOption func(*appSelectConfig)
96+
97+
type appSelectConfig struct {
98+
includeNoApp bool
99+
}
100+
101+
// WithNoAppOption adds a "No app" choice to the selection prompt
102+
func WithNoAppOption() AppSelectOption {
103+
return func(c *appSelectConfig) {
104+
c.includeNoApp = true
105+
}
106+
}
107+
94108
var SelectTeamPrompt = "Select a team"
95109

96110
// getApps returns the apps saved to files with known credentials
@@ -388,17 +402,22 @@ func AppSelectPrompt(
388402
clients *shared.ClientFactory,
389403
environment AppEnvironmentType,
390404
status AppInstallStatus,
405+
opts ...AppSelectOption,
391406
) (
392407
selected SelectedApp,
393408
err error,
394409
) {
410+
var cfg appSelectConfig
411+
for _, opt := range opts {
412+
opt(&cfg)
413+
}
395414
switch {
396415
case environment.Equals(ShowAllEnvironments) && types.IsAppFlagEnvironment(clients.Config.AppFlag):
397416
switch {
398417
case types.IsAppFlagDeploy(clients.Config.AppFlag):
399-
return AppSelectPrompt(ctx, clients, ShowHostedOnly, status)
418+
return AppSelectPrompt(ctx, clients, ShowHostedOnly, status, opts...)
400419
case types.IsAppFlagLocal(clients.Config.AppFlag):
401-
return AppSelectPrompt(ctx, clients, ShowLocalOnly, status)
420+
return AppSelectPrompt(ctx, clients, ShowLocalOnly, status, opts...)
402421
}
403422
case environment.Equals(ShowLocalOnly) && types.IsAppFlagDeploy(clients.Config.AppFlag):
404423
return SelectedApp{}, slackerror.New(slackerror.ErrDeployedAppNotSupported)
@@ -578,6 +597,10 @@ func AppSelectPrompt(
578597
return SelectedApp{}, slackerror.New(slackerror.ErrTeamNotFound)
579598
}
580599
}
600+
noApp := style.Secondary("No app")
601+
if cfg.includeNoApp {
602+
options = append(options, Selection{label: noApp})
603+
}
581604
labels := []string{}
582605
for _, label := range options {
583606
labels = append(labels, label.label)
@@ -621,6 +644,8 @@ func AppSelectPrompt(
621644
}
622645
creation := style.Secondary("Create a new app")
623646
switch {
647+
case selection.Prompt && options[selection.Index].label == noApp:
648+
return SelectedApp{}, slackerror.New(slackerror.ErrNoAppSelected)
624649
case selection.Prompt && options[selection.Index].label != creation:
625650
return options[selection.Index].app, nil
626651
case selection.Prompt && options[selection.Index].label == creation:

internal/prompts/app_select_mock.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func NewAppSelectMock() *AppSelectMock {
3232
}
3333

3434
// AppSelectPrompt mocks the app selection prompt
35-
func (m *AppSelectMock) AppSelectPrompt(ctx context.Context, clients *shared.ClientFactory, env AppEnvironmentType, status AppInstallStatus) (SelectedApp, error) {
35+
func (m *AppSelectMock) AppSelectPrompt(ctx context.Context, clients *shared.ClientFactory, env AppEnvironmentType, status AppInstallStatus, opts ...AppSelectOption) (SelectedApp, error) {
3636
args := m.Called(ctx, clients, env, status)
3737
return args.Get(0).(SelectedApp), args.Error(1)
3838
}

internal/slackerror/errors.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ const (
198198
ErrNotAuthed = "not_authed"
199199
ErrNotBearerToken = "not_bearer_token"
200200
ErrNotFound = "not_found"
201+
ErrNoAppSelected = "no_app_selected"
201202
ErrNoFile = "no_file"
202203
ErrNoPendingRequest = "no_pending_request"
203204
ErrNoPermission = "no_permission"
@@ -1241,6 +1242,11 @@ Otherwise start your app for local development with: %s`,
12411242
Message: "Couldn't find row",
12421243
},
12431244

1245+
ErrNoAppSelected: {
1246+
Code: ErrNoAppSelected,
1247+
Message: "No app selected",
1248+
},
1249+
12441250
ErrNoFile: {
12451251
Code: ErrNoFile,
12461252
Message: "Couldn't upload your bundled code to server",

0 commit comments

Comments
 (0)