diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go new file mode 100644 index 00000000..2286fe51 --- /dev/null +++ b/cmd/sandbox/create.go @@ -0,0 +1,238 @@ +// 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 ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "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 + demoIDs []string + eventCode string + archiveTTL string // TTL duration, e.g. 1d, 2h + archiveDate string // explicit date yyyy-mm-dd + output string +} + +var createCmdFlags createFlags + +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 (e.g., pizzaknifefight). If not provided, derived from org name") + cmd.Flags().StringVar(&createCmdFlags.password, "password", "", "Password used to log into the sandbox") + cmd.Flags().StringVar(&createCmdFlags.locale, "locale", "", "Locale (IANA timezone, e.g. America/New_York) for sandbox and for interpreting archive dates") + cmd.Flags().StringVar(&createCmdFlags.template, "template", "", "Template ID for pre-defined data to preload") + cmd.Flags().StringSliceVar(&createCmdFlags.demoIDs, "demo-ids", nil, "Demo IDs to preload in the sandbox") + cmd.Flags().StringVar(&createCmdFlags.eventCode, "event-code", "", "Event code for the sandbox") + cmd.Flags().StringVar(&createCmdFlags.archiveTTL, "archive-ttl", "", "Time-to-live duration; sandbox will be archived after this period (e.g., 2h, 1d, 7d)") + cmd.Flags().StringVar(&createCmdFlags.archiveDate, "archive-date", "", "Explicit archive date in yyyy-mm-dd format. Cannot be used with --archive") + cmd.Flags().StringVar(&createCmdFlags.output, "output", "text", "Output format: json, text") + + // 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") + + if err := cmd.MarkFlagRequired("name"); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired("domain"); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired("password"); err != nil { + panic(err) + } + + return cmd +} + +func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { + ctx := cmd.Context() + + auth, err := getSandboxAuth(ctx, clients) + if err != nil { + return err + } + + domain := createCmdFlags.domain + if domain == "" { + domain = slugFromsandboxName(createCmdFlags.name) + } + + if createCmdFlags.archiveTTL != "" && createCmdFlags.archiveDate != "" { + return slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("Cannot use both --archive-ttl and --archive-date"). + WithRemediation("Use only one: --archive-ttl for TTL (e.g., 3d) or --archive-date for a specific date (yyyy-mm-dd)") + } + + 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, createCmdFlags.locale) + if err != nil { + return err + } + } + + teamID, sandboxURL, err := clients.API().CreateSandbox(ctx, auth.Token, + createCmdFlags.name, + domain, + createCmdFlags.password, + createCmdFlags.locale, + createCmdFlags.owningOrgID, + createCmdFlags.template, + createCmdFlags.eventCode, + archiveEpochDatetime, + ) + if err != nil { + return err + } + + switch createCmdFlags.output { + case "json": + encoder := json.NewEncoder(clients.IO.WriteOut()) + encoder.SetIndent("", " ") + if err := encoder.Encode(map[string]string{"team_id": teamID, "url": sandboxURL}); err != nil { + return err + } + default: + printCreateSuccess(cmd, clients, teamID, sandboxURL) + } + + return nil +} + +// getEpochFromTTL parses a time-to-live string (e.g., "24h", "1d", "7d") and returns the Unix epoch +// when the sandbox will be archived. Supports Go duration format (h, m, s) and "Nd" for days. +// The value cannot exceed 6 months. +func getEpochFromTTL(ttl string) (int64, error) { + var d time.Duration + if strings.HasSuffix(strings.ToLower(ttl), "d") { + numStr := strings.TrimSuffix(strings.ToLower(ttl), "d") + n, err := strconv.Atoi(numStr) + if err != nil { + return 0, slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("Invalid TTL: %q", ttl). + WithRemediation("Use a duration like 2h, 1d, or 7d") + } + d = time.Duration(n) * 24 * time.Hour + } else { + var err error + d, err = time.ParseDuration(ttl) + if err != nil { + return 0, slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("Invalid TTL: %q", ttl). + WithRemediation("Use a duration like 2h, 1d, or 7d") + } + } + return time.Now().Add(d).Unix(), nil +} + +// getEpochFromDate parses a date in yyyy-mm-dd format and returns the Unix epoch at start of that day +// in the given locale (IANA timezone). If locale is empty, UTC is used. +func getEpochFromDate(dateStr string, locale string) (int64, error) { + loc := time.UTC + if locale != "" { + var err error + loc, err = time.LoadLocation(locale) + if err != nil { + return 0, slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("Invalid locale: %q", locale). + WithRemediation("Use an IANA timezone (e.g., America/New_York, Europe/London)") + } + } + dateFormat := "2006-01-02" + t, err := time.ParseInLocation(dateFormat, dateStr, loc) + if err != nil { + return 0, slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("Invalid archive date: %q", dateStr). + WithRemediation("Use yyyy-mm-dd format (e.g., 2025-12-31)") + } + return t.Unix(), nil +} + +// slugFromsandboxName derives a domain-safe slug from org name (lowercase, alphanumeric + hyphens). +func slugFromsandboxName(name string) string { + var b []byte + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b = append(b, byte(r)) + } else if r >= 'A' && r <= 'Z' { + b = append(b, byte(r+32)) + } else if r == ' ' || r == '-' || r == '_' { + if len(b) > 0 && b[len(b)-1] != '-' { + b = append(b, '-') + } + } + } + // Trim leading/trailing hyphens + for len(b) > 0 && b[0] == '-' { + b = b[1:] + } + for len(b) > 0 && b[len(b)-1] == '-' { + b = b[:len(b)-1] + } + if len(b) == 0 { + return "sandbox" + } + return string(b) +} + +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), + }, + })) +} diff --git a/cmd/sandbox/create_test.go b/cmd/sandbox/create_test.go new file mode 100644 index 00000000..38371f01 --- /dev/null +++ b/cmd/sandbox/create_test.go @@ -0,0 +1,321 @@ +// 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/assert" + "github.com/stretchr/testify/mock" +) + +func TestCreateCommand(t *testing.T) { + 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", "", "", "", "", int64(0)). + 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", "", "", "", "", int64(0)) + }, + }, + "create with JSON output": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "json-box", + "--domain", "json-box", + "--password", "secret", + "--output", "json", + }, + 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, "json-box", "json-box", "secret", "", "", "", "", int64(0)). + Return("T456", "https://json-box.slack.com", nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{`"team_id": "T456"`, `"url": "https://json-box.slack.com"`}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "json-box", "json-box", "secret", "", "", "", "", int64(0)) + }, + }, + "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", "", "", "", "", int64(0)). + 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", "", "", "", "", int64(0)) + }, + }, + "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", "24h", + }, + 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", "", "", "", "", mock.MatchedBy(func(v int64) bool { return v > 0 })). + 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", "", "", "", "", mock.MatchedBy(func(v int64) bool { return v > 0 })) + }, + }, + "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", "", "", "", "", int64(0)). + 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 archive-date": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "date-box", + "--domain", "date-box", + "--password", "pass", + "--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.API.On("CreateSandbox", mock.Anything, testToken, "date-box", "date-box", "pass", "", "", "", "", int64(1767139200)). + 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", "", "", "", "", int64(1767139200)) + }, + }, + "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) + }, + }, + "invalid archive value": { + 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) + }, + }, + "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) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCreateCommand(cf) + }) +} + +func Test_getEpochFromTTL(t *testing.T) { + tests := []struct { + name string + ttl string + wantErr bool + }{ + {"24h", "24h", false}, + {"1d", "1d", false}, + {"7d", "7d", false}, + {"invalid", "invalid", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getEpochFromTTL(tt.ttl) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Greater(t, got, int64(0), "archive date should be in the future") + }) + } +} + +func Test_getEpochFromDate(t *testing.T) { + tests := []struct { + name string + dateStr string + locale string + want int64 + wantErr bool + }{ + {"valid UTC", "2025-12-31", "", 1767139200, false}, // 2025-12-31 00:00:00 UTC + {"valid with locale", "2025-12-31", "America/New_York", 1767157200, false}, // 2025-12-31 00:00:00 EST (UTC-5) + {"invalid format", "12-31-2025", "", 0, true}, + {"invalid date", "not-a-date", "", 0, true}, + {"invalid locale", "2025-12-31", "Invalid/Timezone", 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getEpochFromDate(tt.dateStr, tt.locale) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_slugFromsandboxName(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"simple", "test-box", "test-box"}, + {"spaces", "My Test Box", "my-test-box"}, + {"uppercase", "MyBox", "mybox"}, + {"mixed", "Hello_World 123", "hello-world-123"}, + {"hyphens", "a--b", "a-b"}, + {"leading trailing", "-test-", "test"}, + {"empty", "", "sandbox"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := slugFromsandboxName(tt.in) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/sandbox/delete.go b/cmd/sandbox/delete.go new file mode 100644 index 00000000..62976218 --- /dev/null +++ b/cmd/sandbox/delete.go @@ -0,0 +1,115 @@ +// 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 + yes 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") + + if err := cmd.MarkFlagRequired("sandbox-id"); err != nil { + panic(err) + } + + return cmd +} + +func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { + ctx := cmd.Context() + + auth, err := getSandboxAuth(ctx, clients) + if err != nil { + return err + } + + skipConfirm := deleteCmdFlags.force || deleteCmdFlags.yes + 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", deleteCmdFlags.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, deleteCmdFlags.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 " + deleteCmdFlags.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..975051e1 --- /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{ + { + SandboxTeamID: "T456", + SandboxName: "other-sandbox", + SandboxDomain: "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/sandbox.go b/cmd/sandbox/sandbox.go index a149c3f5..0c23a474 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -49,6 +49,8 @@ New to the Developer Program? Sign up at } cmd.AddCommand(NewListCommand(clients)) + cmd.AddCommand(NewCreateCommand(clients)) + cmd.AddCommand(NewDeleteCommand(clients)) return cmd } diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index 76d784ae..171753c1 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -220,6 +220,16 @@ func (m *APIMock) ListSandboxes(ctx context.Context, token string, filter string return args.Get(0).([]types.Sandbox), args.Error(1) } +func (m *APIMock) CreateSandbox(ctx context.Context, token, name, domain, password, locale, owningOrgID, template, eventCode string, archiveDate int64) (string, string, error) { + args := m.Called(ctx, token, name, domain, password, locale, owningOrgID, template, eventCode, archiveDate) + 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) +} + // DatastoresClient func (m *APIMock) AppsDatastorePut(ctx context.Context, token string, request types.AppDatastorePut) (types.AppDatastorePutResult, error) { diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go index d6eed6b7..3e13a0b7 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,11 +25,17 @@ import ( "github.com/slackapi/slack-cli/internal/slackerror" ) -const sandboxListMethod = "developer.sandbox.list" +const ( + sandboxListMethod = "developer.sandbox.list" + sandboxCreateMethod = "developer.sandbox.create" + sandboxDeleteMethod = "developer.sandbox.delete" +) // SandboxClient is the interface for sandbox-related API calls type SandboxClient interface { ListSandboxes(ctx context.Context, token string, filter string) ([]types.Sandbox, error) + CreateSandbox(ctx context.Context, token, name, domain, password, locale, owningOrgID, template, eventCode string, archiveDate int64) (teamID, sandboxURL string, err error) + DeleteSandbox(ctx context.Context, token, sandboxID string) error } type listSandboxesResponse struct { @@ -69,3 +76,83 @@ func (c *Client) ListSandboxes(ctx context.Context, token string, status string) return resp.Sandboxes, nil } + +type createSandboxResponse struct { + extendedBaseResponse + TeamID string `json:"team_id"` + UserID string `json:"user_id"` + URL string `json:"url"` +} + +// CreateSandbox creates a new developer sandbox +func (c *Client) CreateSandbox(ctx context.Context, token, name, domain, password, locale, owningOrgID, template, eventCode string, archiveDate int64) (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("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 template != "" { + values.Add("template", template) + } + if eventCode != "" { + values.Add("event_code", eventCode) + } + if archiveDate > 0 { + values.Add("archive_date", strconv.FormatInt(archiveDate, 10)) + } + + 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 +} diff --git a/internal/shared/types/sandbox.go b/internal/shared/types/sandbox.go index 750093f4..d214e1f4 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"` + SandboxDomain string `json:"sandbox_domain"` + SandboxName string `json:"sandbox_name"` + SandboxTeamID string `json:"sandbox_team_id"` + Status string `json:"status"` } diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index a9d89ea3..360572bd 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -268,6 +268,7 @@ const ( ErrUserRemovedFromTeam = "user_removed_from_team" ErrWorkflowNotFound = "workflow_not_found" ErrYaml = "yaml_error" + ErrSandboxDomainTaken = "domain_taken" ) var ErrorCodeMap = map[string]Error{ @@ -1614,4 +1615,9 @@ 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", + }, }