diff --git a/cmd/app/add.go b/cmd/app/add.go index 3ed3857d..fa55b519 100644 --- a/cmd/app/add.go +++ b/cmd/app/add.go @@ -42,6 +42,7 @@ var appSelectPromptFunc = prompts.AppSelectPrompt type addCmdFlags struct { orgGrantWorkspaceID string + environmentFlag string } var addFlags addCmdFlags @@ -55,11 +56,12 @@ func NewAddCommand(clients *shared.ClientFactory) *cobra.Command { Long: "Install the app to a team", Example: style.ExampleCommandsf([]style.ExampleCommand{ {Command: "app install", Meaning: "Install a production app to a team"}, - {Command: "app install --team T0123456", Meaning: "Install a production app to a specific team"}, + {Command: "app install --team T0123456 --environment deployed", Meaning: "Install a production app to a specific team"}, + {Command: "app install --team T0123456 --environment local", Meaning: "Install a local dev app to a specific team"}, }), PreRunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - return preRunAddCommand(ctx, clients) + return preRunAddCommand(ctx, clients, cmd) }, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() @@ -72,12 +74,13 @@ func NewAddCommand(clients *shared.ClientFactory) *cobra.Command { } cmd.Flags().StringVar(&addFlags.orgGrantWorkspaceID, cmdutil.OrgGrantWorkspaceFlag, "", cmdutil.OrgGrantWorkspaceDescription()) + cmd.Flags().StringVarP(&addFlags.environmentFlag, "environment", "E", "", "environment of app (local, deployed)") return cmd } // preRunAddCommand confirms an app is available for installation -func preRunAddCommand(ctx context.Context, clients *shared.ClientFactory) error { +func preRunAddCommand(ctx context.Context, clients *shared.ClientFactory, cmd *cobra.Command) error { err := cmdutil.IsValidProjectDirectory(clients) if err != nil { return err @@ -85,38 +88,92 @@ func preRunAddCommand(ctx context.Context, clients *shared.ClientFactory) error if !clients.Config.WithExperimentOn(experiment.BoltFrameworks) { return nil } - manifestSource, err := clients.Config.ProjectConfig.GetManifestSource(ctx) - if err != nil { - return err - } - if manifestSource.Equals(config.ManifestSourceRemote) { - return slackerror.New(slackerror.ErrAppInstall). - WithMessage("Apps cannot be installed due to project configurations"). - WithRemediation( - "Install an app on app settings: %s\nLink an app to this project with %s\nList apps saved with this project using %s", - style.LinkText("https://api.slack.com/apps"), - style.Commandf("app link", false), - style.Commandf("app list", false), - ). - WithDetails(slackerror.ErrorDetails{ - slackerror.ErrorDetail{ - Code: slackerror.ErrProjectConfigManifestSource, - Message: "Cannot install apps with manifests sourced from app settings", - }, - }) - } + clients.Config.SetFlags(cmd) return nil } // RunAddCommand executes the workspace install command, prints output, and returns any errors. func RunAddCommand(ctx context.Context, clients *shared.ClientFactory, selection *prompts.SelectedApp, orgGrantWorkspaceID string) (context.Context, types.InstallState, types.App, error) { if selection == nil { - selected, err := appSelectPromptFunc(ctx, clients, prompts.ShowHostedOnly, prompts.ShowAllApps) - if err != nil { - return ctx, "", types.App{}, err + // TODO: Move to the promptIsProduction when the prompt is refactored and tested. + // Validate that the --app flag is not an app ID when the --environment flag is set. + if types.IsAppID(clients.Config.AppFlag) && addFlags.environmentFlag != "" { + return ctx, "", types.App{}, slackerror.New(slackerror.ErrMismatchedFlags).WithRemediation("When '--app ' is set, please do not set the flag --environment.") + } + + // TODO: Move to the promptIsProduction when the prompt is refactored and tested. + // Validate that the --environment flag matches the --app flag, when the value is `--app local` or `--app deployed`. + if types.IsAppFlagEnvironment(clients.Config.AppFlag) { + if addFlags.environmentFlag != "" && addFlags.environmentFlag != clients.Config.AppFlag { + return ctx, "", types.App{}, slackerror.New(slackerror.ErrMismatchedFlags).WithRemediation("When '--app local' or '--app deployed' is set, please set the flag --environment to match the --app flag.") + } + + if addFlags.environmentFlag == "" { + err := clients.Config.Flags.Lookup("environment").Value.Set(clients.Config.AppFlag) + if err != nil { + return ctx, "", types.App{}, err + } + clients.Config.Flags.Lookup("environment").Changed = true + } + } + + // Default to `--environment deployed` when there is no `--environment` flag and `--team ` is set. + // Skip when `--app ` flag is set, because the environment is looked up in the app selector prompt. + // TODO(semver:major): This is backwards compatibility for when `install` only supported deployed environments. + if !types.IsAppID(clients.Config.AppFlag) && (addFlags.environmentFlag == "" && clients.Config.TeamFlag != "") { + err := clients.Config.Flags.Lookup("environment").Value.Set("deployed") + if err != nil { + return ctx, "", types.App{}, err + } + clients.Config.Flags.Lookup("environment").Changed = true + + clients.IO.PrintInfo(ctx, false, "\n"+style.Sectionf(style.TextSection{ + Emoji: "warning", + Text: "Warning: Default App Environment", + Secondary: []string{ + "App environment is set to deployed when only the --team flag is provided.", + "The next major version will change this behavior.", + "When the --team flag is provided, the --environment flag will be required.", + "Add the '--environment deployed' to avoid breaking changes.", + }, + })) + } + + // When the app flag is an app ID, the app select prompt can resolve the app. + // Otherwise, prompt for the app environment and app. + if types.IsAppID(clients.Config.AppFlag) { + selected, err := appSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowAllApps) + if err != nil { + return ctx, "", types.App{}, err + } + selection = &selected + } else { + // Prompt for deployed or local app environment. + isProductionApp, err := promptIsProduction(ctx, clients) + if err != nil { + return ctx, "", types.App{}, err + } + + // Set the app environment type based on the prompt. + var appEnvironmentType prompts.AppEnvironmentType + if isProductionApp { + appEnvironmentType = prompts.ShowHostedOnly + } else { + appEnvironmentType = prompts.ShowLocalOnly + } + + selected, err := appSelectPromptFunc(ctx, clients, appEnvironmentType, prompts.ShowAllApps) + if err != nil { + return ctx, "", types.App{}, err + } + selection = &selected + + if !isProductionApp { + selection.App.IsDev = true + } } - selection = &selected } + if selection.Auth.TeamDomain == "" { return ctx, "", types.App{}, slackerror.New(slackerror.ErrCredentialsNotFound) } diff --git a/cmd/app/add_test.go b/cmd/app/add_test.go index ab9fa903..abfd52aa 100644 --- a/cmd/app/add_test.go +++ b/cmd/app/add_test.go @@ -24,11 +24,11 @@ import ( "github.com/slackapi/slack-cli/internal/cmdutil" "github.com/slackapi/slack-cli/internal/config" "github.com/slackapi/slack-cli/internal/experiment" + "github.com/slackapi/slack-cli/internal/iostreams" "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/slackapi/slack-cli/test/testutil" "github.com/spf13/cobra" "github.com/stretchr/testify/mock" @@ -85,32 +85,19 @@ func TestAppAddCommandPreRun(t *testing.T) { cf.SDKConfig.WorkingDirectory = "." }, }, - "errors if manifest.source is remote with the bolt experiment": { - ExpectedError: slackerror.New(slackerror.ErrAppInstall). - WithMessage("Apps cannot be installed due to project configurations"). - WithRemediation( - "Install an app on app settings: %s\nLink an app to this project with %s\nList apps saved with this project using %s", - style.LinkText("https://api.slack.com/apps"), - style.Commandf("app link", false), - style.Commandf("app list", false), - ). - WithDetails(slackerror.ErrorDetails{ - slackerror.ErrorDetail{ - Code: slackerror.ErrProjectConfigManifestSource, - Message: "Cannot install apps with manifests sourced from app settings", - }, - }), + "proceeds if manifest.source is local with the bolt experiment": { + ExpectedError: nil, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cf.SDKConfig.WorkingDirectory = "." cm.AddDefaultMocks() cm.Config.ExperimentsFlag = append(cm.Config.ExperimentsFlag, string(experiment.BoltFrameworks)) cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) mockProjectConfig := config.NewProjectConfigMock() - mockProjectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceRemote, nil) + mockProjectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil) cm.Config.ProjectConfig = mockProjectConfig }, }, - "proceeds if manifest.source is local with the bolt experiment": { + "proceeds if manifest.source is remote with the bolt experiment": { ExpectedError: nil, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { cf.SDKConfig.WorkingDirectory = "." @@ -118,7 +105,7 @@ func TestAppAddCommandPreRun(t *testing.T) { cm.Config.ExperimentsFlag = append(cm.Config.ExperimentsFlag, string(experiment.BoltFrameworks)) cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) mockProjectConfig := config.NewProjectConfigMock() - mockProjectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceLocal, nil) + mockProjectConfig.On("GetManifestSource", mock.Anything).Return(config.ManifestSourceRemote, nil) cm.Config.ProjectConfig = mockProjectConfig }, }, @@ -130,13 +117,105 @@ func TestAppAddCommandPreRun(t *testing.T) { } func TestAppAddCommand(t *testing.T) { - testutil.TableTestCommand(t, testutil.CommandTests{ + "errors when --app local and --environment deployed": { + CmdArgs: []string{"--app", "local", "--environment", "deployed"}, + ExpectedError: slackerror.New(slackerror.ErrMismatchedFlags), + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + prepareAddMocks(t, cf, cm, "") // Do not set the environment flag + }, + }, + "errors when --app deployed and --environment local": { + CmdArgs: []string{"--app", "deployed", "--environment", "local"}, + ExpectedError: slackerror.New(slackerror.ErrMismatchedFlags), + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + prepareAddMocks(t, cf, cm, "") // Do not set the environment flag + }, + }, + "errors when --app A0123 and --environment local": { + CmdArgs: []string{"--app", "A0123", "--environment", "local"}, + ExpectedError: slackerror.New(slackerror.ErrMismatchedFlags), + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + prepareAddMocks(t, cf, cm, "") // Do not set the environment flag + }, + }, + "adds a new local app": { + CmdArgs: []string{}, + ExpectedOutputs: []string{"Creating app manifest", "Installing"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + prepareAddMocks(t, cf, cm, "local") + + // Mock TeamSelector prompt to return "team1" + appSelectMock := prompts.NewAppSelectMock() + appSelectPromptFunc = appSelectMock.AppSelectPrompt + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(prompts.SelectedApp{Auth: mockAuthTeam1}, nil) + + // Mock valid session for team1 + cm.API.On("ValidateSession", mock.Anything, mock.Anything).Return(api.AuthSession{ + UserID: &mockAuthTeam1.UserID, + TeamID: &mockAuthTeam1.TeamID, + TeamName: &mockAuthTeam1.TeamDomain, + }, nil) + + // Mock a clean ValidateAppManifest result + cm.API.On("ValidateAppManifest", mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything).Return( + api.ValidateAppManifestResult{ + Warnings: slackerror.Warnings{}, + }, nil, + ) + + // Mock Host + cm.API.On("Host").Return("") + + // Mock a successful CreateApp call and return our mocked AppID + cm.API.On("CreateApp", mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything).Return( + api.CreateAppResult{ + AppID: mockAppTeam1.AppID, + }, + nil, + ) + + // Mock a successful DeveloperAppInstall + cm.API.On("DeveloperAppInstall", mock.Anything, mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + api.DeveloperAppInstallResult{ + AppID: mockAppTeam1.AppID, + APIAccessTokens: struct { + Bot string "json:\"bot,omitempty\"" + AppLevel string "json:\"app_level,omitempty\"" + User string "json:\"user,omitempty\"" + }{}, + }, + types.InstallSuccess, + nil, + ) + + // Mock existing and updated cache + cm.API.On( + "ExportAppManifest", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return( + api.ExportAppResult{}, + nil, + ) + mockProjectCache := cache.NewCacheMock() + mockProjectCache.On("GetManifestHash", mock.Anything, mock.Anything). + Return(cache.Hash(""), nil) + mockProjectCache.On("NewManifestHash", mock.Anything, mock.Anything). + Return(cache.Hash("xoxo"), nil) + mockProjectCache.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything). + Return(nil) + mockProjectConfig := config.NewProjectConfigMock() + mockProjectConfig.On("Cache").Return(mockProjectCache) + cm.Config.ProjectConfig = mockProjectConfig + }, + }, "adds a new deployed app": { CmdArgs: []string{}, ExpectedOutputs: []string{"Creating app manifest", "Installing"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - prepareAddMocks(t, cf, cm) + prepareAddMocks(t, cf, cm, "deployed") // Mock TeamSelector prompt to return "team1" appSelectMock := prompts.NewAppSelectMock() @@ -208,7 +287,7 @@ func TestAppAddCommand(t *testing.T) { CmdArgs: []string{}, ExpectedOutputs: []string{"Updated app manifest"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - prepareAddMocks(t, cf, cm) + prepareAddMocks(t, cf, cm, "deployed") // Mock TeamSelector prompt to return "team1" appSelectMock := prompts.NewAppSelectMock() @@ -290,7 +369,7 @@ func TestAppAddCommand(t *testing.T) { CmdArgs: []string{}, ExpectedError: slackerror.New(slackerror.ErrCredentialsNotFound), Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - prepareAddMocks(t, cf, cm) + prepareAddMocks(t, cf, cm, "deployed") appSelectMock := prompts.NewAppSelectMock() appSelectPromptFunc = appSelectMock.AppSelectPrompt appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowHostedOnly, prompts.ShowAllApps).Return(prompts.SelectedApp{App: mockAppTeam1}, nil) @@ -300,7 +379,7 @@ func TestAppAddCommand(t *testing.T) { CmdArgs: []string{"--" + cmdutil.OrgGrantWorkspaceFlag, "T123"}, ExpectedOutputs: []string{"Creating app manifest", "Installing"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - prepareAddMocks(t, cf, cm) + prepareAddMocks(t, cf, cm, "deployed") // Select workspace appSelectMock := prompts.NewAppSelectMock() appSelectPromptFunc = appSelectMock.AppSelectPrompt @@ -356,11 +435,340 @@ func TestAppAddCommand(t *testing.T) { cm.API.AssertCalled(t, "DeveloperAppInstall", mock.Anything, mock.Anything, mockOrgAuth.Token, mock.Anything, mock.Anything, mock.Anything, "T123", mock.Anything) }, }, + "adds a new local app when --environment local": { + CmdArgs: []string{"--team", "T123", "--environment", "local"}, + ExpectedOutputs: []string{"Creating app manifest", "Installing"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + prepareAddMocks(t, cf, cm, "") // Do not set the environment flag + + // Mock SelectPrompt to receive "--environment local" + cm.IO.On("SelectPrompt", + mock.Anything, + "Choose the app environment", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(iostreams.SelectPromptResponse{ + Flag: true, + Option: "local", + }, nil) + + // Mock TeamSelector prompt to return "team1" + appSelectMock := prompts.NewAppSelectMock() + appSelectPromptFunc = appSelectMock.AppSelectPrompt + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(prompts.SelectedApp{Auth: mockAuthTeam1}, nil) + + // Mock valid session for team1 + cm.API.On("ValidateSession", mock.Anything, mock.Anything).Return(api.AuthSession{ + UserID: &mockAuthTeam1.UserID, + TeamID: &mockAuthTeam1.TeamID, + TeamName: &mockAuthTeam1.TeamDomain, + }, nil) + + // Mock a clean ValidateAppManifest result + cm.API.On("ValidateAppManifest", mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything).Return( + api.ValidateAppManifestResult{ + Warnings: slackerror.Warnings{}, + }, nil, + ) + + // Mock Host + cm.API.On("Host").Return("") + + // Mock a successful CreateApp call and return our mocked AppID + cm.API.On("CreateApp", mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything).Return( + api.CreateAppResult{ + AppID: mockAppTeam1.AppID, + }, + nil, + ) + + // Mock a successful DeveloperAppInstall + cm.API.On("DeveloperAppInstall", mock.Anything, mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + api.DeveloperAppInstallResult{ + AppID: mockAppTeam1.AppID, + APIAccessTokens: struct { + Bot string "json:\"bot,omitempty\"" + AppLevel string "json:\"app_level,omitempty\"" + User string "json:\"user,omitempty\"" + }{}, + }, + types.InstallSuccess, + nil, + ) + + // Mock existing and updated cache + cm.API.On( + "ExportAppManifest", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return( + api.ExportAppResult{}, + nil, + ) + mockProjectCache := cache.NewCacheMock() + mockProjectCache.On("GetManifestHash", mock.Anything, mock.Anything). + Return(cache.Hash(""), nil) + mockProjectCache.On("NewManifestHash", mock.Anything, mock.Anything). + Return(cache.Hash("xoxo"), nil) + mockProjectCache.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything). + Return(nil) + mockProjectConfig := config.NewProjectConfigMock() + mockProjectConfig.On("Cache").Return(mockProjectCache) + cm.Config.ProjectConfig = mockProjectConfig + }, + }, + "adds a new local app when --app local": { + CmdArgs: []string{"--team", "T123", "--app", "local"}, + ExpectedOutputs: []string{"Creating app manifest", "Installing"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + prepareAddMocks(t, cf, cm, "") // Do not set the environment flag + + // Mock SelectPrompt to receive "--environment local" + cm.IO.On("SelectPrompt", + mock.Anything, + "Choose the app environment", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(iostreams.SelectPromptResponse{ + Flag: true, + Option: "local", + }, nil) + + // Mock TeamSelector prompt to return "team1" + appSelectMock := prompts.NewAppSelectMock() + appSelectPromptFunc = appSelectMock.AppSelectPrompt + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(prompts.SelectedApp{Auth: mockAuthTeam1}, nil) + + // Mock valid session for team1 + cm.API.On("ValidateSession", mock.Anything, mock.Anything).Return(api.AuthSession{ + UserID: &mockAuthTeam1.UserID, + TeamID: &mockAuthTeam1.TeamID, + TeamName: &mockAuthTeam1.TeamDomain, + }, nil) + + // Mock a clean ValidateAppManifest result + cm.API.On("ValidateAppManifest", mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything).Return( + api.ValidateAppManifestResult{ + Warnings: slackerror.Warnings{}, + }, nil, + ) + + // Mock Host + cm.API.On("Host").Return("") + + // Mock a successful CreateApp call and return our mocked AppID + cm.API.On("CreateApp", mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything).Return( + api.CreateAppResult{ + AppID: mockAppTeam1.AppID, + }, + nil, + ) + + // Mock a successful DeveloperAppInstall + cm.API.On("DeveloperAppInstall", mock.Anything, mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + api.DeveloperAppInstallResult{ + AppID: mockAppTeam1.AppID, + APIAccessTokens: struct { + Bot string "json:\"bot,omitempty\"" + AppLevel string "json:\"app_level,omitempty\"" + User string "json:\"user,omitempty\"" + }{}, + }, + types.InstallSuccess, + nil, + ) + + // Mock existing and updated cache + cm.API.On( + "ExportAppManifest", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return( + api.ExportAppResult{}, + nil, + ) + mockProjectCache := cache.NewCacheMock() + mockProjectCache.On("GetManifestHash", mock.Anything, mock.Anything). + Return(cache.Hash(""), nil) + mockProjectCache.On("NewManifestHash", mock.Anything, mock.Anything). + Return(cache.Hash("xoxo"), nil) + mockProjectCache.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything). + Return(nil) + mockProjectConfig := config.NewProjectConfigMock() + mockProjectConfig.On("Cache").Return(mockProjectCache) + cm.Config.ProjectConfig = mockProjectConfig + }, + }, + // TODO(semver:major): Remove this test when the defaulting to deployed is removed. + "adds a new deployed app when team flag is provided and environment flag is not set": { + CmdArgs: []string{"--team", "T123"}, + ExpectedOutputs: []string{"Creating app manifest", "Installing"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + prepareAddMocks(t, cf, cm, "") // Do not set the environment flag + + // Mock SelectPrompt to receive "--environment deployed" + // It would be better to remove this mock and rely on the SelectPrompt implementation, but we require other parts of IO to be mocked. + cm.IO.On("SelectPrompt", + mock.Anything, + "Choose the app environment", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(iostreams.SelectPromptResponse{ + Flag: true, + Option: "deployed", + }, nil) + + // Mock TeamSelector prompt to return "team1" + appSelectMock := prompts.NewAppSelectMock() + appSelectPromptFunc = appSelectMock.AppSelectPrompt + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(prompts.SelectedApp{Auth: mockAuthTeam1}, nil) + + // Mock valid session for team1 + cm.API.On("ValidateSession", mock.Anything, mock.Anything).Return(api.AuthSession{ + UserID: &mockAuthTeam1.UserID, + TeamID: &mockAuthTeam1.TeamID, + TeamName: &mockAuthTeam1.TeamDomain, + }, nil) + + // Mock a clean ValidateAppManifest result + cm.API.On("ValidateAppManifest", mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything).Return( + api.ValidateAppManifestResult{ + Warnings: slackerror.Warnings{}, + }, nil, + ) + + // Mock Host + cm.API.On("Host").Return("") + + // Mock a successful CreateApp call and return our mocked AppID + cm.API.On("CreateApp", mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything).Return( + api.CreateAppResult{ + AppID: mockAppTeam1.AppID, + }, + nil, + ) + + // Mock a successful DeveloperAppInstall + cm.API.On("DeveloperAppInstall", mock.Anything, mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + api.DeveloperAppInstallResult{ + AppID: mockAppTeam1.AppID, + APIAccessTokens: struct { + Bot string "json:\"bot,omitempty\"" + AppLevel string "json:\"app_level,omitempty\"" + User string "json:\"user,omitempty\"" + }{}, + }, + types.InstallSuccess, + nil, + ) + + // Mock existing and updated cache + cm.API.On( + "ExportAppManifest", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return( + api.ExportAppResult{}, + nil, + ) + mockProjectCache := cache.NewCacheMock() + mockProjectCache.On("GetManifestHash", mock.Anything, mock.Anything). + Return(cache.Hash(""), nil) + mockProjectCache.On("NewManifestHash", mock.Anything, mock.Anything). + Return(cache.Hash("xoxo"), nil) + mockProjectCache.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything). + Return(nil) + mockProjectConfig := config.NewProjectConfigMock() + mockProjectConfig.On("Cache").Return(mockProjectCache) + cm.Config.ProjectConfig = mockProjectConfig + }, + }, + "skips app environment prompt when --app A0123 is set": { + CmdArgs: []string{"--app", "A123"}, + ExpectedOutputs: []string{"Installing"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertNotCalled(t, "SelectPrompt") + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + prepareAddMocks(t, cf, cm, "") // Do not set the environment flag + + // Mock TeamSelector prompt to return "team1" + appSelectMock := prompts.NewAppSelectMock() + appSelectPromptFunc = appSelectMock.AppSelectPrompt + appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(prompts.SelectedApp{Auth: mockAuthTeam1}, nil) + + // Mock valid session for team1 + cm.API.On("ValidateSession", mock.Anything, mock.Anything).Return(api.AuthSession{ + UserID: &mockAuthTeam1.UserID, + TeamID: &mockAuthTeam1.TeamID, + TeamName: &mockAuthTeam1.TeamDomain, + }, nil) + + // Mock a clean ValidateAppManifest result + cm.API.On("ValidateAppManifest", mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything).Return( + api.ValidateAppManifestResult{ + Warnings: slackerror.Warnings{}, + }, nil, + ) + + // Mock Host + cm.API.On("Host").Return("") + + // Mock a successful CreateApp call and return our mocked AppID + cm.API.On("CreateApp", mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything).Return( + api.CreateAppResult{ + AppID: mockAppTeam1.AppID, + }, + nil, + ) + + // Mock a successful DeveloperAppInstall + cm.API.On("DeveloperAppInstall", mock.Anything, mock.Anything, mockAuthTeam1.Token, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + api.DeveloperAppInstallResult{ + AppID: mockAppTeam1.AppID, + APIAccessTokens: struct { + Bot string "json:\"bot,omitempty\"" + AppLevel string "json:\"app_level,omitempty\"" + User string "json:\"user,omitempty\"" + }{}, + }, + types.InstallSuccess, + nil, + ) + + // Mock existing and updated cache + cm.API.On( + "ExportAppManifest", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return( + api.ExportAppResult{}, + nil, + ) + mockProjectCache := cache.NewCacheMock() + mockProjectCache.On("GetManifestHash", mock.Anything, mock.Anything). + Return(cache.Hash(""), nil) + mockProjectCache.On("NewManifestHash", mock.Anything, mock.Anything). + Return(cache.Hash("xoxo"), nil) + mockProjectCache.On("SetManifestHash", mock.Anything, mock.Anything, mock.Anything). + Return(nil) + mockProjectConfig := config.NewProjectConfigMock() + mockProjectConfig.On("Cache").Return(mockProjectCache) + cm.Config.ProjectConfig = mockProjectConfig + }, + }, "When admin approval request is pending, outputs instructions": { CmdArgs: []string{"--" + cmdutil.OrgGrantWorkspaceFlag, "T123"}, ExpectedOutputs: []string{"Creating app manifest", "Installing", "Your request to install the app is pending", "complete installation by re-running"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - prepareAddMocks(t, cf, cm) + prepareAddMocks(t, cf, cm, "deployed") // Select workspace appSelectMock := prompts.NewAppSelectMock() appSelectPromptFunc = appSelectMock.AppSelectPrompt @@ -416,7 +824,7 @@ func TestAppAddCommand(t *testing.T) { CmdArgs: []string{"--" + cmdutil.OrgGrantWorkspaceFlag, "T123"}, ExpectedOutputs: []string{"Creating app manifest", "Installing", "Your request to install the app has been cancelled"}, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { - prepareAddMocks(t, cf, cm) + prepareAddMocks(t, cf, cm, "deployed") // Select workspace appSelectMock := prompts.NewAppSelectMock() appSelectPromptFunc = appSelectMock.AppSelectPrompt @@ -471,11 +879,12 @@ func TestAppAddCommand(t *testing.T) { }, func(cf *shared.ClientFactory) *cobra.Command { cmd := NewAddCommand(cf) cmd.PreRunE = func(cmd *cobra.Command, args []string) error { return nil } + cf.Config.SetFlags(cmd) return cmd }) } -func prepareAddMocks(t *testing.T, clients *shared.ClientFactory, clientsMock *shared.ClientsMock) { +func prepareAddMocks(t *testing.T, clients *shared.ClientFactory, clientsMock *shared.ClientsMock, appEnvironment string) { clientsMock.AddDefaultMocks() clientsMock.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything). @@ -498,4 +907,18 @@ func prepareAddMocks(t *testing.T, clients *shared.ClientFactory, clientsMock *s listPkgMock := new(ListPkgMock) listFunc = listPkgMock.List listPkgMock.On("List").Return(nil) + + // Mock the prompt to select the app environment. + if appEnvironment != "" { + clientsMock.IO.On("SelectPrompt", + mock.Anything, + "Choose the app environment", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return(iostreams.SelectPromptResponse{ + Flag: true, + Option: appEnvironment, + }, nil) + } } diff --git a/cmd/platform/deploy.go b/cmd/platform/deploy.go index 0a15b78e..ff1f5825 100644 --- a/cmd/platform/deploy.go +++ b/cmd/platform/deploy.go @@ -162,16 +162,24 @@ func hasValidDeploymentMethod( return err } switch { + // When the manifest source is local, we can get the manifest from the local project. case manifestSource.Equals(config.ManifestSourceLocal): manifest, err = clients.AppClient().Manifest.GetManifestLocal(ctx, clients.SDKConfig, clients.HookExecutor) if err != nil { return err } - case manifestSource.Equals(config.ManifestSourceRemote): + // When the manifest source is remote and the app exists, we can get the manifest from the the API. + case manifestSource.Equals(config.ManifestSourceRemote) && app.AppID != "": manifest, err = clients.AppClient().Manifest.GetManifestRemote(ctx, auth.Token, app.AppID) if err != nil { return err } + // When the app does not exist, we need to get the manifest from the local project. + default: + manifest, err = clients.AppClient().Manifest.GetManifestLocal(ctx, clients.SDKConfig, clients.HookExecutor) + if err != nil { + return err + } } if manifest.FunctionRuntime() == types.SlackHosted { return nil diff --git a/cmd/platform/deploy_test.go b/cmd/platform/deploy_test.go index f30451d8..b03b0eb6 100644 --- a/cmd/platform/deploy_test.go +++ b/cmd/platform/deploy_test.go @@ -111,6 +111,7 @@ func TestDeployCommand(t *testing.T) { func TestDeployCommand_HasValidDeploymentMethod(t *testing.T) { tests := map[string]struct { + app types.App manifest types.SlackYaml manifestError error manifestSource config.ManifestSource @@ -144,6 +145,20 @@ func TestDeployCommand_HasValidDeploymentMethod(t *testing.T) { manifestSource: config.ManifestSourceLocal, expectedError: slackerror.New(slackerror.ErrSDKHookNotFound), }, + "succeeds if the app exists and the manifest source is remote": { + app: types.App{ + AppID: "A123", + }, + manifestSource: config.ManifestSourceRemote, + expectedError: slackerror.New(slackerror.ErrSDKHookNotFound), + }, + "succeeds if the app does not exist and the manifest source is remote": { + app: types.App{ + AppID: "", + }, + manifestSource: config.ManifestSourceRemote, + expectedError: slackerror.New(slackerror.ErrSDKHookNotFound), + }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { @@ -152,6 +167,7 @@ func TestDeployCommand_HasValidDeploymentMethod(t *testing.T) { clients := shared.NewClientFactory(clientsMock.MockClientFactory(), func(clients *shared.ClientFactory) { manifestMock := &app.ManifestMockObject{} manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(tt.manifest, tt.manifestError) + manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).Return(tt.manifest, tt.manifestError) clients.AppClient().Manifest = manifestMock projectConfigMock := config.NewProjectConfigMock() projectConfigMock.On("GetManifestSource", mock.Anything). @@ -160,7 +176,11 @@ func TestDeployCommand_HasValidDeploymentMethod(t *testing.T) { clients.SDKConfig = hooks.NewSDKConfigMock() clients.SDKConfig.Hooks.Deploy.Command = tt.deployScript }) - err := hasValidDeploymentMethod(ctx, clients, types.App{}, types.SlackAuth{}) + app := types.App{} + if tt.app.AppID != "" { + app = tt.app + } + err := hasValidDeploymentMethod(ctx, clients, app, types.SlackAuth{}) if tt.expectedError != nil { require.Error(t, err) assert.Equal( diff --git a/cmd/triggers/create.go b/cmd/triggers/create.go index ed78fd6b..076445ed 100644 --- a/cmd/triggers/create.go +++ b/cmd/triggers/create.go @@ -51,7 +51,6 @@ type createCmdFlags struct { var createFlags createCmdFlags -// TODO(mcodik) figure out a way to mock this more nicely var createAppSelectPromptFunc = prompts.AppSelectPrompt var workspaceInstallAppFunc = app.RunAddCommand var createPromptShouldRetryWithInteractivityFunc = promptShouldRetryCreateWithInteractivity diff --git a/docs/reference/experiments.md b/docs/reference/experiments.md index 79793c74..9b73a32f 100644 --- a/docs/reference/experiments.md +++ b/docs/reference/experiments.md @@ -8,13 +8,14 @@ The following is a list of currently available experiments. We'll remove experim * `bolt-install`: enables creating, installing, and running Bolt projects that manage their app manifest on app settings (remote manifest). * `slack create` and `slack init` now set manifest source to "app settings" (remote) for Bolt JS & Bolt Python projects ([PR#96](https://github.com/slackapi/slack-cli/pull/96)). + * `slack run` and `slack install` support creating and installing Bolt Framework apps that have the manifest source set to "app settings (remote)" ([PR#111](https://github.com/slackapi/slack-cli/pull/111), [PR#154](https://github.com/slackapi/slack-cli/pull/154)). * `read-only-collaborators`: enables creating and modifying collaborator permissions via the `slack collaborator` commands. ## Experiments changelog Below is a list of updates related to experiments. -* **June 2025**: +* **June 2025**: * Updated the `slack run` command to create and install new and existing Bolt framework projects configured with app settings as the source of truth (remote manifest). * Added support for creating, installing, and running Bolt projects that manage their app manifest on app settings (remote manifest). New Bolt projects are now configured to have apps managed by app settings rather than by project. When running a project for local development, the app and bot tokens are automatically set, and no longer require developers to export them as environment variables. Existing Bolt projects will continue to work with a project (local) manifest, and linking an app from app settings will configure the project to be managed by app settings (remote manifest). In an upcoming release, support for installing and deploying apps managed by app settings will be implemented. * **May 2025**: Added the experiment `bolt-install` to enable creating, installing, and running Bolt projects that manage their app manifest on app settings (remote manifest). diff --git a/internal/pkg/apps/install.go b/internal/pkg/apps/install.go index e94c3e8c..8eff94dc 100644 --- a/internal/pkg/apps/install.go +++ b/internal/pkg/apps/install.go @@ -54,8 +54,11 @@ func Install(ctx context.Context, clients *shared.ClientFactory, log *logger.Log if err != nil { return types.App{}, "", err } - if !manifestUpdates && !manifestCreates { - return app, "", nil + + if !clients.Config.WithExperimentOn(experiment.BoltInstall) { + if !manifestUpdates && !manifestCreates { + return app, "", nil + } } // Get the token for the authenticated workspace @@ -81,22 +84,47 @@ func Install(ctx context.Context, clients *shared.ClientFactory, log *logger.Log app.EnterpriseID = *authSession.EnterpriseID } - slackYaml, err := clients.AppClient().Manifest.GetManifestLocal(ctx, clients.SDKConfig, clients.HookExecutor) - if err != nil { - return app, "", err + // When the BoltInstall experiment is enabled, we need to get the manifest from the local file + // if the manifest source is local or if we are creating a new app. After an app is created, + // app settings becomes the source of truth for remote manifests, so updates and installs always + // get the latest manifest from app settings. + // When the BoltInstall experiment is disabled, we get the manifest from the local file because + // this is how the original implementation worked. + var slackManifest types.SlackYaml + if clients.Config.WithExperimentOn(experiment.BoltInstall) { + manifestSource, err := clients.Config.ProjectConfig.GetManifestSource(ctx) + if err != nil { + return app, "", err + } + if manifestSource.Equals(config.ManifestSourceLocal) || manifestCreates { + slackManifest, err = clients.AppClient().Manifest.GetManifestLocal(ctx, clients.SDKConfig, clients.HookExecutor) + if err != nil { + return app, "", err + } + } else { + slackManifest, err = clients.AppClient().Manifest.GetManifestRemote(ctx, auth.Token, app.AppID) + if err != nil { + return app, "", err + } + } + } else { + slackManifest, err = clients.AppClient().Manifest.GetManifestLocal(ctx, clients.SDKConfig, clients.HookExecutor) + if err != nil { + return app, "", err + } } - log.Data["appName"] = slackYaml.DisplayInformation.Name + log.Data["appName"] = slackManifest.DisplayInformation.Name log.Data["isUpdate"] = app.AppID != "" log.Data["teamName"] = *authSession.TeamName log.Log("INFO", "app_install_manifest") - manifest := slackYaml.AppManifest - if slackYaml.IsFunctionRuntimeSlackHosted() { + manifest := slackManifest.AppManifest + if slackManifest.IsFunctionRuntimeSlackHosted() { configureHostedManifest(ctx, clients, &manifest) } - err = validateManifestForInstall(ctx, clients, app, manifest) + err = validateManifestForInstall(ctx, clients, token, app, manifest) if err != nil { return app, "", err } @@ -124,6 +152,8 @@ func Install(ctx context.Context, clients *shared.ClientFactory, log *logger.Log app.AppID = result.AppID app.TeamID = *authSession.TeamID app.TeamDomain = auth.TeamDomain + // TODO: add enterprise ID and user ID to app? See InstallLocalApp. + // app.EnterpriseID = config.GetContextEnterpriseID(ctx) } appManageURL := fmt.Sprintf("%s/apps", apiInterface.Host()) @@ -197,7 +227,7 @@ func Install(ctx context.Context, clients *shared.ClientFactory, log *logger.Log } // upload icon, default to icon.png - var iconPath = slackYaml.Icon + var iconPath = slackManifest.Icon if iconPath == "" { if _, err := os.Stat("icon.png"); !os.IsNotExist(err) { iconPath = "icon.png" @@ -213,7 +243,7 @@ func Install(ctx context.Context, clients *shared.ClientFactory, log *logger.Log } else { log.Info("app_install_icon_success") } - // TODO(@mbrevoort) reoptimize + // TODO: Optimization. // Save a md5 hash of the icon in environments.yaml // var iconHash string // iconHash, err = getIconHash(iconPath) @@ -257,9 +287,7 @@ func printNonSuccessInstallState(ctx context.Context, clients *shared.ClientFact clients.IO.PrintInfo(ctx, false, status) } -func validateManifestForInstall(ctx context.Context, clients *shared.ClientFactory, app types.App, appManifest types.AppManifest) error { - var token = config.GetContextToken(ctx) - +func validateManifestForInstall(ctx context.Context, clients *shared.ClientFactory, token string, app types.App, appManifest types.AppManifest) error { validationResult, err := clients.API().ValidateAppManifest(ctx, token, appManifest, app.AppID) if retryValidate := manifest.HandleConnectorNotInstalled(ctx, clients, token, err); retryValidate { @@ -329,8 +357,7 @@ func validateManifestForInstall(ctx context.Context, clients *shared.ClientFacto // InstallLocalApp installs a non-hosted local app to a workspace. func InstallLocalApp(ctx context.Context, clients *shared.ClientFactory, orgGrantWorkspaceID string, log *logger.Logger, auth types.SlackAuth, app types.App) (types.App, api.DeveloperAppInstallResult, types.InstallState, error) { - var span opentracing.Span - span, ctx = opentracing.StartSpanFromContext(ctx, "installLocalApp") + span, ctx := opentracing.StartSpanFromContext(ctx, "installLocalApp") defer span.Finish() manifestUpdates, err := shouldUpdateManifest(ctx, clients, app, auth) @@ -367,6 +394,8 @@ func InstallLocalApp(ctx context.Context, clients *shared.ClientFactory, orgGran if authSession.EnterpriseID != nil { ctx = config.SetContextEnterpriseID(ctx, *authSession.EnterpriseID) clients.EventTracker.SetAuthEnterpriseID(*authSession.EnterpriseID) + // TODO: add enterprise ID to app? See Install. + // app.EnterpriseID = *authSession.EnterpriseID } // When the BoltInstall experiment is enabled, we need to get the manifest from the local file @@ -410,7 +439,7 @@ func InstallLocalApp(ctx context.Context, clients *shared.ClientFactory, orgGran configureLocalManifest(ctx, clients, &manifest) } - err = validateManifestForInstall(ctx, clients, app, manifest) + err = validateManifestForInstall(ctx, clients, token, app, manifest) if err != nil { return app, api.DeveloperAppInstallResult{}, "", err } diff --git a/internal/pkg/apps/install_test.go b/internal/pkg/apps/install_test.go index 1eedbc2b..6763a943 100644 --- a/internal/pkg/apps/install_test.go +++ b/internal/pkg/apps/install_test.go @@ -55,7 +55,8 @@ func TestInstall(t *testing.T) { mockBoltExperiment bool mockConfirmPrompt bool mockIsTTY bool - mockManifest types.SlackYaml + mockManifestAppLocal types.SlackYaml + mockManifestAppRemote types.SlackYaml mockManifestHashInitial cache.Hash mockManifestHashUpdated cache.Hash mockManifestSource config.ManifestSource @@ -88,7 +89,7 @@ func TestInstall(t *testing.T) { }, mockBoltExperiment: true, mockManifestSource: config.ManifestSourceLocal, - mockManifest: types.SlackYaml{ + mockManifestAppLocal: types.SlackYaml{ AppManifest: types.AppManifest{ Metadata: &types.ManifestMetadata{ MajorVersion: 2, @@ -147,7 +148,7 @@ func TestInstall(t *testing.T) { }, mockBoltExperiment: true, mockManifestSource: config.ManifestSourceLocal, - mockManifest: types.SlackYaml{ + mockManifestAppLocal: types.SlackYaml{ AppManifest: types.AppManifest{ Metadata: &types.ManifestMetadata{ MajorVersion: 2, @@ -208,7 +209,8 @@ func TestInstall(t *testing.T) { mockBoltExperiment: true, mockConfirmPrompt: true, mockIsTTY: true, - mockManifest: types.SlackYaml{ + mockManifestSource: config.ManifestSourceLocal, + mockManifestAppLocal: types.SlackYaml{ AppManifest: types.AppManifest{ Metadata: &types.ManifestMetadata{ MajorVersion: 1, @@ -270,7 +272,7 @@ func TestInstall(t *testing.T) { TeamName: &mockTeamDomain, UserID: &mockUserID, }, - mockManifest: types.SlackYaml{ + mockManifestAppLocal: types.SlackYaml{ AppManifest: types.AppManifest{ DisplayInformation: types.DisplayInformation{ Name: "example-3", @@ -297,7 +299,61 @@ func TestInstall(t *testing.T) { }, expectedUpdate: true, }, - "avoids updating or installing an app with a remote manifest": { + "create and install an app with a remote manifest": { + mockApp: types.App{}, + mockAPICreate: api.CreateAppResult{ + AppID: "A001", + }, + mockAPIUpdateError: slackerror.New(slackerror.ErrAppAdd), + mockAuth: types.SlackAuth{ + EnterpriseID: mockEnterpriseID, + TeamID: mockTeamID, + TeamDomain: mockTeamDomain, + Token: mockToken, + UserID: mockUserID, + }, + mockAuthSession: api.AuthSession{ + EnterpriseID: &mockEnterpriseID, + TeamID: &mockTeamID, + TeamName: &mockTeamDomain, + UserID: &mockUserID, + }, + mockBoltExperiment: true, + mockManifestSource: config.ManifestSourceRemote, + mockManifestAppLocal: types.SlackYaml{ + AppManifest: types.AppManifest{ + Metadata: &types.ManifestMetadata{ + MajorVersion: 2, + }, + Settings: &types.AppSettings{ + EventSubscriptions: &types.ManifestEventSubscriptions{ + BotEvents: []string{"chat:write"}, + UserEvents: []string{"channels:read"}, + }, + }, + }, + }, + expectedApp: types.App{ + AppID: "A001", + EnterpriseID: mockEnterpriseID, + TeamID: mockTeamID, + TeamDomain: mockTeamDomain, + }, + expectedManifest: types.AppManifest{ + Metadata: &types.ManifestMetadata{ + MajorVersion: 2, + }, + Settings: &types.AppSettings{ + EventSubscriptions: &types.ManifestEventSubscriptions{ + BotEvents: []string{"chat:write"}, + UserEvents: []string{"channels:read"}, + }, + }, + }, + expectedCreate: true, + expectedUpdate: false, + }, + "avoids updating an app with a remote manifest": { mockApp: types.App{ AppID: "A004", TeamID: mockTeamID, @@ -313,11 +369,10 @@ func TestInstall(t *testing.T) { TeamName: &mockTeamDomain, UserID: &mockUserID, }, - mockAPICreateError: slackerror.New(slackerror.ErrAppCreate), - mockAPIUpdateError: slackerror.New(slackerror.ErrAppAdd), - mockAPIInstallError: slackerror.New(slackerror.ErrAppInstall), - mockBoltExperiment: true, - mockManifestSource: config.ManifestSourceRemote, + mockAPICreateError: slackerror.New(slackerror.ErrAppCreate), + mockAPIUpdateError: slackerror.New(slackerror.ErrAppAdd), + mockBoltExperiment: true, + mockManifestSource: config.ManifestSourceRemote, expectedApp: types.App{ AppID: "A004", TeamID: mockTeamID, @@ -384,7 +439,7 @@ func TestInstall(t *testing.T) { mockBoltExperiment: true, mockConfirmPrompt: false, mockIsTTY: true, - mockManifest: types.SlackYaml{ + mockManifestAppLocal: types.SlackYaml{ AppManifest: types.AppManifest{ Metadata: &types.ManifestMetadata{ MajorVersion: 1, @@ -424,7 +479,7 @@ func TestInstall(t *testing.T) { UserID: &mockUserID, }, mockBoltExperiment: true, - mockManifest: types.SlackYaml{ + mockManifestAppRemote: types.SlackYaml{ AppManifest: types.AppManifest{ Metadata: &types.ManifestMetadata{ MajorVersion: 1, @@ -535,7 +590,8 @@ func TestInstall(t *testing.T) { ) } manifestMock := &app.ManifestMockObject{} - manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(tt.mockManifest, nil) + manifestMock.On("GetManifestLocal", mock.Anything, mock.Anything, mock.Anything).Return(tt.mockManifestAppLocal, nil) + manifestMock.On("GetManifestRemote", mock.Anything, mock.Anything, mock.Anything).Return(tt.mockManifestAppRemote, nil) clientsMock.AppClient.Manifest = manifestMock mockProjectConfig := config.NewProjectConfigMock() if tt.mockBoltExperiment { @@ -1536,7 +1592,7 @@ func TestValidateManifestForInstall(t *testing.T) { Return(tt.result, tt.err) clients := shared.NewClientFactory(clientsMock.MockClientFactory()) - err := validateManifestForInstall(ctx, clients, tt.app, tt.manifest) + err := validateManifestForInstall(ctx, clients, "xoxe.xoxp-1-token", tt.app, tt.manifest) assert.NoError(t, err) tt.check(clientsMock)