diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go new file mode 100644 index 00000000..36dc458f --- /dev/null +++ b/cmd/sandbox/create.go @@ -0,0 +1,278 @@ +// 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 sandbox + +import ( + "fmt" + "strconv" + "strings" + "time" + "unicode" + + "github.com/slackapi/slack-cli/internal/iostreams" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +type createFlags struct { + name string + domain string + password string + locale string + owningOrgID string + template string + eventCode string + archiveTTL string // TTL duration, e.g. 1d, 2w, 3mo + archiveDate string // explicit date yyyy-mm-dd + partner bool +} + +var createCmdFlags createFlags + +// templateNameToID maps user-friendly template names to integer IDs +var templateNameToID = map[string]int{ + "default": 1, // The default template + "empty": 0, // The sandbox will be empty if the template param is not set +} + +func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "create [flags]", + Short: "Create a developer sandbox", + Long: `Create a new Slack developer sandbox`, + Example: style.ExampleCommandsf([]style.ExampleCommand{ + {Command: "sandbox create --name test-box --password mypass", Meaning: "Create a sandbox named test-box"}, + {Command: "sandbox create --name test-box --password mypass --domain test-box --archive-ttl 1d", Meaning: "Create a temporary sandbox that will be archived in 1 day"}, + {Command: "sandbox create --name test-box --password mypass --domain test-box --archive-date 2025-12-31", Meaning: "Create a sandbox that will be archived on a specific date"}, + }), + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + return requireSandboxExperiment(clients) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runCreateCommand(cmd, clients) + }, + } + + cmd.Flags().StringVar(&createCmdFlags.name, "name", "", "Organization name for the new sandbox") + cmd.Flags().StringVar(&createCmdFlags.domain, "domain", "", "Team domain. Derived from org name if not provided") + cmd.Flags().StringVar(&createCmdFlags.password, "password", "", "Password used to log into the sandbox") + cmd.Flags().StringVar(&createCmdFlags.locale, "locale", "", "Locale (eg. en-us, languageCode-countryCode)") + cmd.Flags().StringVar(&createCmdFlags.template, "template", "", "Template with sample data to apply to the sandbox (options: default, empty)") + cmd.Flags().StringVar(&createCmdFlags.eventCode, "event-code", "", "Event code for the sandbox") + cmd.Flags().StringVar(&createCmdFlags.archiveTTL, "archive-ttl", "", "Time-to-live duration (eg. 1d, 2w, 3mo). Cannot be used with --archive-date") + cmd.Flags().StringVar(&createCmdFlags.archiveDate, "archive-date", "", "Explicit archive date in yyyy-mm-dd format. Cannot be used with --archive-ttl") + cmd.Flags().BoolVar(&createCmdFlags.partner, "partner", false, "Developers who are part of the Partner program can create partner sandboxes") + + // If one's developer account is managed by multiple Production Slack teams, one of those team IDs must be provided in the command + cmd.Flags().StringVar(&createCmdFlags.owningOrgID, "owning-org-id", "", "Enterprise team ID that manages your developer account, if applicable") + + return cmd +} + +func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { + ctx := cmd.Context() + + auth, err := getSandboxAuth(ctx, clients) + if err != nil { + return err + } + + name := createCmdFlags.name + if name == "" { + name, err = clients.IO.InputPrompt( + ctx, + "Enter a name for the sandbox", + iostreams.InputPromptConfig{ + Required: true, + }, + ) + if err != nil { + return err + } + } + + password := createCmdFlags.password + if password == "" { + password, err = clients.IO.InputPrompt( + ctx, + "Enter a password for the sandbox", + iostreams.InputPromptConfig{ + Required: true, + }, + ) + if err != nil { + return err + } + } + + domain := createCmdFlags.domain + if domain == "" { + var err error + domain, err = domainFromName(name) + if err != nil { + return err + } + } + + if createCmdFlags.archiveTTL != "" && createCmdFlags.archiveDate != "" { + return slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("Cannot use both --archive-ttl and --archive-date") + } + + archiveEpochDatetime := int64(0) + if createCmdFlags.archiveTTL != "" { + archiveEpochDatetime, err = getEpochFromTTL(createCmdFlags.archiveTTL) + if err != nil { + return err + } + } else if createCmdFlags.archiveDate != "" { + archiveEpochDatetime, err = getEpochFromDate(createCmdFlags.archiveDate) + if err != nil { + return err + } + } + + templateID, err := getTemplateID(createCmdFlags.template) + if err != nil { + return err + } + + teamID, sandboxURL, err := clients.API().CreateSandbox(ctx, auth.Token, + name, + domain, + password, + createCmdFlags.locale, + createCmdFlags.owningOrgID, + templateID, + createCmdFlags.eventCode, + archiveEpochDatetime, + createCmdFlags.partner, + ) + if err != nil { + return err + } + + printCreateSuccess(cmd, clients, teamID, sandboxURL) + + return nil +} + +// getEpochFromTTL parses a time-to-live string (e.g., "1d", "2w", "3mo") and returns the Unix epoch +// when the sandbox will be archived. Supports days (d), weeks (w), and months (mo). +func getEpochFromTTL(ttl string) (int64, error) { + lower := strings.TrimSpace(strings.ToLower(ttl)) + if lower == "" { + return 0, slackerror.New(slackerror.ErrInvalidSandboxArchiveTTL) + } + + var target time.Time + now := time.Now() + + switch { + case strings.HasSuffix(lower, "d"): + n, err := strconv.Atoi(strings.TrimSuffix(lower, "d")) + if err != nil || n < 1 { + return 0, slackerror.New(slackerror.ErrInvalidSandboxArchiveTTL) + } + target = now.AddDate(0, 0, n) + case strings.HasSuffix(lower, "w"): + n, err := strconv.Atoi(strings.TrimSuffix(lower, "w")) + if err != nil || n < 1 { + return 0, slackerror.New(slackerror.ErrInvalidSandboxArchiveTTL) + } + target = now.AddDate(0, 0, n*7) + case strings.HasSuffix(lower, "mo"): + n, err := strconv.Atoi(strings.TrimSuffix(lower, "mo")) + if err != nil || n < 1 { + return 0, slackerror.New(slackerror.ErrInvalidSandboxArchiveTTL) + } + target = now.AddDate(0, n, 0) + default: + return 0, slackerror.New(slackerror.ErrInvalidSandboxArchiveTTL) + } + + return target.Unix(), nil +} + +// getEpochFromDate parses a date in yyyy-mm-dd format and returns the Unix epoch at the start of that day (UTC) +func getEpochFromDate(dateStr string) (int64, error) { + dateFormat := "2006-01-02" + t, err := time.ParseInLocation(dateFormat, dateStr, time.UTC) + if err != nil { + return 0, slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("Invalid archive date: %q", dateStr). + WithRemediation("Use yyyy-mm-dd format") + } + now := time.Now().UTC() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + if t.Before(today) { + return 0, slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("Archive date must be in the future") + } + return t.Unix(), nil +} + +// domainFromName derives domain-safe text from the name of the sandbox (lowercase, alphanumeric + hyphens). +func domainFromName(name string) (string, error) { + name = strings.ToLower(name) + name = strings.ReplaceAll(name, " ", "-") + name = strings.ReplaceAll(name, "_", "-") + var domain []byte + for _, r := range name { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' { + domain = append(domain, byte(r)) + } + } + domain = []byte(strings.Trim(string(domain), "-")) + if len(domain) == 0 { + return "", slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("Provide a valid domain name with the --domain flag") + } + return string(domain), nil +} + +// getTemplateID converts a template string to an integer ID +func getTemplateID(template string) (int, error) { + if template == "" { + return 0, nil + } + key := strings.ToLower(strings.TrimSpace(template)) + // If the provided string is present in the map, return the ID + if id, ok := templateNameToID[key]; ok { + return id, nil + } + // We also accept an integer passed directly via the flag + if id, err := strconv.Atoi(key); err == nil { + return id, nil + } + return 0, slackerror.New(slackerror.ErrInvalidSandboxTemplateID). + WithMessage("Invalid template: %q", template) +} + +func printCreateSuccess(cmd *cobra.Command, clients *shared.ClientFactory, teamID, url string) { + ctx := cmd.Context() + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "beach_with_umbrella", + Text: "Sandbox Created", + Secondary: []string{ + fmt.Sprintf("Team ID: %s", teamID), + fmt.Sprintf("URL: %s", url), + }, + })) + clients.IO.PrintInfo(ctx, false, "Manage this sandbox from the CLI or visit\n%s", style.Secondary("https://api.slack.com/developer-program/sandboxes")) +} diff --git a/cmd/sandbox/create_test.go b/cmd/sandbox/create_test.go new file mode 100644 index 00000000..33b6677e --- /dev/null +++ b/cmd/sandbox/create_test.go @@ -0,0 +1,592 @@ +// 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 sandbox + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/slackapi/slack-cli/internal/experiment" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" +) + +func TestCreateCommand(t *testing.T) { + archiveDate := time.Now().UTC().AddDate(0, 6, 0).Truncate(24 * time.Hour) + archiveDateStr := archiveDate.Format("2006-01-02") + archiveEpoch := archiveDate.Unix() + + testutil.TableTestCommand(t, testutil.CommandTests{ + "create success": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "test-box", + "--domain", "test-box", + "--password", "mypass", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "test-box", "test-box", "mypass", "", "", 0, "", int64(0), false). + Return("T123", "https://test-box.slack.com", nil) + cm.API.On("UsersInfo", mock.Anything, mock.Anything, mock.Anything).Return(&types.UserInfo{Profile: types.UserProfile{}}, nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"T123", "https://test-box.slack.com", "Sandbox Created"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Auth.AssertCalled(t, "AuthWithToken", mock.Anything, "xoxb-test-token") + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "test-box", "test-box", "mypass", "", "", 0, "", int64(0), false) + }, + }, + "create with derived domain": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "My Test Box", + "--domain", "my-test-box", + "--password", "pass", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "My Test Box", "my-test-box", "pass", "", "", 0, "", int64(0), false). + Return("T789", "https://my-test-box.slack.com", nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "My Test Box", "my-test-box", "pass", "", "", 0, "", int64(0), false) + }, + }, + "create with a relative time-to-live value": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tmp-box", + "--domain", "tmp-box", + "--password", "pass", + "--archive-ttl", "1d", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "tmp-box", "tmp-box", "pass", "", "", 0, "", mock.MatchedBy(func(v int64) bool { return v > 0 }), false). + Return("T111", "https://tmp-box.slack.com", nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "tmp-box", "tmp-box", "pass", "", "", 0, "", mock.MatchedBy(func(v int64) bool { return v > 0 }), false) + }, + }, + "create API error": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "err-box", + "--domain", "err-box", + "--password", "pass", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "err-box", "err-box", "pass", "", "", 0, "", int64(0), false). + Return("", "", errors.New("api_error")) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedErrorStrings: []string{"api_error"}, + }, + "create with 'default' template": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tpl-box", + "--domain", "tpl-box", + "--password", "pass", + "--template", "default", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "tpl-box", "tpl-box", "pass", "", "", 1, "", int64(0), false). + Return("T333", "https://tpl-box.slack.com", nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"T333", "https://tpl-box.slack.com", "Sandbox Created"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "tpl-box", "tpl-box", "pass", "", "", 1, "", int64(0), false) + }, + }, + "create with partner flag": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "partner-box", + "--domain", "partner-box", + "--password", "pass", + "--partner", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "partner-box", "partner-box", "pass", "", "", 0, "", int64(0), true). + Return("T555", "https://partner-box.slack.com", nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"T555", "https://partner-box.slack.com", "Sandbox Created"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "partner-box", "partner-box", "pass", "", "", 0, "", int64(0), true) + }, + }, + "create with invalid template fails": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tmpl-box", + "--domain", "tmpl-box", + "--password", "pass", + "--template", "invalid", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedErrorStrings: []string{"Invalid template"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "CreateSandbox", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, + "create with archive-date": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "date-box", + "--domain", "date-box", + "--password", "pass", + "--archive-date", archiveDateStr, + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "date-box", "date-box", "pass", "", "", 0, "", archiveEpoch, false). + Return("T222", "https://date-box.slack.com", nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "date-box", "date-box", "pass", "", "", 0, "", archiveEpoch, false) + }, + }, + "create with both archive and archive-date fails": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tmp-box", + "--domain", "tmp-box", + "--password", "pass", + "--archive-ttl", "1d", + "--archive-date", "2025-12-31", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedErrorStrings: []string{"Cannot use both --archive-ttl and --archive-date"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "CreateSandbox", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, + "create with invalid archive-ttl value fails": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tmp-box", + "--domain", "tmp-box", + "--password", "pass", + "--archive-ttl", "invalid", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedErrorStrings: []string{"Invalid TTL"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "CreateSandbox", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, + "experiment required": { + CmdArgs: []string{ + "--name", "test-box", + "--domain", "test-box", + "--password", "pass", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + }, + ExpectedErrorStrings: []string{"sandbox"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "CreateSandbox", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCreateCommand(cf) + }) +} + +func setupCreateMocks(t *testing.T, ctx context.Context, cm *shared.ClientsMock, name, domain, password string, archiveEpoch interface{}, partner bool) { + t.Helper() + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, name, domain, password, "", "", 0, "", archiveEpoch, partner). + Return("T222", "https://"+domain+".slack.com", nil) + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) +} + +func setupCreateAuthOnly(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + t.Helper() + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) +} + +func Test_getEpochFromTTL(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "1d": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "ttl-box", + "--domain", "ttl-box", + "--password", "pass", + "--archive-ttl", "1d", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + setupCreateMocks(t, ctx, cm, "ttl-box", "ttl-box", "pass", mock.MatchedBy(func(x int64) bool { return x > 0 }), false) + }, + ExpectedStdoutOutputs: []string{"Sandbox Created"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "ttl-box", "ttl-box", "pass", "", "", 0, "", mock.MatchedBy(func(x int64) bool { return x > 0 }), false) + }, + }, + "1w": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "ttl-box", + "--domain", "ttl-box", + "--password", "pass", + "--archive-ttl", "1w", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + setupCreateMocks(t, ctx, cm, "ttl-box", "ttl-box", "pass", mock.MatchedBy(func(x int64) bool { return x > 0 }), false) + }, + ExpectedStdoutOutputs: []string{"Sandbox Created"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "ttl-box", "ttl-box", "pass", "", "", 0, "", mock.MatchedBy(func(x int64) bool { return x > 0 }), false) + }, + }, + "6mo": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "ttl-box", + "--domain", "ttl-box", + "--password", "pass", + "--archive-ttl", "6mo", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + setupCreateMocks(t, ctx, cm, "ttl-box", "ttl-box", "pass", mock.MatchedBy(func(x int64) bool { return x > 0 }), false) + }, + ExpectedStdoutOutputs: []string{"Sandbox Created"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "ttl-box", "ttl-box", "pass", "", "", 0, "", mock.MatchedBy(func(x int64) bool { return x > 0 }), false) + }, + }, + "invalid": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "ttl-box", + "--domain", "ttl-box", + "--password", "pass", + "--archive-ttl", "invalid", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + setupCreateAuthOnly(t, ctx, cm) + }, + ExpectedErrorStrings: []string{"Invalid TTL"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "CreateSandbox", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCreateCommand(cf) + }) +} + +func Test_getEpochFromDate(t *testing.T) { + validDate := time.Now().UTC().Add(7 * 24 * time.Hour).Truncate(24 * time.Hour) + validDateStr := validDate.Format("2006-01-02") + validEpoch := validDate.Unix() + + testutil.TableTestCommand(t, testutil.CommandTests{ + "valid": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "date-box", + "--domain", "date-box", + "--password", "pass", + "--archive-date", validDateStr, + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + setupCreateMocks(t, ctx, cm, "date-box", "date-box", "pass", validEpoch, false) + }, + ExpectedStdoutOutputs: []string{"Sandbox Created"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "date-box", "date-box", "pass", "", "", 0, "", validEpoch, false) + }, + }, + "invalid format": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "date-box", + "--domain", "date-box", + "--password", "pass", + "--archive-date", "12-31-2025", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + setupCreateAuthOnly(t, ctx, cm) + }, + ExpectedErrorStrings: []string{"Invalid archive date"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "CreateSandbox", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, + "invalid date": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "date-box", + "--domain", "date-box", + "--password", "pass", + "--archive-date", "not-a-date", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + setupCreateAuthOnly(t, ctx, cm) + }, + ExpectedErrorStrings: []string{"Invalid archive date"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "CreateSandbox", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, + "date in past": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "date-box", + "--domain", "date-box", + "--password", "pass", + "--archive-date", "2020-01-01", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + setupCreateAuthOnly(t, ctx, cm) + }, + ExpectedErrorStrings: []string{"Archive date must be in the future"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "CreateSandbox", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCreateCommand(cf) + }) +} + +func Test_getTemplateID(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "valid template name": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tpl-box", + "--domain", "tpl-box", + "--password", "pass", + "--template", "default", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "tpl-box", "tpl-box", "pass", "", "", 1, "", int64(0), false). + Return("T333", "https://tpl-box.slack.com", nil) + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"Sandbox Created"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "tpl-box", "tpl-box", "pass", "", "", 1, "", int64(0), false) + }, + }, + "integer value also accepted": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tpl-box", + "--domain", "tpl-box", + "--password", "pass", + "--template", "1", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "tpl-box", "tpl-box", "pass", "", "", 1, "", int64(0), false). + Return("T333", "https://tpl-box.slack.com", nil) + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"Sandbox Created"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "tpl-box", "tpl-box", "pass", "", "", 1, "", int64(0), false) + }, + }, + "invalid template name fails": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tpl-box", + "--domain", "tpl-box", + "--password", "pass", + "--template", "invalid", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + setupCreateAuthOnly(t, ctx, cm) + }, + ExpectedErrorStrings: []string{"Invalid template"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "CreateSandbox", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCreateCommand(cf) + }) +} + +func Test_domainFromName(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "handles invalid URL characters": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "-Hello_World 123-", + "--password", "pass", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + setupCreateMocks(t, ctx, cm, "-Hello_World 123-", "hello-world-123", "pass", int64(0), false) + }, + ExpectedStdoutOutputs: []string{"Sandbox Created"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "-Hello_World 123-", "hello-world-123", "pass", "", "", 0, "", int64(0), false) + }, + }, + "empty": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "", + "--password", "pass", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + setupCreateAuthOnly(t, ctx, cm) + cm.IO.On("InputPrompt", mock.Anything, "Enter a name for the sandbox", mock.Anything).Return("", nil) + }, + ExpectedErrorStrings: []string{"Provide a valid domain name"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "CreateSandbox", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCreateCommand(cf) + }) +} diff --git a/cmd/sandbox/delete.go b/cmd/sandbox/delete.go new file mode 100644 index 00000000..6f38f740 --- /dev/null +++ b/cmd/sandbox/delete.go @@ -0,0 +1,124 @@ +// 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 sandbox + +import ( + "fmt" + + "github.com/slackapi/slack-cli/internal/iostreams" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +type deleteFlags struct { + sandboxID string + force bool +} + +var deleteCmdFlags deleteFlags + +func NewDeleteCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete [flags]", + Short: "Delete a developer sandbox", + Long: `Permanently delete a sandbox and all of its data`, + Example: style.ExampleCommandsf([]style.ExampleCommand{ + {Command: "sandbox delete --sandbox-id E0123456", Meaning: "Delete a sandbox identified by its team ID"}, + }), + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + return requireSandboxExperiment(clients) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runDeleteCommand(cmd, clients) + }, + } + + cmd.Flags().StringVar(&deleteCmdFlags.sandboxID, "sandbox-id", "", "Sandbox team ID to delete") + cmd.Flags().BoolVar(&deleteCmdFlags.force, "force", false, "Skip confirmation prompt") + + return cmd +} + +func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { + ctx := cmd.Context() + + auth, err := getSandboxAuth(ctx, clients) + if err != nil { + return err + } + + sandboxID := deleteCmdFlags.sandboxID + if sandboxID == "" { + sandboxID, err = clients.IO.InputPrompt( + ctx, + "Enter the ID of the sandbox", + iostreams.InputPromptConfig{ + Required: true, + }, + ) + if err != nil { + return err + } + } + + skipConfirm := deleteCmdFlags.force + if !skipConfirm { + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "warning", + Text: style.Bold(" Danger zone"), + Secondary: []string{ + fmt.Sprintf("Sandbox (%s) and all of its data will be permanently deleted", sandboxID), + "This cannot be undone", + }, + })) + + proceed, err := clients.IO.ConfirmPrompt(ctx, "Are you sure you want to delete the sandbox?", false) + if err != nil { + if slackerror.Is(err, slackerror.ErrProcessInterrupted) { + clients.IO.SetExitCode(iostreams.ExitCancel) + } + return err + } + if !proceed { + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "thumbs_up", + Text: "Deletion cancelled", + })) + return nil + } + } + + if err := clients.API().DeleteSandbox(ctx, auth.Token, sandboxID); err != nil { + return err + } + + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "white_check_mark", + Text: "Sandbox Deleted", + Secondary: []string{ + "Sandbox " + sandboxID + " has been permanently deleted", + }, + })) + + err = printSandboxes(cmd, clients, auth.Token, auth) + if err != nil { + return err + } + + return nil +} diff --git a/cmd/sandbox/delete_test.go b/cmd/sandbox/delete_test.go new file mode 100644 index 00000000..c5b0a33f --- /dev/null +++ b/cmd/sandbox/delete_test.go @@ -0,0 +1,183 @@ +// 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 sandbox + +import ( + "context" + "errors" + "testing" + + "github.com/slackapi/slack-cli/internal/experiment" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" +) + +func TestDeleteCommand(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "delete success": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--sandbox-id", "T123", + "--force", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken, UserID: "U123"}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("DeleteSandbox", mock.Anything, testToken, "T123").Return(nil) + cm.API.On("ListSandboxes", mock.Anything, testToken, "").Return([]types.Sandbox{}, nil) + cm.API.On("UsersInfo", mock.Anything, mock.Anything, mock.Anything).Return(&types.UserInfo{Profile: types.UserProfile{}}, nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"Sandbox Deleted", "T123", "No sandboxes found"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Auth.AssertCalled(t, "AuthWithToken", mock.Anything, "xoxb-test-token") + cm.API.AssertCalled(t, "DeleteSandbox", mock.Anything, "xoxb-test-token", "T123") + cm.API.AssertCalled(t, "ListSandboxes", mock.Anything, "xoxb-test-token", "") + }, + }, + "delete with remaining sandboxes": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--sandbox-id", "T123", + "--force", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken, UserID: "U123"}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("DeleteSandbox", mock.Anything, testToken, "T123").Return(nil) + sandboxes := []types.Sandbox{ + { + TeamID: "T456", + Name: "other-sandbox", + Domain: "other-sandbox", + Status: "active", + DateCreated: 1700000000, + DateArchived: 0, + }, + } + cm.API.On("ListSandboxes", mock.Anything, testToken, "").Return(sandboxes, nil) + cm.API.On("UsersInfo", mock.Anything, mock.Anything, mock.Anything).Return(&types.UserInfo{Profile: types.UserProfile{}}, nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"Sandbox Deleted", "T123", "other-sandbox", "T456"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "DeleteSandbox", mock.Anything, "xoxb-test-token", "T123") + cm.API.AssertCalled(t, "ListSandboxes", mock.Anything, "xoxb-test-token", "") + }, + }, + "deletion cancelled": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--sandbox-id", "T123", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to delete the sandbox?", false).Return(false, nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"Deletion cancelled"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "ConfirmPrompt", mock.Anything, "Are you sure you want to delete the sandbox?", false) + cm.API.AssertNotCalled(t, "DeleteSandbox", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "delete confirmation proceeds": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--sandbox-id", "E0123456", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken, UserID: "U123"}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to delete the sandbox?", false).Return(true, nil) + cm.API.On("DeleteSandbox", mock.Anything, testToken, "E0123456").Return(nil) + cm.API.On("ListSandboxes", mock.Anything, testToken, "").Return([]types.Sandbox{}, nil) + cm.API.On("UsersInfo", mock.Anything, mock.Anything, mock.Anything).Return(&types.UserInfo{Profile: types.UserProfile{}}, nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"Sandbox Deleted", "E0123456"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "ConfirmPrompt", mock.Anything, "Are you sure you want to delete the sandbox?", false) + cm.API.AssertCalled(t, "DeleteSandbox", mock.Anything, "xoxb-test-token", "E0123456") + }, + }, + "delete API error": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--sandbox-id", "T123", + "--force", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("DeleteSandbox", mock.Anything, testToken, "T123").Return(errors.New("api_error")) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedErrorStrings: []string{"api_error"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "DeleteSandbox", mock.Anything, "xoxb-test-token", "T123") + }, + }, + "experiment required": { + CmdArgs: []string{ + "--sandbox-id", "T123", + "--force", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + }, + ExpectedErrorStrings: []string{"sandbox"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "DeleteSandbox", mock.Anything, mock.Anything, mock.Anything) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewDeleteCommand(cf) + }) +} diff --git a/cmd/sandbox/list.go b/cmd/sandbox/list.go index 970f70bc..e35359bd 100644 --- a/cmd/sandbox/list.go +++ b/cmd/sandbox/list.go @@ -92,7 +92,7 @@ func printSandboxes(cmd *cobra.Command, clients *shared.ClientFactory, token str section := style.TextSection{ Emoji: "beach_with_umbrella", - Text: " Developer Sandboxes", + Text: "Developer Sandboxes", } // Some users' logins may not include the scope needed to access the email address from the `users.info` method, so it may not be set @@ -110,10 +110,10 @@ func printSandboxes(cmd *cobra.Command, clients *shared.ClientFactory, token str timeFormat := "2006-01-02" // We only support the granularity of the day for now, rather than a more precise datetime for _, s := range sandboxes { - clients.IO.PrintInfo(ctx, false, " %s (%s)", style.Bold(s.SandboxName), s.SandboxTeamID) + clients.IO.PrintInfo(ctx, false, " %s (%s)", style.Bold(s.Name), s.TeamID) - if s.SandboxDomain != "" { - clients.IO.PrintInfo(ctx, false, " %s", style.Secondary(fmt.Sprintf("URL: https://%s.slack.com", s.SandboxDomain))) + if s.Domain != "" { + clients.IO.PrintInfo(ctx, false, " %s", style.Secondary(fmt.Sprintf("URL: https://%s.slack.com", s.Domain))) } if s.Status != "" { diff --git a/cmd/sandbox/list_test.go b/cmd/sandbox/list_test.go index ef099982..d0148af8 100644 --- a/cmd/sandbox/list_test.go +++ b/cmd/sandbox/list_test.go @@ -58,12 +58,12 @@ func TestListCommand(t *testing.T) { cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") sandboxes := []types.Sandbox{ { - SandboxTeamID: "T123", - SandboxName: "my-sandbox", - SandboxDomain: "my-sandbox", - Status: "active", - DateCreated: 1700000000, - DateArchived: 0, + TeamID: "T123", + Name: "my-sandbox", + Domain: "my-sandbox", + Status: "active", + DateCreated: 1700000000, + DateArchived: 0, }, } cm.API.On("ListSandboxes", mock.Anything, testToken, "").Return(sandboxes, nil) @@ -87,12 +87,12 @@ func TestListCommand(t *testing.T) { cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") sandboxes := []types.Sandbox{ { - SandboxTeamID: "T456", - SandboxName: "old-sandbox", - SandboxDomain: "old-sandbox", - Status: "archived", - DateCreated: 1700000000, - DateArchived: 1710000000, + TeamID: "T456", + Name: "old-sandbox", + Domain: "old-sandbox", + Status: "archived", + DateCreated: 1700000000, + DateArchived: 1710000000, }, } cm.API.On("ListSandboxes", mock.Anything, testToken, "").Return(sandboxes, nil) diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index a149c3f5..27ed0cfb 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -48,6 +48,8 @@ New to the Developer Program? Sign up at }, } + cmd.AddCommand(NewCreateCommand(clients)) + cmd.AddCommand(NewDeleteCommand(clients)) cmd.AddCommand(NewListCommand(clients)) return cmd @@ -75,6 +77,8 @@ func getSandboxAuth(ctx context.Context, clients *shared.ClientFactory) (*types. } // Prompt the user to select a team to use for authentication + // TODO(experiment:charm): Change this to prompt "help" message once charm is stable + clients.IO.PrintInfo(ctx, false, "%s", style.Secondary("Choose a Slack team where your email address matches your Slack developer account")) auth, err := prompts.PromptTeamSlackAuth(ctx, clients, "Select a team for authentication") if err != nil { return nil, err diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index 76d784ae..c68b8953 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -215,6 +215,16 @@ func (m *APIMock) FunctionDistributionRemoveUsers(ctx context.Context, callbackI // SandboxClient +func (m *APIMock) CreateSandbox(ctx context.Context, token, name, domain, password, locale, owningOrgID string, templateID int, eventCode string, archiveDate int64, isPartner bool) (string, string, error) { + args := m.Called(ctx, token, name, domain, password, locale, owningOrgID, templateID, eventCode, archiveDate, isPartner) + return args.String(0), args.String(1), args.Error(2) +} + +func (m *APIMock) DeleteSandbox(ctx context.Context, token, sandboxID string) error { + args := m.Called(ctx, token, sandboxID) + return args.Error(0) +} + func (m *APIMock) ListSandboxes(ctx context.Context, token string, filter string) ([]types.Sandbox, error) { args := m.Called(ctx, token, filter) return args.Get(0).([]types.Sandbox), args.Error(1) diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go index d6eed6b7..2bd88915 100644 --- a/internal/api/sandbox.go +++ b/internal/api/sandbox.go @@ -17,6 +17,7 @@ package api import ( "context" "net/url" + "strconv" "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/internal/goutils" @@ -24,18 +25,107 @@ import ( "github.com/slackapi/slack-cli/internal/slackerror" ) -const sandboxListMethod = "developer.sandbox.list" +const ( + sandboxCreateMethod = "enterprise.signup.createDevOrg" + sandboxDeleteMethod = "developer.sandbox.delete" + sandboxListMethod = "developer.sandbox.list" +) // SandboxClient is the interface for sandbox-related API calls type SandboxClient interface { + CreateSandbox(ctx context.Context, token, name, domain, password, locale, owningOrgID string, templateID int, eventCode string, archiveDate int64, isPartner bool) (teamID, sandboxURL string, err error) + DeleteSandbox(ctx context.Context, token, sandboxID string) error ListSandboxes(ctx context.Context, token string, filter string) ([]types.Sandbox, error) } +type createSandboxResponse struct { + extendedBaseResponse + TeamID string `json:"team_id"` + UserID string `json:"user_id"` + URL string `json:"url"` +} + type listSandboxesResponse struct { extendedBaseResponse Sandboxes []types.Sandbox `json:"sandboxes"` } +// CreateSandbox creates a new developer sandbox +func (c *Client) CreateSandbox(ctx context.Context, token, name, domain, password, locale, owningOrgID string, templateID int, eventCode string, archiveDate int64, isPartner bool) (teamID, sandboxURL string, err error) { + var span opentracing.Span + span, ctx = opentracing.StartSpanFromContext(ctx, "apiclient.CreateSandbox") + defer span.Finish() + + values := url.Values{} + values.Add("token", token) + values.Add("org_name", name) + values.Add("domain", domain) + values.Add("password", password) + if locale != "" { + values.Add("locale", locale) + } + if owningOrgID != "" { + values.Add("owning_org_id", owningOrgID) + } + if templateID != 0 { + values.Add("template_id", strconv.Itoa(templateID)) + } + if eventCode != "" { + values.Add("event_code", eventCode) + } + if archiveDate > 0 { + values.Add("archive_date", strconv.FormatInt(archiveDate, 10)) + } + if isPartner { + values.Add("is_partner", "true") + } + + b, err := c.postForm(ctx, sandboxCreateMethod, values) + if err != nil { + return "", "", errHTTPRequestFailed.WithRootCause(err) + } + + resp := createSandboxResponse{} + err = goutils.JSONUnmarshal(b, &resp) + if err != nil { + return "", "", errHTTPResponseInvalid.WithRootCause(err).AddAPIMethod(sandboxCreateMethod) + } + + if !resp.Ok { + return "", "", slackerror.NewAPIError(resp.Error, resp.Description, resp.Errors, sandboxCreateMethod) + } + + return resp.TeamID, resp.URL, nil +} + +// DeleteSandbox permanently deletes a developer sandbox +func (c *Client) DeleteSandbox(ctx context.Context, token, sandboxID string) error { + var span opentracing.Span + span, ctx = opentracing.StartSpanFromContext(ctx, "apiclient.DeleteSandbox") + defer span.Finish() + + values := url.Values{} + values.Add("token", token) + values.Add("sandbox_team_id", sandboxID) + + b, err := c.postForm(ctx, sandboxDeleteMethod, values) + if err != nil { + return errHTTPRequestFailed.WithRootCause(err) + } + + resp := extendedBaseResponse{} + err = goutils.JSONUnmarshal(b, &resp) + if err != nil { + return errHTTPResponseInvalid.WithRootCause(err).AddAPIMethod(sandboxDeleteMethod) + } + + if !resp.Ok { + return slackerror.NewAPIError(resp.Error, resp.Description, resp.Errors, sandboxDeleteMethod) + } + + return nil +} + // ListSandboxes returns all sandboxes owned by the Developer Account with an email address that matches the authenticated user func (c *Client) ListSandboxes(ctx context.Context, token string, status string) ([]types.Sandbox, error) { var span opentracing.Span diff --git a/internal/api/sandbox_test.go b/internal/api/sandbox_test.go new file mode 100644 index 00000000..1798f351 --- /dev/null +++ b/internal/api/sandbox_test.go @@ -0,0 +1,137 @@ +// 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 api + +import ( + "testing" + + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackcontext" + "github.com/stretchr/testify/require" +) + +func TestClient_CreateSandbox_Ok(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: sandboxCreateMethod, + Response: `{"ok":true,"team_id":"T123","user_id":"U123","url":"https://my-sandbox.slack.com"}`, + }) + defer teardown() + teamID, sandboxURL, err := c.CreateSandbox(ctx, "token", "My Sandbox", "my-sandbox", "secret", "", "", 0, "", 0, false) + require.NoError(t, err) + require.Equal(t, "T123", teamID) + require.Equal(t, "https://my-sandbox.slack.com", sandboxURL) +} + +func TestClient_CreateSandbox_WithOptionalParams(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: sandboxCreateMethod, + Response: `{"ok":true,"team_id":"T456","user_id":"U456","url":"https://other-sandbox.slack.com"}`, + }) + defer teardown() + teamID, sandboxURL, err := c.CreateSandbox(ctx, "token", "My Sandbox", "my-sandbox", "secret", "en-US", "O123", 1, "EVENT123", 1234567890, false) + require.NoError(t, err) + require.Equal(t, "T456", teamID) + require.Equal(t, "https://other-sandbox.slack.com", sandboxURL) +} + +func TestClient_CreateSandbox_CommonErrors(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + verifyCommonErrorCases(t, sandboxCreateMethod, func(c *Client) error { + _, _, err := c.CreateSandbox(ctx, "token", "name", "domain", "password", "", "", 0, "", 0, false) + return err + }) +} + +func TestClient_DeleteSandbox_Ok(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: sandboxDeleteMethod, + Response: `{"ok":true}`, + }) + defer teardown() + err := c.DeleteSandbox(ctx, "token", "T123") + require.NoError(t, err) +} + +func TestClient_DeleteSandbox_CommonErrors(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + verifyCommonErrorCases(t, sandboxDeleteMethod, func(c *Client) error { + return c.DeleteSandbox(ctx, "token", "T123") + }) +} + +func TestClient_ListSandboxes_Ok(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: sandboxListMethod, + Response: `{"ok":true,"sandboxes":[{"sandbox_team_id":"T1","sandbox_name":"Sandbox 1","sandbox_domain":"sb1","status":"active","date_created":123,"date_archived":0},{"sandbox_team_id":"T2","sandbox_name":"Sandbox 2","sandbox_domain":"sb2","status":"active","date_created":456,"date_archived":0}]}`, + }) + defer teardown() + sandboxes, err := c.ListSandboxes(ctx, "token", "") + require.NoError(t, err) + require.Len(t, sandboxes, 2) + require.Equal(t, "T1", sandboxes[0].TeamID) + require.Equal(t, "Sandbox 1", sandboxes[0].Name) + require.Equal(t, "sb1", sandboxes[0].Domain) + require.Equal(t, "active", sandboxes[0].Status) + require.Equal(t, "T2", sandboxes[1].TeamID) +} + +func TestClient_ListSandboxes_Empty(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: sandboxListMethod, + Response: `{"ok":true,"sandboxes":[]}`, + }) + defer teardown() + sandboxes, err := c.ListSandboxes(ctx, "token", "") + require.NoError(t, err) + require.Empty(t, sandboxes) +} + +func TestClient_ListSandboxes_NilSandboxesReturnsEmptySlice(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: sandboxListMethod, + Response: `{"ok":true}`, + }) + defer teardown() + sandboxes, err := c.ListSandboxes(ctx, "token", "") + require.NoError(t, err) + require.Equal(t, []types.Sandbox{}, sandboxes) +} + +func TestClient_ListSandboxes_WithStatusFilter(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + c, teardown := NewFakeClient(t, FakeClientParams{ + ExpectedMethod: sandboxListMethod, + Response: `{"ok":true,"sandboxes":[{"sandbox_team_id":"T1","sandbox_name":"Archived","sandbox_domain":"arch","status":"archived","date_created":100,"date_archived":200}]}`, + }) + defer teardown() + sandboxes, err := c.ListSandboxes(ctx, "token", "archived") + require.NoError(t, err) + require.Len(t, sandboxes, 1) + require.Equal(t, "archived", sandboxes[0].Status) +} + +func TestClient_ListSandboxes_CommonErrors(t *testing.T) { + ctx := slackcontext.MockContext(t.Context()) + verifyCommonErrorCases(t, sandboxListMethod, func(c *Client) error { + _, err := c.ListSandboxes(ctx, "token", "") + return err + }) +} diff --git a/internal/shared/types/sandbox.go b/internal/shared/types/sandbox.go index 750093f4..7ec1033b 100644 --- a/internal/shared/types/sandbox.go +++ b/internal/shared/types/sandbox.go @@ -14,12 +14,12 @@ package types -// Sandbox represents a Slack Developer Sandbox from the developer.sandbox.list API. +// Sandbox represents a Slack Developer Sandbox type Sandbox struct { - DateArchived int64 `json:"date_archived"` // When the developer sandbox is or will be archived, as epoch seconds - DateCreated int64 `json:"date_created"` // When the developer sandbox was created, as epoch seconds - SandboxDomain string `json:"sandbox_domain"` // Domain of the developer sandbox - SandboxName string `json:"sandbox_name"` // Name of the developer sandbox - SandboxTeamID string `json:"sandbox_team_id"` // Encoded team ID of the developer sandbox - Status string `json:"status"` // Status of the developer sandbox: Active or Archived + DateArchived int64 `json:"date_archived"` + DateCreated int64 `json:"date_created"` + Domain string `json:"sandbox_domain"` + Name string `json:"sandbox_name"` + TeamID string `json:"sandbox_team_id"` + Status string `json:"status"` } diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index a9d89ea3..507b75dd 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -161,6 +161,7 @@ const ( ErrInvalidTriggerEventType = "invalid_trigger_event_type" ErrInvalidTriggerInputs = "invalid_trigger_inputs" ErrInvalidTriggerType = "invalid_trigger_type" + ErrInvalidSandboxTemplateID = "invalid_template_id" ErrInvalidUserID = "invalid_user_id" ErrInvalidWebhookConfig = "invalid_webhook_config" ErrInvalidWebhookSchemaRef = "invalid_webhook_schema_ref" @@ -268,6 +269,10 @@ const ( ErrUserRemovedFromTeam = "user_removed_from_team" ErrWorkflowNotFound = "workflow_not_found" ErrYaml = "yaml_error" + ErrSandboxDomainTaken = "domain_taken" + ErrAtActiveSandboxLimit = "at_active_sandbox_limit" + ErrInvalidSandboxTeamID = "invalid_sandbox_team_id" + ErrInvalidSandboxArchiveTTL = "invalid_archive_ttl" ) var ErrorCodeMap = map[string]Error{ @@ -1043,6 +1048,11 @@ Otherwise start your app for local development with: %s`, Message: "The provided trigger type is not recognized", }, + ErrInvalidSandboxTemplateID: { + Code: ErrInvalidSandboxTemplateID, + Message: "The provided sandbox template value is invalid", + }, + ErrInvalidUserID: { Code: ErrInvalidUserID, Message: "A value passed as a user_id is invalid", @@ -1614,4 +1624,26 @@ Otherwise start your app for local development with: %s`, Code: ErrYaml, Message: "An error occurred while parsing the app manifest YAML file", }, + + ErrSandboxDomainTaken: { + Code: ErrSandboxDomainTaken, + Message: "This domain has been claimed by another sandbox", + }, + + ErrAtActiveSandboxLimit: { + Code: ErrAtActiveSandboxLimit, + Message: "You've reached the maximum number of active sandboxes", + }, + + ErrInvalidSandboxTeamID: { + Code: ErrInvalidSandboxTeamID, + Message: "The provided sandbox team ID is invalid", + Remediation: fmt.Sprintf("List your sandboxes with the %s command to find the ID", style.Commandf("sandbox list", false)), + }, + + ErrInvalidSandboxArchiveTTL: { + Code: ErrInvalidSandboxArchiveTTL, + Message: "Invalid TTL", + Remediation: "Use days (1d), weeks (2w), or months (3mo); min 1d, max 6mo", + }, } diff --git a/internal/style/style.go b/internal/style/style.go index 1eab0ba1..2d56d9bd 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -116,6 +116,8 @@ func Emoji(alias string) string { padding = " " case "wastebasket": padding = " " + case "beach_with_umbrella": + padding = " " } return emoji.Sprint(":"+alias+":") + padding