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..54bc5b8e --- /dev/null +++ b/cmd/app/request.go @@ -0,0 +1,250 @@ +// 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/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"), + 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 { + 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..4b9cdcf0 --- /dev/null +++ b/cmd/app/request_test.go @@ -0,0 +1,393 @@ +// 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/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 prepares a project with an app to select + enableRequest := func(ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + cf.SDKConfig = hooks.NewSDKConfigMock() + 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 prepares the command outside of a project + enableRequestWithoutProject := func(ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + } + + testutil.TableTestCommand(t, testutil.CommandTests{ + "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/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/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",