diff --git a/cmd/app/app.go b/cmd/app/app.go index 124f5d35..299388d8 100644 --- a/cmd/app/app.go +++ b/cmd/app/app.go @@ -52,6 +52,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { cmd.AddCommand(NewDeleteCommand(clients)) cmd.AddCommand(NewLinkCommand(clients)) cmd.AddCommand(NewListCommand(clients)) + cmd.AddCommand(NewRequestCommand(clients)) cmd.AddCommand(NewSettingsCommand(clients)) cmd.AddCommand(NewUninstallCommand(clients)) cmd.AddCommand(NewUnlinkCommand(clients)) diff --git a/cmd/app/request.go b/cmd/app/request.go new file mode 100644 index 00000000..d4abb88d --- /dev/null +++ b/cmd/app/request.go @@ -0,0 +1,259 @@ +// 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 app + +import ( + "context" + "fmt" + "slices" + "strings" + "time" + + "github.com/opentracing/opentracing-go" + "github.com/slackapi/slack-cli/internal/api" + "github.com/slackapi/slack-cli/internal/cmdutil" + "github.com/slackapi/slack-cli/internal/experiment" + "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" +) + +// requestTimeFormat displays the moment a request changed +const requestTimeFormat = "2006-01-02 15:04:05 Z07:00" + +// Handle to a function used for testing +var requestAppSelectPromptFunc = prompts.AppSelectPrompt + +// Handle to a function used for testing +var requestTeamSelectPromptFunc = prompts.PromptTeamSlackAuth + +// Flags +type requestCmdFlags struct { + workspaceIDs []string +} + +var requestFlags requestCmdFlags + +// NewRequestCommand returns a new Cobra command +func NewRequestCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "request [flags]", + Aliases: []string{"requests"}, + Short: "Check approval requests to install the app", + Long: strings.Join([]string{ + "Check the status of your most recent request to have the app approved for", + "install.", + "", + "Requests are searched on the team of the authenticated account. An account of", + "a workspace that belongs to an organization also searches that organization,", + "while an account of an organization searches the organization alone.", + "", + "Other workspaces of an organization can be searched with the --workspace-ids", + "flag.", + "", + "Searches are made with the credentials of an authenticated account chosen", + "with the --team flag or a prompt.", + "", + "Apps saved to a project are chosen with a prompt, but any app can be checked", + "by app ID with the --app flag, which does not require a project.", + }, "\n"), + Hidden: true, + Example: style.ExampleCommandsf([]style.ExampleCommand{ + {Command: "app request", Meaning: "Check requests to install an app"}, + {Command: "app request --app A0123456789", Meaning: "Check requests for an app outside a project"}, + {Command: "app request --workspace-ids T0123456789,T9876543210", Meaning: "Check requests on certain workspaces of an organization"}, + }), + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + if !clients.Config.WithExperimentOn(experiment.AppApprovalStatus) { + return slackerror.New(slackerror.ErrExperimentRequired). + WithRemediation("Enable the %s experiment with %s", + style.Highlight(string(experiment.AppApprovalStatus)), + style.CommandText("--experiment app-approval-status"), + ) + } + clients.Config.SetFlags(cmd) + // An app named by ID is checked without the apps of a project + if types.IsAppID(clients.Config.AppFlag) { + return nil + } + // Verify command is run in a project directory + if err := cmdutil.IsValidProjectDirectory(clients); err != nil { + invalid := slackerror.ToSlackError(err) + return invalid.WithRemediation("%s\n\nApps of other projects can be checked with %s", + invalid.Remediation, + style.CommandText("--app A0123456789"), + ) + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runRequestCommand(cmd, clients) + }, + } + + cmd.Flags().StringSliceVar(&requestFlags.workspaceIDs, "workspace-ids", nil, "also check these workspaces of an organization,\nwith a maximum of 50 workspaces") + + return cmd +} + +// runRequestCommand will execute the request command +func runRequestCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { + ctx := cmd.Context() + span, ctx := opentracing.StartSpanFromContext(ctx, "cmd.app.request") + defer span.Finish() + + appID, auth, err := requestAppSelection(ctx, clients) + if err != nil { + return err + } + + result, err := clients.API().ListAppApprovalRequests(ctx, auth.Token, appID, requestFlags.workspaceIDs) + if err != nil { + return err + } + + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "lock", + Text: "App Install Approval Requests", + Secondary: FormatRequestSuccess(appID, requestTeamNames(auth), result.Requests), + })) + return nil +} + +// requestTeamNames collects the names of searched teams that are known. +// +// Requests are returned with team IDs alone, so only the team of the +// authenticated account is named. Other teams of an organization are not +// looked up to avoid another API call. +func requestTeamNames(auth types.SlackAuth) map[string]string { + if auth.TeamID == "" || auth.TeamDomain == "" { + return nil + } + return map[string]string{auth.TeamID: auth.TeamDomain} +} + +// requestAppSelection decides the app to check and the account to search with. +// +// An app named by ID with the app flag is checked without a project so that +// apps missing from a project can be checked too. The team of that app is +// gathered from the authenticated accounts instead of the project apps. +func requestAppSelection(ctx context.Context, clients *shared.ClientFactory) (appID string, auth types.SlackAuth, err error) { + if types.IsAppID(clients.Config.AppFlag) { + selected, err := requestTeamSelectPromptFunc(ctx, clients, "Select an account to search with", nil) + if err != nil { + return "", types.SlackAuth{}, err + } + if selected == nil || selected.Token == "" { + return "", types.SlackAuth{}, slackerror.New(slackerror.ErrCredentialsNotFound) + } + return clients.Config.AppFlag, *selected, nil + } + selection, err := requestAppSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps) + if err != nil { + return "", types.SlackAuth{}, err + } + if selection.App.AppID == "" { + return "", types.SlackAuth{}, slackerror.New(slackerror.ErrAppNotFound) + } + return selection.App.AppID, selection.Auth, nil +} + +// FormatRequestSuccess formats the install request of each team for an app. +// Teams found in teamNames are titled by name while others are titled by ID. +func FormatRequestSuccess(appID string, teamNames map[string]string, requests []api.AppsApprovalsRequest) (secondaryText []string) { + sorted := slices.SortedFunc(slices.Values(requests), func(a api.AppsApprovalsRequest, b api.AppsApprovalsRequest) int { + return strings.Compare(a.TeamID, b.TeamID) + }) + field := func(label string, value string) string { + return fmt.Sprintf(style.Indent(style.Secondary("%-13s %s")), label+":", value) + } + if appID != "" { + secondaryText = append(secondaryText, fmt.Sprintf(style.Bold("%-13s %s"), "App ID:", appID)) + } + // Requests are gathered apart from the app to know when none were made + requestsText := []string{} + for _, request := range sorted { + requestsText = append(requestsText, fmt.Sprintf(style.Bold("%s:"), formatRequestTeam(teamNames, request.TeamID))) + requestsText = append(requestsText, field("Request ID", request.ID)) + requestsText = append(requestsText, field("Status", formatRequestStatus(request.Status))) + requestsText = append(requestsText, field("Requested", formatRequestTime(request.DateCreated))) + if request.DateResolved > 0 { + requestsText = append(requestsText, field("Resolved", formatRequestTime(request.DateResolved))) + } + if request.CancelledBy != "" { + requestsText = append(requestsText, field("Cancelled by", formatRequestCancelledBy(request.CancelledBy))) + } + if request.CanSelfApprove { + requestsText = append(requestsText, style.Indent(style.Secondary("You can install this app without approval. Please cancel the request."))) + } + } + if len(requestsText) <= 0 { + requestsText = append(requestsText, "You have not requested to install this app") + } + secondaryText = append(secondaryText, requestsText...) + return +} + +// formatRequestTeam titles a team by name and ID when the name is known +func formatRequestTeam(teamNames map[string]string, teamID string) string { + if name, ok := teamNames[teamID]; ok { + return fmt.Sprintf("%s (%s)", name, teamID) + } + return teamID +} + +// formatRequestTime displays a Unix timestamp in the local timezone +func formatRequestTime(timestamp int64) string { + if timestamp <= 0 { + return "unknown" + } + return time.Unix(timestamp, 0).Format(requestTimeFormat) +} + +// formatRequestCancelledBy names the kind of actor that cancelled a request. +// Every returned request was made by the authenticated account, so a request +// cancelled by a user was withdrawn by that same account. +func formatRequestCancelledBy(actor api.AppsApprovalsRequestCancelledBy) string { + switch actor { + case api.AppsApprovalsRequestCancelledByAdmin: + return "an admin" + case api.AppsApprovalsRequestCancelledBySystem: + return "the system" + case api.AppsApprovalsRequestCancelledByUser: + return "you" + default: + return string(actor) + } +} + +// formatRequestStatus styles a status by how much attention it deserves +func formatRequestStatus(status api.AppsApprovalsRequestStatus) string { + switch status { + case api.AppsApprovalsRequestStatusApproved: + return style.Green(string(status)) + case api.AppsApprovalsRequestStatusCancelled: + return style.Secondary(string(status)) + case api.AppsApprovalsRequestStatusDenied: + return style.Red(string(status)) + case api.AppsApprovalsRequestStatusPending: + return style.Yellow(string(status)) + default: + return string(status) + } +} diff --git a/cmd/app/request_test.go b/cmd/app/request_test.go new file mode 100644 index 00000000..d57d6a33 --- /dev/null +++ b/cmd/app/request_test.go @@ -0,0 +1,406 @@ +// 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 app + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/slackapi/slack-cli/internal/api" + "github.com/slackapi/slack-cli/internal/experiment" + "github.com/slackapi/slack-cli/internal/hooks" + "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/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// mockRequestCreated is the moment a mocked request was made +var mockRequestCreated = time.Date(2026, 8, 21, 15, 4, 5, 0, time.UTC).Unix() + +// mockRequestResolved is the moment a mocked request was reviewed +var mockRequestResolved = time.Date(2026, 8, 22, 9, 30, 0, 0, time.UTC).Unix() + +func TestRequestCommand(t *testing.T) { + // enableRequest turns on the experiment that gates the command + enableRequest := func(ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cf.SDKConfig = hooks.NewSDKConfigMock() + cf.Config.ExperimentsFlag = []string{string(experiment.AppApprovalStatus)} + cf.Config.LoadExperiments(ctx, cf.IO.PrintDebug) + requestAppSelectPromptFunc = func(ctx context.Context, clients *shared.ClientFactory, environment prompts.AppEnvironmentType, status prompts.AppInstallStatus, opts ...prompts.AppSelectOption) (prompts.SelectedApp, error) { + return prompts.SelectedApp{ + App: types.App{AppID: "A1234", TeamID: "T1234", TeamDomain: "teamone"}, + Auth: types.SlackAuth{Token: "xoxp-example", TeamID: "T1234", TeamDomain: "teamone"}, + }, nil + } + } + + restoreRequest := func() { + requestAppSelectPromptFunc = prompts.AppSelectPrompt + requestTeamSelectPromptFunc = prompts.PromptTeamSlackAuth + } + + // enableRequestWithoutProject turns on the experiment outside of a project + enableRequestWithoutProject := func(ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cf.Config.ExperimentsFlag = []string{string(experiment.AppApprovalStatus)} + cf.Config.LoadExperiments(ctx, cf.IO.PrintDebug) + } + + testutil.TableTestCommand(t, testutil.CommandTests{ + "errors when the app-approval-status experiment is off": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cf.SDKConfig = hooks.NewSDKConfigMock() + cf.Config.LoadExperiments(ctx, cf.IO.PrintDebug) + }, + ExpectedError: slackerror.New(slackerror.ErrExperimentRequired), + }, + "reports a request that awaits review": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableRequest(ctx, cm, cf) + cm.API.On("ListAppApprovalRequests", mock.Anything, "xoxp-example", "A1234", []string(nil)). + Return(api.AppsApprovalsRequestsListResult{ + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatusPending, DateCreated: mockRequestCreated}, + }, + }, nil) + }, + Teardown: restoreRequest, + ExpectedOutputs: []string{ + "App Install Approval Requests", + "App ID: A1234", + "teamone (T1234):", + "Request ID: Ar1234", + "Status: pending", + "Requested:", + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "ListAppApprovalRequests", mock.Anything, "xoxp-example", "A1234", []string(nil)) + }, + }, + "explains that an app was never requested": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableRequest(ctx, cm, cf) + cm.API.On("ListAppApprovalRequests", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(api.AppsApprovalsRequestsListResult{Requests: []api.AppsApprovalsRequest{}}, nil) + }, + Teardown: restoreRequest, + ExpectedOutputs: []string{"You have not requested to install this app"}, + }, + "searches the workspaces of the provided workspace IDs": { + CmdArgs: []string{"--workspace-ids", "T1234,T5678"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableRequest(ctx, cm, cf) + cm.API.On("ListAppApprovalRequests", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(api.AppsApprovalsRequestsListResult{}, nil) + }, + Teardown: restoreRequest, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "ListAppApprovalRequests", mock.Anything, "xoxp-example", "A1234", []string{"T1234", "T5678"}) + }, + }, + "returns the error of too many searched workspaces": { + CmdArgs: []string{"--workspace-ids", strings.Join(mockRequestWorkspaceIDs(51), ",")}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableRequest(ctx, cm, cf) + cm.API.On("ListAppApprovalRequests", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(api.AppsApprovalsRequestsListResult{}, slackerror.New(slackerror.ErrInvalidArguments)) + }, + Teardown: restoreRequest, + ExpectedError: slackerror.New(slackerror.ErrInvalidArguments), + }, + "suggests the app flag without a project directory": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableRequestWithoutProject(ctx, cm, cf) + }, + Teardown: restoreRequest, + ExpectedErrorStrings: []string{slackerror.ErrInvalidAppDirectory, "hooks.json", "--app A0123456789"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "ListAppApprovalRequests") + }, + }, + "checks an app named by ID outside of a project": { + CmdArgs: []string{"--app", "A5678"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableRequestWithoutProject(ctx, cm, cf) + requestTeamSelectPromptFunc = func(ctx context.Context, clients *shared.ClientFactory, promptText string, promptConfig *prompts.PromptTeamSlackAuthConfig) (*types.SlackAuth, error) { + return &types.SlackAuth{Token: "xoxp-selected", TeamID: "T5678", TeamDomain: "teamtwo"}, nil + } + cm.API.On("ListAppApprovalRequests", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(api.AppsApprovalsRequestsListResult{ + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar5678", TeamID: "T5678", Status: api.AppsApprovalsRequestStatusApproved, DateCreated: mockRequestCreated}, + {ID: "Ar9012", TeamID: "T9012", Status: api.AppsApprovalsRequestStatusPending, DateCreated: mockRequestCreated}, + }, + }, nil) + }, + Teardown: restoreRequest, + ExpectedOutputs: []string{ + "teamtwo (T5678):", + "Status: approved", + "T9012:", + "Status: pending", + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "ListAppApprovalRequests", mock.Anything, "xoxp-selected", "A5678", []string(nil)) + }, + }, + "returns the error of a failed team selection": { + CmdArgs: []string{"--app", "A5678"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableRequestWithoutProject(ctx, cm, cf) + requestTeamSelectPromptFunc = func(ctx context.Context, clients *shared.ClientFactory, promptText string, promptConfig *prompts.PromptTeamSlackAuthConfig) (*types.SlackAuth, error) { + return nil, slackerror.New(slackerror.ErrProcessInterrupted) + } + }, + Teardown: restoreRequest, + ExpectedError: slackerror.New(slackerror.ErrProcessInterrupted), + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "ListAppApprovalRequests") + }, + }, + "errors when the selected team has no token": { + CmdArgs: []string{"--app", "A5678"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableRequestWithoutProject(ctx, cm, cf) + requestTeamSelectPromptFunc = func(ctx context.Context, clients *shared.ClientFactory, promptText string, promptConfig *prompts.PromptTeamSlackAuthConfig) (*types.SlackAuth, error) { + return &types.SlackAuth{TeamID: "T5678"}, nil + } + }, + Teardown: restoreRequest, + ExpectedError: slackerror.New(slackerror.ErrCredentialsNotFound), + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "ListAppApprovalRequests") + }, + }, + "errors without a project when an app environment is used": { + CmdArgs: []string{"--app", "local"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableRequestWithoutProject(ctx, cm, cf) + }, + Teardown: restoreRequest, + ExpectedError: slackerror.New(slackerror.ErrInvalidAppDirectory), + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "ListAppApprovalRequests") + }, + }, + "returns the error of an interrupted app selection": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableRequest(ctx, cm, cf) + requestAppSelectPromptFunc = func(ctx context.Context, clients *shared.ClientFactory, environment prompts.AppEnvironmentType, status prompts.AppInstallStatus, opts ...prompts.AppSelectOption) (prompts.SelectedApp, error) { + return prompts.SelectedApp{}, slackerror.New(slackerror.ErrProcessInterrupted) + } + }, + Teardown: restoreRequest, + ExpectedError: slackerror.New(slackerror.ErrProcessInterrupted), + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "ListAppApprovalRequests") + }, + }, + "errors when the selected app is missing an ID": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableRequest(ctx, cm, cf) + requestAppSelectPromptFunc = func(ctx context.Context, clients *shared.ClientFactory, environment prompts.AppEnvironmentType, status prompts.AppInstallStatus, opts ...prompts.AppSelectOption) (prompts.SelectedApp, error) { + return prompts.SelectedApp{Auth: types.SlackAuth{Token: "xoxp-example"}}, nil + } + }, + Teardown: restoreRequest, + ExpectedError: slackerror.New(slackerror.ErrAppNotFound), + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "ListAppApprovalRequests") + }, + }, + "returns the error of a failed lookup": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + enableRequest(ctx, cm, cf) + cm.API.On("ListAppApprovalRequests", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(api.AppsApprovalsRequestsListResult{}, slackerror.New(slackerror.ErrAPIFeatureNotEnabled)) + }, + Teardown: restoreRequest, + ExpectedError: slackerror.New(slackerror.ErrAPIFeatureNotEnabled), + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewRequestCommand(cf) + }) +} + +func TestRequestFormat(t *testing.T) { + tests := map[string]struct { + TeamNames map[string]string + Requests []api.AppsApprovalsRequest + Expected []string + Unexpected []string + }{ + "no request was made for the app": { + Requests: []api.AppsApprovalsRequest{}, + Expected: []string{ + "App ID: A1234", + "You have not requested to install this app", + }, + }, + "the app is named before the requests of each team": { + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatusPending, DateCreated: mockRequestCreated}, + }, + Expected: []string{ + "App ID: A1234", + "T1234", + "Request ID: Ar1234", + }, + }, + "an open request omits the resolved timestamp": { + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatusPending, DateCreated: mockRequestCreated}, + }, + Expected: []string{ + "T1234", + "Request ID: Ar1234", + "Status: pending", + "Requested: 2026-08-21", + }, + Unexpected: []string{"Resolved:", "Cancelled by:", "without approval"}, + }, + "an approved request includes the resolved timestamp": { + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatusApproved, DateCreated: mockRequestCreated, DateResolved: mockRequestResolved}, + }, + Expected: []string{ + "Status: approved", + "Requested: 2026-08-21", + "Resolved: 2026-08-22", + }, + }, + "a request cancelled by an admin of the team": { + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatusCancelled, DateCreated: mockRequestCreated, DateResolved: mockRequestResolved, CancelledBy: api.AppsApprovalsRequestCancelledByAdmin}, + }, + Expected: []string{ + "Status: cancelled", + "Cancelled by: an admin", + }, + }, + "a request withdrawn by the authenticated account": { + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatusCancelled, DateCreated: mockRequestCreated, DateResolved: mockRequestResolved, CancelledBy: api.AppsApprovalsRequestCancelledByUser}, + }, + Expected: []string{"Cancelled by: you"}, + }, + "a request cancelled without an actor of its own": { + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatusCancelled, DateCreated: mockRequestCreated, DateResolved: mockRequestResolved, CancelledBy: api.AppsApprovalsRequestCancelledBySystem}, + }, + Expected: []string{"Cancelled by: the system"}, + }, + "a denied request that the account can approve itself": { + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatusDenied, CanSelfApprove: true, DateCreated: mockRequestCreated, DateResolved: mockRequestResolved}, + }, + Expected: []string{ + "Status: denied", + "You can install this app without approval. Please cancel the request.", + }, + }, + "a request without a timestamp reports an unknown moment": { + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatusPending}, + }, + Expected: []string{"Requested: unknown"}, + Unexpected: []string{"Resolved:"}, + }, + "an unrecognized status is reported without styles": { + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatus("escalated"), DateCreated: mockRequestCreated}, + }, + Expected: []string{"Status: escalated"}, + }, + "an unrecognized cancellation actor is reported as named": { + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatusCancelled, DateCreated: mockRequestCreated, CancelledBy: api.AppsApprovalsRequestCancelledBy("workflow")}, + }, + Expected: []string{"Cancelled by: workflow"}, + }, + "a searched team is titled by name when it is known": { + TeamNames: map[string]string{"T1234": "teamone"}, + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatusPending, DateCreated: mockRequestCreated}, + {ID: "Ar5678", TeamID: "T5678", Status: api.AppsApprovalsRequestStatusPending, DateCreated: mockRequestCreated}, + }, + Expected: []string{ + "teamone (T1234):", + "T5678:", + }, + }, + "requests are sorted by the team ID": { + Requests: []api.AppsApprovalsRequest{ + {ID: "Ar5678", TeamID: "T5678", Status: api.AppsApprovalsRequestStatusCancelled, DateCreated: mockRequestCreated}, + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatusApproved, DateCreated: mockRequestCreated}, + }, + Expected: []string{ + "T1234", + "Status: approved", + "T5678", + "Status: cancelled", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + formatted := strings.Join(FormatRequestSuccess("A1234", tc.TeamNames, tc.Requests), "\n") + previous := -1 + for _, value := range tc.Expected { + index := strings.Index(formatted, value) + assert.Greater(t, index, previous, "expected %q to follow the preceding values", value) + previous = index + } + for _, value := range tc.Unexpected { + assert.NotContains(t, formatted, value) + } + }) + } +} + +// TestRequestFormatOrdering checks that the requests of the caller are left in +// the order they were provided while output remains sorted by team +func TestRequestFormatOrdering(t *testing.T) { + requests := []api.AppsApprovalsRequest{ + {ID: "Ar5678", TeamID: "T5678", Status: api.AppsApprovalsRequestStatusPending, DateCreated: mockRequestCreated}, + {ID: "Ar1234", TeamID: "T1234", Status: api.AppsApprovalsRequestStatusPending, DateCreated: mockRequestCreated}, + } + + formatted := strings.Join(FormatRequestSuccess("A1234", nil, requests), "\n") + + assert.Less(t, strings.Index(formatted, "Ar1234"), strings.Index(formatted, "Ar5678")) + assert.Equal(t, "Ar5678", requests[0].ID, "expected the provided requests to remain unsorted") + assert.Equal(t, "Ar1234", requests[1].ID, "expected the provided requests to remain unsorted") +} + +// mockRequestWorkspaceIDs returns a count of unique workspace IDs +func mockRequestWorkspaceIDs(count int) []string { + workspaceIDs := []string{} + for i := range count { + workspaceIDs = append(workspaceIDs, fmt.Sprintf("T%09d", i)) + } + return workspaceIDs +} diff --git a/cmd/help/help.go b/cmd/help/help.go index 967c9e0a..4027d32e 100644 --- a/cmd/help/help.go +++ b/cmd/help/help.go @@ -96,7 +96,7 @@ const charmHelpTemplate string = `{{.Long | ToDescription}} {{if eq .Name (GetProcessName)}}{{Header "Commands"}}{{range .Commands}}{{if and (.HasAvailableSubCommands) (not .Hidden)}} {{.Name | ToGroupName }}{{range .Commands}}{{if (not .Hidden)}} {{rpad .Name .NamePadding | ToCommandText}} {{.Short | ToDescription}}{{end}}{{end}}{{end}}{{end}}{{if and (.HasAvailableSubCommands) (not .Hidden)}}{{range .Commands}}{{if and (not .HasAvailableSubCommands) (not .Hidden)}}{{if not (IsAlias .Name $.Data.Aliases)}} - {{(rpad .Name .NamePadding) | ToGroupName }}{{.Short | ToDescription}}{{end}}{{end}}{{end}}{{end}}{{else}}{{Header "Subcommands"}}{{if and (.HasAvailableSubCommands) (not .Hidden)}}{{range .Commands}}{{if not .HasAvailableSubCommands}} + {{(rpad .Name .NamePadding) | ToGroupName }}{{.Short | ToDescription}}{{end}}{{end}}{{end}}{{end}}{{else}}{{Header "Subcommands"}}{{if and (.HasAvailableSubCommands) (not .Hidden)}}{{range .Commands}}{{if and (not .HasAvailableSubCommands) (not .Hidden)}} {{(rpad .Name .NamePadding) | ToCommandText }} {{.Short | ToDescription}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} {{Header "Flags"}} @@ -139,7 +139,7 @@ const legacyHelpTemplate string = `{{.Long}} {{if eq .Name (GetProcessName)}}{{Header "Commands"}}{{range .Commands}}{{if and (.HasAvailableSubCommands) (not .Hidden)}} {{.Name | ToCommandText }}{{range .Commands}}{{if (not .Hidden)}} {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{if and (.HasAvailableSubCommands) (not .Hidden)}}{{range .Commands}}{{if and (not .HasAvailableSubCommands) (not .Hidden)}}{{if not (IsAlias .Name $.Data.Aliases)}} - {{(rpad .Name .NamePadding) | ToCommandText }}{{.Short}}{{end}}{{end}}{{end}}{{end}}{{else}}{{Header "Subcommands"}}{{if and (.HasAvailableSubCommands) (not .Hidden)}}{{range .Commands}}{{if not .HasAvailableSubCommands}} + {{(rpad .Name .NamePadding) | ToCommandText }}{{.Short}}{{end}}{{end}}{{end}}{{end}}{{else}}{{Header "Subcommands"}}{{if and (.HasAvailableSubCommands) (not .Hidden)}}{{range .Commands}}{{if and (not .HasAvailableSubCommands) (not .Hidden)}} {{(rpad .Name .NamePadding) | ToCommandText }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} {{Header "Flags"}} diff --git a/docs/reference/experiments.md b/docs/reference/experiments.md index a5989207..edb38465 100644 --- a/docs/reference/experiments.md +++ b/docs/reference/experiments.md @@ -6,6 +6,7 @@ The Slack CLI has an experiment (`-e`) flag behind which we put features current The following is a list of currently available experiments. We'll remove experiments from this page if we decide they are no longer needed or once they are released, in which case we'll make an announcement about the feature's general availability in the [developer changelog](https://docs.slack.dev/changelog). +- `app-approval-status`: checks requests to install an app. - `lipgloss`: shows pretty styles. - `manifest-sync`: resolves conflicting app manifest values. @@ -13,6 +14,7 @@ The following is a list of currently available experiments. We'll remove experim Below is a list of updates related to experiments. +- **August 2026**: Added the `app-approval-status` experiment to check the status of requests to have an app approved for install. - **August 2026**: Concluded the `set-icon` experiment; The Slack CLI now offers full support for icon upload on all app types by default. - **July 2026**: Added the `manifest-sync` experiment to resolve changed app manifest values between a project and app settings. - **April 2026**: Concluded the `sandboxes` experiment with full support in the Slack CLI. Refer to the [`slack sandbox create`](/tools/slack-cli/reference/commands/slack_sandbox_create/), [`slack sandbox delete`](/tools/slack-cli/reference/commands/slack_sandbox_delete/), and [`slack sandbox list`](/tools/slack-cli/reference/commands/slack_sandbox_list/) commands for more details. diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index 3feb315f..7dbb330b 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -315,6 +315,11 @@ func (m *APIMock) CertifiedAppInstall(ctx context.Context, token string, certifi return args.Get(0).(CertifiedInstallResult), args.Error(1) } +func (m *APIMock) ListAppApprovalRequests(ctx context.Context, token string, appID string, requestedTeams []string) (AppsApprovalsRequestsListResult, error) { + args := m.Called(ctx, token, appID, requestedTeams) + return args.Get(0).(AppsApprovalsRequestsListResult), args.Error(1) +} + func (m *APIMock) RequestAppApproval(ctx context.Context, token string, appID string, teamID string, reason string, scopes string, outgoingDomains []string) (AppsApprovalsRequestsCreateResult, error) { args := m.Called(ctx, token, appID, teamID, reason, scopes, outgoingDomains) return args.Get(0).(AppsApprovalsRequestsCreateResult), args.Error(1) diff --git a/internal/api/app.go b/internal/api/app.go index a871dcd5..64885afc 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -45,6 +45,7 @@ const ( appStatusMethod = "apps.status" appApprovalRequestCreateMethod = "apps.approvals.requests.create" appApprovalRequestCancelMethod = "apps.approvals.requests.cancel" + appApprovalRequestListMethod = "apps.approvals.requests.list" ) // AppsClient is the interface for app-related API calls @@ -60,6 +61,7 @@ type AppsClient interface { Host() string Icon(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) IconSet(ctx context.Context, fs afero.Fs, token, appID, iconFilePath string) (IconResult, error) + ListAppApprovalRequests(ctx context.Context, token string, appID string, requestedTeams []string) (AppsApprovalsRequestsListResult, error) RequestAppApproval(ctx context.Context, token string, appID string, teamID string, reason string, scopes string, outgoingDomains []string) (AppsApprovalsRequestsCreateResult, error) SetHost(host string) UninstallApp(ctx context.Context, token string, appID, teamID string) error @@ -587,6 +589,109 @@ type appsApprovalsRequestsCancelResponse struct { extendedBaseResponse } +// AppsApprovalsRequestStatus is where an app approval request stands. A pending +// request has not been resolved yet, while approved, denied, and cancelled +// requests are settled. The status is a property of the request alone, so it +// reads the same for every caller. +type AppsApprovalsRequestStatus string + +const ( + AppsApprovalsRequestStatusApproved AppsApprovalsRequestStatus = "approved" + AppsApprovalsRequestStatusCancelled AppsApprovalsRequestStatus = "cancelled" + AppsApprovalsRequestStatusDenied AppsApprovalsRequestStatus = "denied" + AppsApprovalsRequestStatusPending AppsApprovalsRequestStatus = "pending" +) + +// AppsApprovalsRequestCancelledBy is the kind of actor that cancelled an app +// approval request. A user is the requester withdrawing their own request, an +// admin is someone who resolves approvals on the team, and system is Slack +// cancelling the request with no actor of its own, which is what happens when +// the app named by the request is deleted. +type AppsApprovalsRequestCancelledBy string + +const ( + AppsApprovalsRequestCancelledByAdmin AppsApprovalsRequestCancelledBy = "admin" + AppsApprovalsRequestCancelledBySystem AppsApprovalsRequestCancelledBy = "system" + AppsApprovalsRequestCancelledByUser AppsApprovalsRequestCancelledBy = "user" +) + +// AppsApprovalsRequest is a single request by a user to have an app approved +// for install +type AppsApprovalsRequest struct { + // ID is the encoded ID of the request + ID string `json:"id"` + // TeamID is the team that resolves the request, which is an organization + // for requests routed to an organization and a workspace otherwise + TeamID string `json:"team_id"` + // Status is where the request stands + Status AppsApprovalsRequestStatus `json:"status"` + // CanSelfApprove is true if the account can install the app on the team + // without approval from anyone else + CanSelfApprove bool `json:"can_self_approve"` + // DateCreated is the Unix timestamp of when the request was created + DateCreated int64 `json:"date_created"` + // DateResolved is the Unix timestamp of when the request was resolved and + // is absent while the request remains open + DateResolved int64 `json:"date_resolved,omitempty"` + // CancelledBy is the kind of actor that cancelled the request and is absent + // unless the request was cancelled + CancelledBy AppsApprovalsRequestCancelledBy `json:"cancelled_by,omitempty"` +} + +type AppsApprovalsRequestsListResult struct { + // Requests contains the most recent request that the authenticated account + // made for an app on each of the searched teams + Requests []AppsApprovalsRequest `json:"requests"` +} + +type appsApprovalsRequestsListResponse struct { + extendedBaseResponse + AppsApprovalsRequestsListResult +} + +// ListAppApprovalRequests fetches the most recent request that the authenticated +// account made to install an app on each of the searched teams. +// +// The teams searched are the team of the token, the organization of that team +// if the token is scoped to a workspace of one, and any workspace of that +// organization named in requestedTeams. +func (c *Client) ListAppApprovalRequests(ctx context.Context, token string, appID string, requestedTeams []string) (AppsApprovalsRequestsListResult, error) { + var span opentracing.Span + span, ctx = opentracing.StartSpanFromContext(ctx, "apiclient.ListAppApprovalRequests") + defer span.Finish() + + args := struct { + AppID string `json:"app_id"` + RequestedTeams []string `json:"requested_teams,omitempty"` + }{ + appID, + requestedTeams, + } + + body, err := json.Marshal(args) + if err != nil { + return AppsApprovalsRequestsListResult{}, errInvalidArguments.WithRootCause(err) + } + + b, err := c.postJSON(ctx, appApprovalRequestListMethod, token, "", body) + if err != nil { + return AppsApprovalsRequestsListResult{}, errHTTPRequestFailed.WithRootCause(err) + } + + resp := appsApprovalsRequestsListResponse{} + err = goutils.JSONUnmarshal(b, &resp) + + if err != nil { + return AppsApprovalsRequestsListResult{}, errHTTPResponseInvalid.WithRootCause(err).AddAPIMethod(appApprovalRequestListMethod) + } + + if !resp.Ok { + return AppsApprovalsRequestsListResult{}, slackerror.NewAPIError(resp.Error, resp.Description, resp.Errors, appApprovalRequestListMethod) + } + + return resp.AppsApprovalsRequestsListResult, nil +} + // GenerateS3PresignedPost details to be saved type AppsConnectionsOpenResult struct { URL string `json:"url"` diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 873f7b49..5414ce36 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -154,6 +154,131 @@ func Test_Client_GetAppStatus(t *testing.T) { } } +func Test_Client_ListAppApprovalRequests(t *testing.T) { + tests := map[string]struct { + appID string + requestedTeams []string + expectedRequest string + httpResponseJSON string + expectedRequests []AppsApprovalsRequest + expectedErrors []string + }{ + "omits the requested teams when none are named": { + appID: "A1234", + expectedRequest: `{"app_id":"A1234"}`, + httpResponseJSON: `{"ok":true,"requests":[]}`, + expectedRequests: []AppsApprovalsRequest{}, + }, + "includes the requested teams of an organization": { + appID: "A1234", + requestedTeams: []string{"T1234", "T5678"}, + expectedRequest: `{"app_id":"A1234","requested_teams":["T1234","T5678"]}`, + httpResponseJSON: `{"ok":true,"requests":[]}`, + expectedRequests: []AppsApprovalsRequest{}, + }, + "collects an open request that awaits review": { + appID: "A1234", + expectedRequest: `{"app_id":"A1234"}`, + httpResponseJSON: `{"ok":true,"requests":[{"id":"Ar1234","team_id":"T1234","status":"pending","can_self_approve":false,"date_created":1787000000}]}`, + expectedRequests: []AppsApprovalsRequest{ + { + ID: "Ar1234", + TeamID: "T1234", + Status: AppsApprovalsRequestStatusPending, + DateCreated: 1787000000, + }, + }, + }, + "collects a settled request of an organization and a workspace": { + appID: "A1234", + expectedRequest: `{"app_id":"A1234"}`, + httpResponseJSON: `{"ok":true,"requests":[{"id":"Ar1234","team_id":"E1234","status":"cancelled","can_self_approve":true,"date_created":1787000000,"date_resolved":1787060000,"cancelled_by":"admin"},{"id":"Ar5678","team_id":"T5678","status":"denied","can_self_approve":false,"date_created":1787000000,"date_resolved":1787060000}]}`, + expectedRequests: []AppsApprovalsRequest{ + { + ID: "Ar1234", + TeamID: "E1234", + Status: AppsApprovalsRequestStatusCancelled, + CanSelfApprove: true, + DateCreated: 1787000000, + DateResolved: 1787060000, + CancelledBy: AppsApprovalsRequestCancelledByAdmin, + }, + { + ID: "Ar5678", + TeamID: "T5678", + Status: AppsApprovalsRequestStatusDenied, + DateCreated: 1787000000, + DateResolved: 1787060000, + }, + }, + }, + "errors when the app is not found": { + appID: "A0000", + expectedRequest: `{"app_id":"A0000"}`, + httpResponseJSON: `{"ok":false,"error":"app_not_found"}`, + expectedErrors: []string{slackerror.ErrAppNotFound, "The app was not found"}, + }, + "errors when a team is outside of the organization": { + appID: "A1234", + requestedTeams: []string{"T0000"}, + expectedRequest: `{"app_id":"A1234","requested_teams":["T0000"]}`, + httpResponseJSON: `{"ok":false,"error":"restricted_action"}`, + expectedErrors: []string{ + slackerror.ErrAPIRestrictedAction, + "The requested action is not allowed for a specified team", + "Check that each team belongs to the organization", + }, + }, + "errors when the team cannot check requests": { + appID: "A1234", + expectedRequest: `{"app_id":"A1234"}`, + httpResponseJSON: `{"ok":false,"error":"feature_not_enabled"}`, + expectedErrors: []string{ + slackerror.ErrAPIFeatureNotEnabled, + "This feature is not enabled for the team", + "Reach out to an admin for additional information", + }, + }, + "errors when more than fifty workspaces are searched": { + appID: "A1234", + requestedTeams: []string{"T0000"}, + expectedRequest: `{"app_id":"A1234","requested_teams":["T0000"]}`, + httpResponseJSON: `{"ok":false,"error":"invalid_arguments"}`, + expectedErrors: []string{slackerror.ErrInvalidArguments}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: appApprovalRequestListMethod, + ExpectedRequest: tc.expectedRequest, + Response: tc.httpResponseJSON, + }) + defer teardown() + + result, err := c.ListAppApprovalRequests(ctx, "token", tc.appID, tc.requestedTeams) + if len(tc.expectedErrors) > 0 { + require.Error(t, err) + for _, expected := range tc.expectedErrors { + require.Contains(t, err.Error(), expected) + } + return + } + require.NoError(t, err) + require.Equal(t, tc.expectedRequests, result.Requests) + }) + } +} + +func Test_Client_ListAppApprovalRequests_CommonErrors(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + verifyCommonErrorCases(t, appApprovalRequestListMethod, func(c *Client) error { + _, err := c.ListAppApprovalRequests(ctx, "token", "A1234", nil) + return err + }) +} + func TestClient_UpdateApp_OK(t *testing.T) { ctx := slackcontext.MockContext(t.Context()) c, teardown := NewFakeClient(t, FakeClientParams{ diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index c34894c1..d4ad75b6 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -30,6 +30,10 @@ type Experiment string // e.g. --experiment=first-toggle,second-toggle const ( + + // AppApprovalStatus experiment shows the requested install approval status of the app. + AppApprovalStatus Experiment = "app-approval-status" + // Lipgloss experiment shows pretty styles. Lipgloss Experiment = "lipgloss" @@ -43,6 +47,7 @@ const ( // AllExperiments is a list of all available experiments that can be enabled // Please also add here 👇 var AllExperiments = []Experiment{ + AppApprovalStatus, Lipgloss, ManifestSync, Placeholder, diff --git a/internal/prompts/team_select.go b/internal/prompts/team_select.go index 3542a655..d27f008a 100644 --- a/internal/prompts/team_select.go +++ b/internal/prompts/team_select.go @@ -40,6 +40,7 @@ func PromptTeamSlackAuth(ctx context.Context, clients *shared.ClientFactory, pro } if len(allAuths) == 1 { + clients.Auth().SetSelectedAuth(ctx, allAuths[0], clients.Config, clients.Os) return &allAuths[0], nil } diff --git a/internal/prompts/team_select_test.go b/internal/prompts/team_select_test.go new file mode 100644 index 00000000..9269daba --- /dev/null +++ b/internal/prompts/team_select_test.go @@ -0,0 +1,56 @@ +// 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 prompts + +import ( + "testing" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackcontext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestPromptTeamSlackAuth(t *testing.T) { + tests := map[string]struct { + auths []types.SlackAuth + expectedAuth types.SlackAuth + }{ + "selects the only authenticated account without a prompt": { + auths: []types.SlackAuth{ + {Token: team1Token, TeamID: team1TeamID, TeamDomain: team1TeamDomain}, + }, + expectedAuth: types.SlackAuth{Token: team1Token, TeamID: team1TeamID, TeamDomain: team1TeamDomain}, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + clientsMock := shared.NewClientsMock() + clientsMock.Auth.On(Auths, mock.Anything).Return(tc.auths, nil) + clientsMock.AddDefaultMocks() + clients := shared.NewClientFactory(clientsMock.MockClientFactory()) + + auth, err := PromptTeamSlackAuth(ctx, clients, "Select a team", nil) + + require.NoError(t, err) + assert.Equal(t, tc.expectedAuth, *auth) + clientsMock.Auth.AssertCalled(t, "SetSelectedAuth", mock.Anything, tc.expectedAuth, clients.Config, clients.Os) + clientsMock.IO.AssertNotCalled(t, SelectPrompt) + }) + } +} diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index e1b9404b..98e4ab1b 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -27,6 +27,8 @@ const ( ErrAddAppToProject = "add_app_to_project_error" ErrAlreadyLoggedOut = "already_logged_out" ErrAlreadyResolved = "already_resolved" + ErrAPIFeatureNotEnabled = "feature_not_enabled" + ErrAPIRestrictedAction = "restricted_action" ErrAppAdd = "app_add_error" ErrAppApprovalRequestDenied = "app_approval_request_denied" ErrAppApprovalRequestEligible = "app_approval_request_eligible" @@ -308,6 +310,18 @@ var ErrorCodeMap = map[string]Error{ Message: "The app already has a resolution and cannot be requested", }, + ErrAPIFeatureNotEnabled: { + Code: ErrAPIFeatureNotEnabled, + Message: "This feature is not enabled for the team", + Remediation: "Reach out to an admin for additional information", + }, + + ErrAPIRestrictedAction: { + Code: ErrAPIRestrictedAction, + Message: "The requested action is not allowed for a specified team", + Remediation: "Check that each team belongs to the organization of the authenticated account", + }, + ErrAppAdd: { Code: ErrAppAdd, Message: "Couldn't create a new app",