From c0b11c58a93bbcff50397d9893c64ec172b45617 Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Wed, 25 Feb 2026 21:55:16 -0500 Subject: [PATCH 01/20] Sandbox management commands --- cmd/app/delete.go | 2 +- cmd/root.go | 2 + cmd/sandbox/create.go | 209 ++++++++++++++++++++++++++++++ cmd/sandbox/delete.go | 114 ++++++++++++++++ cmd/sandbox/list.go | 129 ++++++++++++++++++ cmd/sandbox/sandbox.go | 58 +++++++++ cmd/sandbox/token.go | 107 +++++++++++++++ internal/api/api_mock.go | 17 +++ internal/api/sandbox.go | 173 +++++++++++++++++++++++++ internal/api/types.go | 1 + internal/experiment/experiment.go | 4 + internal/shared/types/sandbox.go | 47 +++++++ internal/shared/types/user.go | 1 + internal/slackerror/errors.go | 8 +- 14 files changed, 870 insertions(+), 2 deletions(-) create mode 100644 cmd/sandbox/create.go create mode 100644 cmd/sandbox/delete.go create mode 100644 cmd/sandbox/list.go create mode 100644 cmd/sandbox/sandbox.go create mode 100644 cmd/sandbox/token.go create mode 100644 internal/api/sandbox.go create mode 100644 internal/shared/types/sandbox.go diff --git a/cmd/app/delete.go b/cmd/app/delete.go index 2ae6ae43..27c62981 100644 --- a/cmd/app/delete.go +++ b/cmd/app/delete.go @@ -128,7 +128,7 @@ func newDeleteLogger(clients *shared.ClientFactory, cmd *cobra.Command, envName func confirmDeletion(ctx context.Context, IO iostreams.IOStreamer, app prompts.SelectedApp) (bool, error) { IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ Emoji: "warning", - Text: style.Bold("Danger zone"), + Text: style.Bold(" Danger zone"), Secondary: []string{ fmt.Sprintf("App (%s) will be permanently deleted", app.App.AppID), "All triggers, workflows, and functions will be deleted", diff --git a/cmd/root.go b/cmd/root.go index 7b78e246..70880d68 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -40,6 +40,7 @@ import ( "github.com/slackapi/slack-cli/cmd/openformresponse" "github.com/slackapi/slack-cli/cmd/platform" "github.com/slackapi/slack-cli/cmd/project" + "github.com/slackapi/slack-cli/cmd/sandbox" "github.com/slackapi/slack-cli/cmd/triggers" "github.com/slackapi/slack-cli/cmd/upgrade" versioncmd "github.com/slackapi/slack-cli/cmd/version" @@ -175,6 +176,7 @@ func Init(ctx context.Context) (*cobra.Command, *shared.ClientFactory) { openformresponse.NewCommand(clients), platform.NewCommand(clients), project.NewCommand(clients), + sandbox.NewCommand(clients), triggers.NewCommand(clients), upgrade.NewCommand(clients), versioncmd.NewCommand(clients), diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go new file mode 100644 index 00000000..7aea6286 --- /dev/null +++ b/cmd/sandbox/create.go @@ -0,0 +1,209 @@ +// 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/shared/types" + "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 + ttl string + autoLogin bool + output string + token string +} + +var createCmdFlags createFlags + +func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "create [flags]", + Short: "Create a new sandbox", + Long: `Create a new Slack developer sandbox. + +Provisions a new sandbox. Domain is derived from org name if --domain is not provided.`, + Example: style.ExampleCommandsf([]style.ExampleCommand{ + {Command: "sandbox create --name test-box", Meaning: "Create a sandbox named test-box"}, + {Command: "sandbox create --name test-box --password mypass --owning-org-id E12345", Meaning: "Create a sandbox with login password and owning org"}, + {Command: "sandbox create --name test-box --domain test-box --ttl 24h --output json", Meaning: "Create an ephemeral sandbox for CI/CD with JSON output"}, + }), + 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.owningOrgID, "owning-org-id", "", "Enterprise team ID that manages your developer account, if applicable") + cmd.Flags().StringVar(&createCmdFlags.template, "template", "", "Template ID for pre-defined data to preload") + cmd.Flags().StringVar(&createCmdFlags.eventCode, "event-code", "", "Event code for the sandbox") + cmd.Flags().StringVar(&createCmdFlags.ttl, "ttl", "", "Time-to-live duration; sandbox will be archived after this period (e.g., 2h, 1d, 7d)") + cmd.Flags().StringVar(&createCmdFlags.output, "output", "text", "Output format: json, text") + cmd.Flags().StringVar(&createCmdFlags.token, "token", "", "Service account token for CI/CD authentication") + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("domain") + cmd.MarkFlagRequired("password") + + return cmd +} + +func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { + ctx := cmd.Context() + + token, err := getSandboxToken(ctx, clients, createCmdFlags.token) + if err != nil { + return err + } + + domain := createCmdFlags.domain + if domain == "" { + domain = slugFromsandboxName(createCmdFlags.name) + } + + archiveDate, err := ttlToArchiveDate(createCmdFlags.ttl) + if err != nil { + return err + } + + result, err := clients.API().CreateSandbox(ctx, token, + createCmdFlags.name, + domain, + createCmdFlags.password, + createCmdFlags.locale, + createCmdFlags.owningOrgID, + createCmdFlags.template, + createCmdFlags.eventCode, + archiveDate, + ) + if err != nil { + return err + } + + switch createCmdFlags.output { + case "json": + encoder := json.NewEncoder(clients.IO.WriteOut()) + encoder.SetIndent("", " ") + if err := encoder.Encode(result); err != nil { + return err + } + default: + printCreateSuccess(cmd, clients, result) + } + + if createCmdFlags.autoLogin && result.URL != "" { + clients.Browser().OpenURL(result.URL) + } + + return nil +} + +const maxTTL = 180 * 24 * time.Hour // 6 months + +// ttlToArchiveDate parses a TTL string (e.g., "24h", "1d", "7d") and returns the Unix epoch +// when the sandbox will be archived. Returns 0 if ttl is empty (no archiving). Supports +// Go duration format (h, m, s) and "Nd" for days. TTL cannot exceed 6 months. +func ttlToArchiveDate(ttl string) (int64, error) { + if ttl == "" { + return 0, nil + } + 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") + } + } + if d > maxTTL { + return 0, slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("TTL cannot exceed 6 months"). + WithRemediation("Use a shorter duration (e.g., 2h, 1d, 7d)") + } + return time.Now().Add(d).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, result types.CreateSandboxResult) { + 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", result.TeamID), + fmt.Sprintf("User ID: %s", result.UserID), + fmt.Sprintf("URL: %s", result.URL), + }, + })) +} diff --git a/cmd/sandbox/delete.go b/cmd/sandbox/delete.go new file mode 100644 index 00000000..4617a519 --- /dev/null +++ b/cmd/sandbox/delete.go @@ -0,0 +1,114 @@ +// 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 + token string +} + +var deleteCmdFlags deleteFlags + +func NewDeleteCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete [flags]", + Short: "Delete a sandbox", + Long: `Permanently delete a sandbox and all of its data`, + Example: style.ExampleCommandsf([]style.ExampleCommand{ + {Command: "sandbox delete --sandbox 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", "", "Sandbox team ID to delete") + cmd.Flags().BoolVar(&deleteCmdFlags.force, "force", false, "Skip confirmation prompt") + cmd.Flags().StringVar(&deleteCmdFlags.token, "token", "", "Service account token for CI/CD authentication") + cmd.MarkFlagRequired("sandbox") + + return cmd +} + +func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { + ctx := cmd.Context() + + token, auth, err := getSandboxTokenAndAuth(ctx, clients, deleteCmdFlags.token) + 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, 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, token, auth) + if err != nil { + return err + } + + return nil +} diff --git a/cmd/sandbox/list.go b/cmd/sandbox/list.go new file mode 100644 index 00000000..f50cdb49 --- /dev/null +++ b/cmd/sandbox/list.go @@ -0,0 +1,129 @@ +// 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" + "strings" + "time" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +type listFlags struct { + filter string + token string +} + +var listCmdFlags listFlags + +func NewListCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "list [flags]", + Short: "List your sandboxes", + Long: `List all of your active or archived sandboxes.`, + Example: style.ExampleCommandsf([]style.ExampleCommand{ + {Command: "sandbox list", Meaning: "List your sandboxes"}, + {Command: "sandbox list --filter active", Meaning: "List active sandboxes only"}, + }), + PreRunE: func(cmd *cobra.Command, args []string) error { + return requireSandboxExperiment(clients) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runListCommand(cmd, clients) + }, + } + + cmd.Flags().StringVar(&listCmdFlags.filter, "filter", "", "Filter by status: active, archived") + cmd.Flags().StringVar(&listCmdFlags.token, "token", "", "Service account token for CI/CD authentication") + + return cmd +} + +func runListCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { + ctx := cmd.Context() + + token, auth, err := getSandboxTokenAndAuth(ctx, clients, listCmdFlags.token) + if err != nil { + return err + } + + fmt.Println() + err = printSandboxes(cmd, clients, token, auth) + if err != nil { + return err + } + + return nil +} + +func printSandboxes(cmd *cobra.Command, clients *shared.ClientFactory, token string, auth *types.SlackAuth) error { + ctx := cmd.Context() + + sandboxes, err := clients.API().ListSandboxes(ctx, token, listCmdFlags.filter) + if err != nil { + return err + } + + email := "" + if auth != nil && auth.UserID != "" { + if userInfo, err := clients.API().UsersInfo(ctx, token, auth.UserID); err == nil && userInfo.Profile.Email != "" { + email = userInfo.Profile.Email + } + } + + section := style.TextSection{ + Emoji: "beach_with_umbrella", + Text: " Developer Sandboxes", + } + if email != "" { + section.Secondary = []string{fmt.Sprintf("Owned by Slack developer account %s", email)} + } + + clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(section)) + + if len(sandboxes) == 0 { + clients.IO.PrintInfo(ctx, false, "%s\n", style.Secondary("No sandboxes found. Create one with `slack sandbox create --name `")) + return nil + } + + timeFormat := "2006-01-02 15:04" + for _, s := range sandboxes { + cmd.Printf(" %s (%s)\n", style.Bold(s.SandboxName), s.SandboxTeamID) + if s.SandboxDomain != "" { + cmd.Printf(" %s\n", style.Secondary(fmt.Sprintf("URL: https://%s.slack.com", s.SandboxDomain))) + } + if s.Status != "" { + status := style.Secondary(fmt.Sprintf("Status: %s", strings.ToTitle(s.Status))) + if strings.EqualFold(s.Status, "archived") { + cmd.Printf(" %s %s\n", style.Emoji("warning"), status) + } else { + cmd.Printf(" %s\n", status) + } + } + if s.DateCreated > 0 { + cmd.Printf(" %s\n", style.Secondary(fmt.Sprintf("Created: %s", time.Unix(s.DateCreated, 0).Format(timeFormat)))) + } + if s.DateArchived > 0 { + cmd.Printf(" %s\n", style.Secondary(fmt.Sprintf("Archived: %s", time.Unix(s.DateArchived, 0).Format(timeFormat)))) + } + cmd.Println() + } + + return nil +} diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go new file mode 100644 index 00000000..f577273b --- /dev/null +++ b/cmd/sandbox/sandbox.go @@ -0,0 +1,58 @@ +// 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 ( + "github.com/slackapi/slack-cli/internal/experiment" + "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" +) + +func NewCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "sandbox [flags] --experiment=sandboxes", + Short: "Create and manage your sandboxes", + Long: `Create, list, or delete Slack developer sandboxes without leaving your terminal. +Use the --team flag to select the authentication to use for these commands. + +Prefer a UI? Head over to https://api.slack.com/developer-program/sandboxes + +New to the Developer Program? Sign up at https://api.slack.com/developer-program/join`, + Example: style.ExampleCommandsf([]style.ExampleCommand{}), + PreRunE: func(cmd *cobra.Command, args []string) error { + return requireSandboxExperiment(clients) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(NewCreateCommand(clients)) + cmd.AddCommand(NewListCommand(clients)) + cmd.AddCommand(NewDeleteCommand(clients)) + + return cmd +} + +func requireSandboxExperiment(clients *shared.ClientFactory) error { + if !clients.Config.WithExperimentOn(experiment.Sandboxes) { + return slackerror.New(slackerror.ErrMissingExperiment). + WithMessage("%sThe sandbox management commands are under construction", style.Emoji("construction")). + WithRemediation("To try them out, just add the --experiment=sandboxes flag to your command!") + } + return nil +} diff --git a/cmd/sandbox/token.go b/cmd/sandbox/token.go new file mode 100644 index 00000000..2cdca86d --- /dev/null +++ b/cmd/sandbox/token.go @@ -0,0 +1,107 @@ +// 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" + "strings" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackerror" +) + +// getSandboxToken returns the token to use for sandbox API operations. +// It uses the --token flag if provided, otherwise resolves from stored credentials. +func getSandboxToken(ctx context.Context, clients *shared.ClientFactory, tokenFlag string) (string, error) { + token, _, err := getSandboxTokenAndAuth(ctx, clients, tokenFlag) + return token, err +} + +// getSandboxTokenAndAuth returns the token and auth used for sandbox API operations. +// When --token is provided, auth is resolved via AuthWithToken (may have limited fields). +// Otherwise auth comes from stored credentials. +func getSandboxTokenAndAuth(ctx context.Context, clients *shared.ClientFactory, tokenFlag string) (string, *types.SlackAuth, error) { + if tokenFlag != "" { + auth, err := clients.Auth().AuthWithToken(ctx, tokenFlag) + if err != nil { + return "", nil, err + } + return tokenFlag, &auth, nil + } + + auth, err := resolveAuthForSandbox(ctx, clients) + if err != nil { + return "", nil, err + } + + return auth.Token, auth, nil +} + +// resolveAuthForSandbox gets the appropriate auth for sandbox operations. +// If the global --token flag is set, that is used. Otherwise, if --team is set, +// uses the auth that matches that team; else the first available auth. +func resolveAuthForSandbox(ctx context.Context, clients *shared.ClientFactory) (*types.SlackAuth, error) { + // Check persistent token flag (from root) + if clients.Config.TokenFlag != "" { + auth, err := clients.Auth().AuthWithToken(ctx, clients.Config.TokenFlag) + if err != nil { + return nil, err + } + return &auth, nil + } + + auths, err := clients.Auth().Auths(ctx) + if err != nil { + return nil, err + } + + if len(auths) == 0 { + return nil, slackerror.New(slackerror.ErrCredentialsNotFound). + WithMessage("You must be logged in to manage sandboxes"). + WithRemediation("Run 'slack login' to authenticate, or use --token for CI/CD") + } + + // If --team flag is set, find matching auth + if clients.Config.TeamFlag != "" { + for _, auth := range auths { + if auth.TeamID == clients.Config.TeamFlag || auth.TeamDomain == clients.Config.TeamFlag { + return &auth, nil + } + } + return nil, slackerror.New(slackerror.ErrTeamNotFound). + WithMessage("No auth found for team: " + clients.Config.TeamFlag). + WithRemediation("Run 'slack auth list' to see your authorized workspaces") + } + + // Use first auth + return &auths[0], nil +} + +// parseLabels parses a comma-separated key=value string into a map +func parseLabels(labelsStr string) map[string]string { + if labelsStr == "" { + return nil + } + + labels := make(map[string]string) + for _, pair := range strings.Split(labelsStr, ",") { + kv := strings.SplitN(strings.TrimSpace(pair), "=", 2) + if len(kv) == 2 { + labels[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + } + return labels +} diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index 898dd036..0789e1a2 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -213,6 +213,23 @@ func (m *APIMock) FunctionDistributionRemoveUsers(ctx context.Context, callbackI return args.Error(0) } +// SandboxClient + +func (m *APIMock) CreateSandbox(ctx context.Context, token string, orgName, domain, password, locale, owningOrgID, templateID, eventCode string, archiveDate int64) (types.CreateSandboxResult, error) { + args := m.Called(ctx, token, orgName, domain, password, locale, owningOrgID, templateID, eventCode, archiveDate) + return args.Get(0).(types.CreateSandboxResult), args.Error(1) +} + +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) +} + +func (m *APIMock) DeleteSandbox(ctx context.Context, token string, sandboxTeamID string) error { + args := m.Called(ctx, token, sandboxTeamID) + 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 new file mode 100644 index 00000000..0ac300c1 --- /dev/null +++ b/internal/api/sandbox.go @@ -0,0 +1,173 @@ +// 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 ( + "context" + "fmt" + "net/url" + "strconv" + + "github.com/opentracing/opentracing-go" + "github.com/slackapi/slack-cli/internal/goutils" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackerror" +) + +const ( + sandboxCreateMethod = "enterprise.signup.createDevOrg" + sandboxListMethod = "developer.sandbox.list" + sandboxDeleteMethod = "developer.sandbox.delete" +) + +// SandboxClient is the interface for sandbox-related API calls +type SandboxClient interface { + CreateSandbox(ctx context.Context, token string, name, domain, password, locale, owningOrgID, templateID, eventCode string, archiveDate int64) (types.CreateSandboxResult, error) + ListSandboxes(ctx context.Context, token string, filter string) ([]types.Sandbox, error) + DeleteSandbox(ctx context.Context, token string, sandboxTeamID string) error +} + +type createSandboxResponse struct { + extendedBaseResponse + types.CreateSandboxResult +} + +type listSandboxesResponse struct { + extendedBaseResponse + Sandboxes []types.Sandbox `json:"sandboxes"` +} + +var listSandboxFilterEnum = []string{"active", "archived"} + +// CreateSandbox provisions a new Developer Sandbox (developer org and primary user). +func (c *Client) CreateSandbox(ctx context.Context, token string, name, domain, password, locale, owningOrgID, templateID, eventCode string, archiveDate int64) (types.CreateSandboxResult, 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 != "" { + values.Add("template_id", templateID) + } + 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 types.CreateSandboxResult{}, errHTTPRequestFailed.WithRootCause(err) + } + + resp := createSandboxResponse{} + err = goutils.JSONUnmarshal(b, &resp) + if err != nil { + return types.CreateSandboxResult{}, errHTTPResponseInvalid.WithRootCause(err).AddAPIMethod(sandboxCreateMethod) + } + + if !resp.Ok { + return types.CreateSandboxResult{}, slackerror.NewAPIError(resp.Error, resp.Description, resp.Errors, sandboxCreateMethod) + } + + return resp.CreateSandboxResult, 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 + span, ctx = opentracing.StartSpanFromContext(ctx, "apiclient.ListSandboxes") + defer span.Finish() + + if status != "" { + valid := false + for _, v := range listSandboxFilterEnum { + if status == v { + valid = true + break + } + } + if !valid { + return nil, errInvalidArguments.WithRootCause(fmt.Errorf("allowed values for sandbox status filter: %v", listSandboxFilterEnum)) + } + } + + values := url.Values{} + values.Add("token", token) + if status != "" { + values.Add("status", status) + } + + b, err := c.postForm(ctx, sandboxListMethod, values) + if err != nil { + return nil, errHTTPRequestFailed.WithRootCause(err) + } + + resp := listSandboxesResponse{} + err = goutils.JSONUnmarshal(b, &resp) + if err != nil { + return nil, errHTTPResponseInvalid.WithRootCause(err).AddAPIMethod(sandboxListMethod) + } + + if !resp.Ok { + return nil, slackerror.NewAPIError(resp.Error, resp.Description, resp.Errors, sandboxListMethod) + } + + if resp.Sandboxes == nil { + return []types.Sandbox{}, nil + } + + return resp.Sandboxes, nil +} + +// DeleteSandbox permanently deletes the specified sandbox. +// Required: token, sandbox_team_id +func (c *Client) DeleteSandbox(ctx context.Context, token string, sandboxTeamID 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", sandboxTeamID) + + 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/api/types.go b/internal/api/types.go index e9cfe4a1..22f0431c 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -28,6 +28,7 @@ type APIInterface interface { DatastoresClient ExternalAuthClient FunctionDistributionClient + SandboxClient SessionsClient StepsClient TeamClient diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index c7b90149..bad7cf7d 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -41,6 +41,9 @@ const ( // Charm experiment enables beautiful prompts. Charm Experiment = "charm" + // Sandboxes experiment lets users who have joined the Slack Developer Program use the CLI to manage their sandboxes. + Sandboxes Experiment = "sandboxes" + // Placeholder experiment is a placeholder for testing and does nothing... or does it? Placeholder Experiment = "placeholder" ) @@ -52,6 +55,7 @@ var AllExperiments = []Experiment{ BoltInstall, Charm, Placeholder, + Sandboxes, } // EnabledExperiments is a list of experiments that are permanently enabled diff --git a/internal/shared/types/sandbox.go b/internal/shared/types/sandbox.go new file mode 100644 index 00000000..88fa0161 --- /dev/null +++ b/internal/shared/types/sandbox.go @@ -0,0 +1,47 @@ +// 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 types + +// Sandbox represents a Slack Developer Sandbox from the developer.sandbox.list API. +type Sandbox struct { + SandboxTeamID string `json:"sandbox_team_id"` // Encoded team ID of the developer sandbox + SandboxName string `json:"sandbox_name"` // Name of the developer sandbox + SandboxDomain string `json:"sandbox_domain"` // Domain of the developer sandbox + DateCreated int64 `json:"date_created"` // When the developer sandbox was created, as epoch seconds + DateArchived int64 `json:"date_archived"` // When the developer sandbox is or will be archived, as epoch seconds + Status string `json:"status"` // Status of the developer sandbox: Active or Archived +} + +// CreateSandboxRequest is the request payload for creating a sandbox. +// Matches enterprise.signup.createDevOrg API contract. +type CreateSandboxRequest struct { + Token string `json:"token"` + OrgName string `json:"org_name"` + Domain string `json:"domain"` + Password string `json:"password,omitempty"` + Locale string `json:"locale,omitempty"` + OwningOrgID string `json:"owning_org_id,omitempty"` + TemplateID string `json:"template_id,omitempty"` + EventCode string `json:"event_code,omitempty"` + ArchiveDate int64 `json:"archive_date,omitempty"` // When the sandbox will be archived, as epoch seconds +} + +// CreateSandboxResult is the response from creating a sandbox. +// Matches enterprise.signup.createDevOrg API output. +type CreateSandboxResult struct { + UserID string `json:"user_id"` + TeamID string `json:"team_id"` + URL string `json:"url"` +} diff --git a/internal/shared/types/user.go b/internal/shared/types/user.go index 10738da5..4f56b73b 100644 --- a/internal/shared/types/user.go +++ b/internal/shared/types/user.go @@ -72,4 +72,5 @@ type UserInfo struct { type UserProfile struct { DisplayName string `json:"display_name"` + Email string `json:"email,omitempty"` } diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index a9d89ea3..e6d86224 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{ @@ -1120,7 +1121,7 @@ Otherwise start your app for local development with: %s`, ErrMissingExperiment: { Code: ErrMissingExperiment, - Message: "The feature is behind an experiment not toggled on", + Message: "The feature is behind an experiment flag", }, ErrMissingFunctionIdentifier: { @@ -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", + }, } From cea2bcd20b70040399674f0cce7c0736c019cdbe Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Wed, 11 Mar 2026 13:28:50 -0400 Subject: [PATCH 02/20] updates --- cmd/app/delete.go | 2 +- cmd/sandbox/create.go | 122 ++++++------ cmd/sandbox/create_test.go | 317 +++++++++++++++++++++++++++++++ cmd/sandbox/delete.go | 19 +- cmd/sandbox/delete_test.go | 183 ++++++++++++++++++ cmd/sandbox/sandbox.go | 3 + cmd/sandbox/token.go | 107 ----------- internal/api/api_mock.go | 10 + internal/api/sandbox.go | 89 ++++++++- internal/shared/types/sandbox.go | 14 +- internal/slackerror/errors.go | 2 +- 11 files changed, 682 insertions(+), 186 deletions(-) create mode 100644 cmd/sandbox/create_test.go create mode 100644 cmd/sandbox/delete_test.go delete mode 100644 cmd/sandbox/token.go diff --git a/cmd/app/delete.go b/cmd/app/delete.go index 4375627a..30d3264b 100644 --- a/cmd/app/delete.go +++ b/cmd/app/delete.go @@ -111,7 +111,7 @@ func RunDeleteCommand(ctx context.Context, clients *shared.ClientFactory, cmd *c func confirmDeletion(ctx context.Context, IO iostreams.IOStreamer, app prompts.SelectedApp) (bool, error) { IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ Emoji: "warning", - Text: style.Bold(" Danger zone"), + Text: style.Bold("Danger zone"), Secondary: []string{ fmt.Sprintf("App (%s) will be permanently deleted", app.App.AppID), "All triggers, workflows, and functions will be deleted", diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index 7aea6286..366484dd 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -15,14 +15,12 @@ package sandbox import ( - "encoding/json" "fmt" "strconv" "strings" "time" "github.com/slackapi/slack-cli/internal/shared" - "github.com/slackapi/slack-cli/internal/shared/types" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/style" "github.com/spf13/cobra" @@ -36,10 +34,8 @@ type createFlags struct { owningOrgID string template string eventCode string - ttl string - autoLogin bool - output string - token string + archiveTTL string // TTL duration, e.g. 1d, 2h + archiveDate string // explicit date yyyy-mm-dd } var createCmdFlags createFlags @@ -47,14 +43,12 @@ var createCmdFlags createFlags func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "create [flags]", - Short: "Create a new sandbox", - Long: `Create a new Slack developer sandbox. - -Provisions a new sandbox. Domain is derived from org name if --domain is not provided.`, + Short: "Create a developer sandbox", + Long: `Create a new Slack developer sandbox`, Example: style.ExampleCommandsf([]style.ExampleCommand{ - {Command: "sandbox create --name test-box", Meaning: "Create a sandbox named test-box"}, - {Command: "sandbox create --name test-box --password mypass --owning-org-id E12345", Meaning: "Create a sandbox with login password and owning org"}, - {Command: "sandbox create --name test-box --domain test-box --ttl 24h --output json", Meaning: "Create an ephemeral sandbox for CI/CD with JSON output"}, + {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 { @@ -68,16 +62,21 @@ Provisions a new sandbox. Domain is derived from org name if --domain is not pro 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.owningOrgID, "owning-org-id", "", "Enterprise team ID that manages your developer account, if applicable") + cmd.Flags().StringVar(&createCmdFlags.locale, "locale", "", "Locale (eg. en-us, languageCode-countryCode)") cmd.Flags().StringVar(&createCmdFlags.template, "template", "", "Template ID for pre-defined data to preload") cmd.Flags().StringVar(&createCmdFlags.eventCode, "event-code", "", "Event code for the sandbox") - cmd.Flags().StringVar(&createCmdFlags.ttl, "ttl", "", "Time-to-live duration; sandbox will be archived after this period (e.g., 2h, 1d, 7d)") - cmd.Flags().StringVar(&createCmdFlags.output, "output", "text", "Output format: json, text") - cmd.Flags().StringVar(&createCmdFlags.token, "token", "", "Service account token for CI/CD authentication") + cmd.Flags().StringVar(&createCmdFlags.archiveTTL, "archive-ttl", "", "Time-to-live duration; sandbox will be archived at end of day 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.MarkFlagRequired("name") - cmd.MarkFlagRequired("domain") - cmd.MarkFlagRequired("password") + // 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("password"); err != nil { + panic(err) + } return cmd } @@ -85,22 +84,36 @@ Provisions a new sandbox. Domain is derived from org name if --domain is not pro func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { ctx := cmd.Context() - token, err := getSandboxToken(ctx, clients, createCmdFlags.token) + auth, err := getSandboxAuth(ctx, clients) if err != nil { return err } domain := createCmdFlags.domain if domain == "" { - domain = slugFromsandboxName(createCmdFlags.name) + domain = domainFromName(createCmdFlags.name) } - archiveDate, err := ttlToArchiveDate(createCmdFlags.ttl) - if err != nil { - return err + 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) + if err != nil { + return err + } } - result, err := clients.API().CreateSandbox(ctx, token, + teamID, sandboxURL, err := clients.API().CreateSandbox(ctx, auth.Token, createCmdFlags.name, domain, createCmdFlags.password, @@ -108,39 +121,21 @@ func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { createCmdFlags.owningOrgID, createCmdFlags.template, createCmdFlags.eventCode, - archiveDate, + archiveEpochDatetime, ) if err != nil { return err } - switch createCmdFlags.output { - case "json": - encoder := json.NewEncoder(clients.IO.WriteOut()) - encoder.SetIndent("", " ") - if err := encoder.Encode(result); err != nil { - return err - } - default: - printCreateSuccess(cmd, clients, result) - } - - if createCmdFlags.autoLogin && result.URL != "" { - clients.Browser().OpenURL(result.URL) - } + printCreateSuccess(cmd, clients, teamID, sandboxURL) return nil } -const maxTTL = 180 * 24 * time.Hour // 6 months - -// ttlToArchiveDate parses a TTL string (e.g., "24h", "1d", "7d") and returns the Unix epoch -// when the sandbox will be archived. Returns 0 if ttl is empty (no archiving). Supports -// Go duration format (h, m, s) and "Nd" for days. TTL cannot exceed 6 months. -func ttlToArchiveDate(ttl string) (int64, error) { - if ttl == "" { - return 0, 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") @@ -160,16 +155,23 @@ func ttlToArchiveDate(ttl string) (int64, error) { WithRemediation("Use a duration like 2h, 1d, or 7d") } } - if d > maxTTL { + 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 (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("TTL cannot exceed 6 months"). - WithRemediation("Use a shorter duration (e.g., 2h, 1d, 7d)") + WithMessage("Invalid archive date: %q", dateStr). + WithRemediation("Use yyyy-mm-dd format (e.g., 2025-12-31)") } - return time.Now().Add(d).Unix(), nil + return t.Unix(), nil } -// slugFromsandboxName derives a domain-safe slug from org name (lowercase, alphanumeric + hyphens). -func slugFromsandboxName(name string) string { +// domainFromName derives domain-safe text from the name of the sandbox (lowercase, alphanumeric + hyphens). +func domainFromName(name string) string { var b []byte for _, r := range name { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { @@ -195,15 +197,15 @@ func slugFromsandboxName(name string) string { return string(b) } -func printCreateSuccess(cmd *cobra.Command, clients *shared.ClientFactory, result types.CreateSandboxResult) { +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", result.TeamID), - fmt.Sprintf("User ID: %s", result.UserID), - fmt.Sprintf("URL: %s", result.URL), + 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..a3a17565 --- /dev/null +++ b/cmd/sandbox/create_test.go @@ -0,0 +1,317 @@ +// 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-box": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "json-box", + "--domain", "json-box", + "--password", "secret", + }, + 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{"T456", "https://json-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", "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 + want int64 + wantErr bool + }{ + {"valid", "2025-12-31", 1767139200, false}, // 2025-12-31 00:00:00 UTC + {"invalid format", "12-31-2025", 0, true}, + {"invalid date", "not-a-date", 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getEpochFromDate(tt.dateStr) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_domainFromName(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 := domainFromName(tt.in) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/sandbox/delete.go b/cmd/sandbox/delete.go index 4617a519..62976218 100644 --- a/cmd/sandbox/delete.go +++ b/cmd/sandbox/delete.go @@ -28,7 +28,6 @@ type deleteFlags struct { sandboxID string force bool yes bool - token string } var deleteCmdFlags deleteFlags @@ -36,10 +35,10 @@ var deleteCmdFlags deleteFlags func NewDeleteCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "delete [flags]", - Short: "Delete a sandbox", + Short: "Delete a developer sandbox", Long: `Permanently delete a sandbox and all of its data`, Example: style.ExampleCommandsf([]style.ExampleCommand{ - {Command: "sandbox delete --sandbox E0123456", Meaning: "Delete a sandbox identified by its team ID"}, + {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 { @@ -50,10 +49,12 @@ func NewDeleteCommand(clients *shared.ClientFactory) *cobra.Command { }, } - cmd.Flags().StringVar(&deleteCmdFlags.sandboxID, "sandbox", "", "Sandbox team ID to delete") + cmd.Flags().StringVar(&deleteCmdFlags.sandboxID, "sandbox-id", "", "Sandbox team ID to delete") cmd.Flags().BoolVar(&deleteCmdFlags.force, "force", false, "Skip confirmation prompt") - cmd.Flags().StringVar(&deleteCmdFlags.token, "token", "", "Service account token for CI/CD authentication") - cmd.MarkFlagRequired("sandbox") + + if err := cmd.MarkFlagRequired("sandbox-id"); err != nil { + panic(err) + } return cmd } @@ -61,7 +62,7 @@ func NewDeleteCommand(clients *shared.ClientFactory) *cobra.Command { func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { ctx := cmd.Context() - token, auth, err := getSandboxTokenAndAuth(ctx, clients, deleteCmdFlags.token) + auth, err := getSandboxAuth(ctx, clients) if err != nil { return err } @@ -93,7 +94,7 @@ func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { } } - if err := clients.API().DeleteSandbox(ctx, token, deleteCmdFlags.sandboxID); err != nil { + if err := clients.API().DeleteSandbox(ctx, auth.Token, deleteCmdFlags.sandboxID); err != nil { return err } @@ -105,7 +106,7 @@ func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { }, })) - err = printSandboxes(cmd, clients, token, auth) + err = printSandboxes(cmd, clients, auth.Token, auth) if err != nil { return err } 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..7a50e6a4 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 } @@ -75,6 +77,7 @@ func getSandboxAuth(ctx context.Context, clients *shared.ClientFactory) (*types. } // Prompt the user to select a team to use for authentication + clients.IO.PrintInfo(ctx, false, 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/cmd/sandbox/token.go b/cmd/sandbox/token.go deleted file mode 100644 index 2cdca86d..00000000 --- a/cmd/sandbox/token.go +++ /dev/null @@ -1,107 +0,0 @@ -// 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" - "strings" - - "github.com/slackapi/slack-cli/internal/shared" - "github.com/slackapi/slack-cli/internal/shared/types" - "github.com/slackapi/slack-cli/internal/slackerror" -) - -// getSandboxToken returns the token to use for sandbox API operations. -// It uses the --token flag if provided, otherwise resolves from stored credentials. -func getSandboxToken(ctx context.Context, clients *shared.ClientFactory, tokenFlag string) (string, error) { - token, _, err := getSandboxTokenAndAuth(ctx, clients, tokenFlag) - return token, err -} - -// getSandboxTokenAndAuth returns the token and auth used for sandbox API operations. -// When --token is provided, auth is resolved via AuthWithToken (may have limited fields). -// Otherwise auth comes from stored credentials. -func getSandboxTokenAndAuth(ctx context.Context, clients *shared.ClientFactory, tokenFlag string) (string, *types.SlackAuth, error) { - if tokenFlag != "" { - auth, err := clients.Auth().AuthWithToken(ctx, tokenFlag) - if err != nil { - return "", nil, err - } - return tokenFlag, &auth, nil - } - - auth, err := resolveAuthForSandbox(ctx, clients) - if err != nil { - return "", nil, err - } - - return auth.Token, auth, nil -} - -// resolveAuthForSandbox gets the appropriate auth for sandbox operations. -// If the global --token flag is set, that is used. Otherwise, if --team is set, -// uses the auth that matches that team; else the first available auth. -func resolveAuthForSandbox(ctx context.Context, clients *shared.ClientFactory) (*types.SlackAuth, error) { - // Check persistent token flag (from root) - if clients.Config.TokenFlag != "" { - auth, err := clients.Auth().AuthWithToken(ctx, clients.Config.TokenFlag) - if err != nil { - return nil, err - } - return &auth, nil - } - - auths, err := clients.Auth().Auths(ctx) - if err != nil { - return nil, err - } - - if len(auths) == 0 { - return nil, slackerror.New(slackerror.ErrCredentialsNotFound). - WithMessage("You must be logged in to manage sandboxes"). - WithRemediation("Run 'slack login' to authenticate, or use --token for CI/CD") - } - - // If --team flag is set, find matching auth - if clients.Config.TeamFlag != "" { - for _, auth := range auths { - if auth.TeamID == clients.Config.TeamFlag || auth.TeamDomain == clients.Config.TeamFlag { - return &auth, nil - } - } - return nil, slackerror.New(slackerror.ErrTeamNotFound). - WithMessage("No auth found for team: " + clients.Config.TeamFlag). - WithRemediation("Run 'slack auth list' to see your authorized workspaces") - } - - // Use first auth - return &auths[0], nil -} - -// parseLabels parses a comma-separated key=value string into a map -func parseLabels(labelsStr string) map[string]string { - if labelsStr == "" { - return nil - } - - labels := make(map[string]string) - for _, pair := range strings.Split(labelsStr, ",") { - kv := strings.SplitN(strings.TrimSpace(pair), "=", 2) - if len(kv) == 2 { - labels[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) - } - } - return labels -} 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..9396ce7d 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 = "enterprise.signup.createDevOrg" + 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("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 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 e6d86224..360572bd 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -1121,7 +1121,7 @@ Otherwise start your app for local development with: %s`, ErrMissingExperiment: { Code: ErrMissingExperiment, - Message: "The feature is behind an experiment flag", + Message: "The feature is behind an experiment not toggled on", }, ErrMissingFunctionIdentifier: { From a35fbc3e6b3aacdec35d1f0a9df689505c40db2a Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 12 Mar 2026 15:04:03 -0400 Subject: [PATCH 03/20] update --- cmd/sandbox/delete.go | 3 +-- cmd/sandbox/sandbox.go | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/sandbox/delete.go b/cmd/sandbox/delete.go index 62976218..24135dc3 100644 --- a/cmd/sandbox/delete.go +++ b/cmd/sandbox/delete.go @@ -27,7 +27,6 @@ import ( type deleteFlags struct { sandboxID string force bool - yes bool } var deleteCmdFlags deleteFlags @@ -67,7 +66,7 @@ func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { return err } - skipConfirm := deleteCmdFlags.force || deleteCmdFlags.yes + skipConfirm := deleteCmdFlags.force if !skipConfirm { clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ Emoji: "warning", diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index 7a50e6a4..f34d3484 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -77,7 +77,7 @@ func getSandboxAuth(ctx context.Context, clients *shared.ClientFactory) (*types. } // Prompt the user to select a team to use for authentication - clients.IO.PrintInfo(ctx, false, style.Secondary("Choose a Slack team where your email address matches your Slack developer account")) + 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 From a50fb40ad8db468e1a684f3dc963a8a6abdeadee Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Wed, 18 Mar 2026 17:23:03 -0400 Subject: [PATCH 04/20] Update cmd/sandbox/sandbox.go Co-authored-by: Eden Zimbelman --- cmd/sandbox/sandbox.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index f34d3484..b6614fb0 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -77,6 +77,7 @@ 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 { From 0b525a5e3e305286a87c5366283076002f4b6b87 Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 10:22:07 -0400 Subject: [PATCH 05/20] update name to domain handling, add unit tests, alphabetize --- cmd/sandbox/create.go | 45 ++++++------ cmd/sandbox/create_test.go | 101 ++++++++++++++++++++------ internal/api/sandbox.go | 90 +++++++++++------------ internal/api/sandbox_test.go | 137 +++++++++++++++++++++++++++++++++++ 4 files changed, 282 insertions(+), 91 deletions(-) create mode 100644 internal/api/sandbox_test.go diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index 366484dd..b9a045d8 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -19,6 +19,7 @@ import ( "strconv" "strings" "time" + "unicode" "github.com/slackapi/slack-cli/internal/shared" "github.com/slackapi/slack-cli/internal/slackerror" @@ -32,7 +33,7 @@ type createFlags struct { password string locale string owningOrgID string - template string + templateID int eventCode string archiveTTL string // TTL duration, e.g. 1d, 2h archiveDate string // explicit date yyyy-mm-dd @@ -63,7 +64,7 @@ func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command { 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 (eg. en-us, languageCode-countryCode)") - cmd.Flags().StringVar(&createCmdFlags.template, "template", "", "Template ID for pre-defined data to preload") + cmd.Flags().IntVar(&createCmdFlags.templateID, "template", 0, "Template ID for pre-defined data to preload") 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 at end of day 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") @@ -91,7 +92,11 @@ func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { domain := createCmdFlags.domain if domain == "" { - domain = domainFromName(createCmdFlags.name) + var err error + domain, err = domainFromName(createCmdFlags.name) + if err != nil { + return err + } } if createCmdFlags.archiveTTL != "" && createCmdFlags.archiveDate != "" { @@ -119,7 +124,7 @@ func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { createCmdFlags.password, createCmdFlags.locale, createCmdFlags.owningOrgID, - createCmdFlags.template, + createCmdFlags.templateID, createCmdFlags.eventCode, archiveEpochDatetime, ) @@ -171,30 +176,22 @@ func getEpochFromDate(dateStr string) (int64, error) { } // domainFromName derives domain-safe text from the name of the sandbox (lowercase, alphanumeric + hyphens). -func domainFromName(name string) string { - var b []byte +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 (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, '-') - } + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' { + domain = append(domain, byte(r)) } } - // 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" + 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(b) + return string(domain), nil } func printCreateSuccess(cmd *cobra.Command, clients *shared.ClientFactory, teamID, url string) { diff --git a/cmd/sandbox/create_test.go b/cmd/sandbox/create_test.go index a3a17565..a822b0e2 100644 --- a/cmd/sandbox/create_test.go +++ b/cmd/sandbox/create_test.go @@ -43,7 +43,7 @@ func TestCreateCommand(t *testing.T) { 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)). + cm.API.On("CreateSandbox", mock.Anything, testToken, "test-box", "test-box", "mypass", "", "", 0, "", 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) @@ -54,7 +54,7 @@ func TestCreateCommand(t *testing.T) { 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)) + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "test-box", "test-box", "mypass", "", "", 0, "", int64(0)) }, }, "create with json-box": { @@ -70,7 +70,7 @@ func TestCreateCommand(t *testing.T) { 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)). + cm.API.On("CreateSandbox", mock.Anything, testToken, "json-box", "json-box", "secret", "", "", 0, "", int64(0)). Return("T456", "https://json-box.slack.com", nil) cm.AddDefaultMocks() @@ -79,7 +79,7 @@ func TestCreateCommand(t *testing.T) { }, ExpectedStdoutOutputs: []string{"T456", "https://json-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", "json-box", "json-box", "secret", "", "", "", "", int64(0)) + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "json-box", "json-box", "secret", "", "", 0, "", int64(0)) }, }, "create with derived domain": { @@ -95,7 +95,7 @@ func TestCreateCommand(t *testing.T) { 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)). + cm.API.On("CreateSandbox", mock.Anything, testToken, "My Test Box", "my-test-box", "pass", "", "", 0, "", int64(0)). Return("T789", "https://my-test-box.slack.com", nil) cm.AddDefaultMocks() @@ -103,7 +103,7 @@ func TestCreateCommand(t *testing.T) { 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)) + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "My Test Box", "my-test-box", "pass", "", "", 0, "", int64(0)) }, }, "create with a relative time-to-live value": { @@ -120,7 +120,7 @@ func TestCreateCommand(t *testing.T) { 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 })). + cm.API.On("CreateSandbox", mock.Anything, testToken, "tmp-box", "tmp-box", "pass", "", "", 0, "", mock.MatchedBy(func(v int64) bool { return v > 0 })). Return("T111", "https://tmp-box.slack.com", nil) cm.AddDefaultMocks() @@ -128,7 +128,7 @@ func TestCreateCommand(t *testing.T) { 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 })) + 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 })) }, }, "create API error": { @@ -144,7 +144,7 @@ func TestCreateCommand(t *testing.T) { 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)). + cm.API.On("CreateSandbox", mock.Anything, testToken, "err-box", "err-box", "pass", "", "", 0, "", int64(0)). Return("", "", errors.New("api_error")) cm.AddDefaultMocks() @@ -153,6 +153,32 @@ func TestCreateCommand(t *testing.T) { }, ExpectedErrorStrings: []string{"api_error"}, }, + "create with template": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tpl-box", + "--domain", "tpl-box", + "--password", "pass", + "--template", "42", + }, + 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", "", "", 42, "", int64(0)). + 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", "", "", 42, "", int64(0)) + }, + }, "create with archive-date": { CmdArgs: []string{ "--experiment=sandboxes", @@ -167,7 +193,7 @@ func TestCreateCommand(t *testing.T) { 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)). + cm.API.On("CreateSandbox", mock.Anything, testToken, "date-box", "date-box", "pass", "", "", 0, "", int64(1767139200)). Return("T222", "https://date-box.slack.com", nil) cm.AddDefaultMocks() @@ -175,7 +201,32 @@ func TestCreateCommand(t *testing.T) { 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)) + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "date-box", "date-box", "pass", "", "", 0, "", int64(1767139200)) + }, + }, + "create with template": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tmpl-box", + "--domain", "tmpl-box", + "--password", "pass", + "--template", "42", + }, + 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, "tmpl-box", "tmpl-box", "pass", "", "", 42, "", int64(0)). + Return("T333", "https://tmpl-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", "tmpl-box", "tmpl-box", "pass", "", "", 42, "", int64(0)) }, }, "create with both archive and archive-date fails": { @@ -296,21 +347,27 @@ func Test_getEpochFromDate(t *testing.T) { func Test_domainFromName(t *testing.T) { tests := []struct { - name string - in string - want string + name string + in string + want string + wantErr bool }{ - {"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"}, + {"simple", "test-box", "test-box", false}, + {"spaces", "My Test Box", "my-test-box", false}, + {"uppercase", "MyBox", "mybox", false}, + {"mixed", "Hello_World 123", "hello-world-123", false}, + {"hyphens", "a--b", "a-b", false}, + {"leading trailing", "-test-", "test", false}, + {"empty", "", "", true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := domainFromName(tt.in) + got, err := domainFromName(tt.in) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) assert.Equal(t, tt.want, got) }) } diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go index 9396ce7d..0fa8c486 100644 --- a/internal/api/sandbox.go +++ b/internal/api/sandbox.go @@ -26,55 +26,16 @@ import ( ) const ( - sandboxListMethod = "developer.sandbox.list" sandboxCreateMethod = "enterprise.signup.createDevOrg" sandboxDeleteMethod = "developer.sandbox.delete" + sandboxListMethod = "developer.sandbox.list" ) // 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) + CreateSandbox(ctx context.Context, token, name, domain, password, locale, owningOrgID string, templateID int, eventCode string, archiveDate int64) (teamID, sandboxURL string, err error) DeleteSandbox(ctx context.Context, token, sandboxID string) error -} - -type listSandboxesResponse struct { - extendedBaseResponse - Sandboxes []types.Sandbox `json:"sandboxes"` -} - -// 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 - span, ctx = opentracing.StartSpanFromContext(ctx, "apiclient.ListSandboxes") - defer span.Finish() - - values := url.Values{} - values.Add("token", token) - if status != "" { - values.Add("status", status) - } - - b, err := c.postForm(ctx, sandboxListMethod, values) - if err != nil { - return nil, errHTTPRequestFailed.WithRootCause(err) - } - - resp := listSandboxesResponse{} - err = goutils.JSONUnmarshal(b, &resp) - if err != nil { - return nil, errHTTPResponseInvalid.WithRootCause(err).AddAPIMethod(sandboxListMethod) - } - - if !resp.Ok { - return nil, slackerror.NewAPIError(resp.Error, resp.Description, resp.Errors, sandboxListMethod) - } - - if resp.Sandboxes == nil { - return []types.Sandbox{}, nil - } - - return resp.Sandboxes, nil + ListSandboxes(ctx context.Context, token string, filter string) ([]types.Sandbox, error) } type createSandboxResponse struct { @@ -84,8 +45,13 @@ type createSandboxResponse struct { 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, template, eventCode string, archiveDate int64) (teamID, sandboxURL string, err error) { +func (c *Client) CreateSandbox(ctx context.Context, token, name, domain, password, locale, owningOrgID string, templateID int, eventCode string, archiveDate int64) (teamID, sandboxURL string, err error) { var span opentracing.Span span, ctx = opentracing.StartSpanFromContext(ctx, "apiclient.CreateSandbox") defer span.Finish() @@ -101,8 +67,8 @@ func (c *Client) CreateSandbox(ctx context.Context, token, name, domain, passwor if owningOrgID != "" { values.Add("owning_org_id", owningOrgID) } - if template != "" { - values.Add("template", template) + if templateID != 0 { + values.Add("template_id", strconv.Itoa(templateID)) } if eventCode != "" { values.Add("event_code", eventCode) @@ -156,3 +122,37 @@ func (c *Client) DeleteSandbox(ctx context.Context, token, sandboxID string) err 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 + span, ctx = opentracing.StartSpanFromContext(ctx, "apiclient.ListSandboxes") + defer span.Finish() + + values := url.Values{} + values.Add("token", token) + if status != "" { + values.Add("status", status) + } + + b, err := c.postForm(ctx, sandboxListMethod, values) + if err != nil { + return nil, errHTTPRequestFailed.WithRootCause(err) + } + + resp := listSandboxesResponse{} + err = goutils.JSONUnmarshal(b, &resp) + if err != nil { + return nil, errHTTPResponseInvalid.WithRootCause(err).AddAPIMethod(sandboxListMethod) + } + + if !resp.Ok { + return nil, slackerror.NewAPIError(resp.Error, resp.Description, resp.Errors, sandboxListMethod) + } + + if resp.Sandboxes == nil { + return []types.Sandbox{}, nil + } + + return resp.Sandboxes, nil +} diff --git a/internal/api/sandbox_test.go b/internal/api/sandbox_test.go new file mode 100644 index 00000000..86180916 --- /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) + 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) + 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) + 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].SandboxTeamID) + require.Equal(t, "Sandbox 1", sandboxes[0].SandboxName) + require.Equal(t, "sb1", sandboxes[0].SandboxDomain) + require.Equal(t, "active", sandboxes[0].Status) + require.Equal(t, "T2", sandboxes[1].SandboxTeamID) +} + +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 + }) +} From 2e50c5b68a51e990d676029cde45d35481e304cc Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 11:48:58 -0400 Subject: [PATCH 06/20] support partner flag, handle template ID --- cmd/sandbox/create.go | 40 +++++++- cmd/sandbox/create_test.go | 187 +++++++++++++++++++++++++++++----- cmd/sandbox/delete.go | 2 +- cmd/sandbox/delete_test.go | 6 +- internal/api/api_mock.go | 4 +- internal/api/sandbox.go | 7 +- internal/api/sandbox_test.go | 6 +- internal/slackerror/errors.go | 6 ++ 8 files changed, 215 insertions(+), 43 deletions(-) diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index b9a045d8..5b80685f 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -33,14 +33,39 @@ type createFlags struct { password string locale string owningOrgID string - templateID int + template string eventCode string archiveTTL string // TTL duration, e.g. 1d, 2h 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 +} + +// 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.ErrInvalidTemplateID). + WithMessage("Invalid template: %q", template) +} + func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "create [flags]", @@ -61,13 +86,14 @@ func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command { } 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.domain, "domain", "", "Team domain. If not provided, will be derived from org name") 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().IntVar(&createCmdFlags.templateID, "template", 0, "Template ID for pre-defined data to preload") + cmd.Flags().StringVar(&createCmdFlags.template, "template", "", "Template for pre-defined data to preload (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; sandbox will be archived at end of day 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().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") @@ -118,15 +144,21 @@ func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { } } + templateID, err := getTemplateID(createCmdFlags.template) + if err != nil { + return err + } + teamID, sandboxURL, err := clients.API().CreateSandbox(ctx, auth.Token, createCmdFlags.name, domain, createCmdFlags.password, createCmdFlags.locale, createCmdFlags.owningOrgID, - createCmdFlags.templateID, + templateID, createCmdFlags.eventCode, archiveEpochDatetime, + createCmdFlags.partner, ) if err != nil { return err diff --git a/cmd/sandbox/create_test.go b/cmd/sandbox/create_test.go index a822b0e2..4bd354d2 100644 --- a/cmd/sandbox/create_test.go +++ b/cmd/sandbox/create_test.go @@ -43,7 +43,7 @@ func TestCreateCommand(t *testing.T) { 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)). + 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) @@ -54,7 +54,7 @@ func TestCreateCommand(t *testing.T) { 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)) + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "test-box", "test-box", "mypass", "", "", 0, "", int64(0), false) }, }, "create with json-box": { @@ -70,7 +70,7 @@ func TestCreateCommand(t *testing.T) { 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", "", "", 0, "", int64(0)). + cm.API.On("CreateSandbox", mock.Anything, testToken, "json-box", "json-box", "secret", "", "", 0, "", int64(0), false). Return("T456", "https://json-box.slack.com", nil) cm.AddDefaultMocks() @@ -79,7 +79,33 @@ func TestCreateCommand(t *testing.T) { }, ExpectedStdoutOutputs: []string{"T456", "https://json-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", "json-box", "json-box", "secret", "", "", 0, "", int64(0)) + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "json-box", "json-box", "secret", "", "", 0, "", int64(0), false) + }, + }, + "create with partner": { + 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("T999", "https://partner-box.slack.com", nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"T999", "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 derived domain": { @@ -95,7 +121,7 @@ func TestCreateCommand(t *testing.T) { 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)). + 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() @@ -103,7 +129,33 @@ func TestCreateCommand(t *testing.T) { 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)) + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "My Test Box", "my-test-box", "pass", "", "", 0, "", int64(0), false) + }, + }, + "create with partner": { + 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("T999", "https://partner-box.slack.com", nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"T999", "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 a relative time-to-live value": { @@ -120,7 +172,7 @@ func TestCreateCommand(t *testing.T) { 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 })). + 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() @@ -128,7 +180,7 @@ func TestCreateCommand(t *testing.T) { 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 })) + 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": { @@ -144,7 +196,7 @@ func TestCreateCommand(t *testing.T) { 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)). + cm.API.On("CreateSandbox", mock.Anything, testToken, "err-box", "err-box", "pass", "", "", 0, "", int64(0), false). Return("", "", errors.New("api_error")) cm.AddDefaultMocks() @@ -153,21 +205,21 @@ func TestCreateCommand(t *testing.T) { }, ExpectedErrorStrings: []string{"api_error"}, }, - "create with template": { + "create with template default": { CmdArgs: []string{ "--experiment=sandboxes", "--token", "xoxb-test-token", "--name", "tpl-box", "--domain", "tpl-box", "--password", "pass", - "--template", "42", + "--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", "", "", 42, "", int64(0)). + 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() @@ -176,57 +228,107 @@ func TestCreateCommand(t *testing.T) { }, 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", "", "", 42, "", int64(0)) + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "tpl-box", "tpl-box", "pass", "", "", 1, "", int64(0), false) }, }, - "create with archive-date": { + "create with partner flag": { CmdArgs: []string{ "--experiment=sandboxes", "--token", "xoxb-test-token", - "--name", "date-box", - "--domain", "date-box", + "--name", "partner-box", + "--domain", "partner-box", "--password", "pass", - "--archive-date", "2025-12-31", + "--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, "date-box", "date-box", "pass", "", "", 0, "", int64(1767139200)). - Return("T222", "https://date-box.slack.com", nil) + 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 template empty": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tmpl-box", + "--domain", "tmpl-box", + "--password", "pass", + "--template", "empty", + }, + 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, "tmpl-box", "tmpl-box", "pass", "", "", 0, "", int64(0), false). + Return("T444", "https://tmpl-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, "", int64(1767139200)) + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "tmpl-box", "tmpl-box", "pass", "", "", 0, "", int64(0), false) }, }, - "create with template": { + "create with invalid template fails": { CmdArgs: []string{ "--experiment=sandboxes", "--token", "xoxb-test-token", "--name", "tmpl-box", "--domain", "tmpl-box", "--password", "pass", - "--template", "42", + "--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.API.On("CreateSandbox", mock.Anything, testToken, "tmpl-box", "tmpl-box", "pass", "", "", 42, "", int64(0)). - Return("T333", "https://tmpl-box.slack.com", nil) cm.AddDefaultMocks() cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) }, + ExpectedErrorStrings: []string{"Invalid template", "default, empty"}, ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { - cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "tmpl-box", "tmpl-box", "pass", "", "", 42, "", int64(0)) + 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", "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", "", "", 0, "", int64(1767139200), 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, "", int64(1767139200), false) }, }, "create with both archive and archive-date fails": { @@ -251,7 +353,7 @@ func TestCreateCommand(t *testing.T) { }, 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) + 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 archive value": { @@ -275,7 +377,7 @@ func TestCreateCommand(t *testing.T) { }, 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) + 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": { @@ -289,7 +391,7 @@ func TestCreateCommand(t *testing.T) { }, 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) + 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 { @@ -345,6 +447,35 @@ func Test_getEpochFromDate(t *testing.T) { } } +func Test_getTemplateID(t *testing.T) { + tests := []struct { + name string + in string + want int + wantErr bool + }{ + {"empty string", "", 0, false}, + {"default", "default", 1, false}, + {"empty", "empty", 0, false}, + {"default case insensitive", "Default", 1, false}, + {"default case insensitive uppercase", "DEFAULT", 1, false}, + {"empty case insensitive", "Empty", 0, false}, + {"empty case insensitive uppercase", "EMPTY", 0, false}, + {"invalid", "invalid", 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getTemplateID(tt.in) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + func Test_domainFromName(t *testing.T) { tests := []struct { name string diff --git a/cmd/sandbox/delete.go b/cmd/sandbox/delete.go index 24135dc3..3b5ca8b0 100644 --- a/cmd/sandbox/delete.go +++ b/cmd/sandbox/delete.go @@ -99,7 +99,7 @@ func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ Emoji: "white_check_mark", - Text: "Sandbox deleted", + Text: "Sandbox Deleted", Secondary: []string{ "Sandbox " + deleteCmdFlags.sandboxID + " has been permanently deleted", }, diff --git a/cmd/sandbox/delete_test.go b/cmd/sandbox/delete_test.go index 975051e1..91c0ea71 100644 --- a/cmd/sandbox/delete_test.go +++ b/cmd/sandbox/delete_test.go @@ -49,7 +49,7 @@ func TestDeleteCommand(t *testing.T) { cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) }, - ExpectedStdoutOutputs: []string{"Sandbox deleted", "T123", "No sandboxes found"}, + 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") @@ -86,7 +86,7 @@ func TestDeleteCommand(t *testing.T) { cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) }, - ExpectedStdoutOutputs: []string{"Sandbox deleted", "T123", "other-sandbox", "T456"}, + 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", "") @@ -135,7 +135,7 @@ func TestDeleteCommand(t *testing.T) { cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) }, - ExpectedStdoutOutputs: []string{"Sandbox deleted", "E0123456"}, + 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") diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index 171753c1..cfcee4df 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -220,8 +220,8 @@ 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) +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) } diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go index 0fa8c486..2bd88915 100644 --- a/internal/api/sandbox.go +++ b/internal/api/sandbox.go @@ -33,7 +33,7 @@ const ( // 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) (teamID, sandboxURL string, err error) + 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) } @@ -51,7 +51,7 @@ type listSandboxesResponse struct { } // 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) (teamID, sandboxURL string, err error) { +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() @@ -76,6 +76,9 @@ func (c *Client) CreateSandbox(ctx context.Context, token, name, domain, passwor 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 { diff --git a/internal/api/sandbox_test.go b/internal/api/sandbox_test.go index 86180916..3bf2cf6d 100644 --- a/internal/api/sandbox_test.go +++ b/internal/api/sandbox_test.go @@ -29,7 +29,7 @@ func TestClient_CreateSandbox_Ok(t *testing.T) { 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) + 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) @@ -42,7 +42,7 @@ func TestClient_CreateSandbox_WithOptionalParams(t *testing.T) { 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) + 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) @@ -51,7 +51,7 @@ func TestClient_CreateSandbox_WithOptionalParams(t *testing.T) { 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) + _, _, err := c.CreateSandbox(ctx, "token", "name", "domain", "password", "", "", 0, "", 0, false) return err }) } diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 360572bd..86c97860 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" + ErrInvalidTemplateID = "invalid_template_id" ErrInvalidUserID = "invalid_user_id" ErrInvalidWebhookConfig = "invalid_webhook_config" ErrInvalidWebhookSchemaRef = "invalid_webhook_schema_ref" @@ -1044,6 +1045,11 @@ Otherwise start your app for local development with: %s`, Message: "The provided trigger type is not recognized", }, + ErrInvalidTemplateID: { + Code: ErrInvalidTemplateID, + Message: "The provided sandbox template value is invalid", + }, + ErrInvalidUserID: { Code: ErrInvalidUserID, Message: "A value passed as a user_id is invalid", From ca46e138bc2ea1b6e1318da39804814e87686c0a Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 13:04:28 -0400 Subject: [PATCH 07/20] update archive-ttl support --- cmd/sandbox/create.go | 78 +++++++++++++++++++++++------------ cmd/sandbox/create_test.go | 36 +++++++++++----- internal/slackerror/errors.go | 7 ++++ 3 files changed, 85 insertions(+), 36 deletions(-) diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index 5b80685f..bdb3d94f 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -35,7 +35,7 @@ type createFlags struct { owningOrgID string template string eventCode string - archiveTTL string // TTL duration, e.g. 1d, 2h + archiveTTL string // TTL duration, e.g. 1d, 2w, 3mo archiveDate string // explicit date yyyy-mm-dd partner bool } @@ -91,8 +91,8 @@ func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command { cmd.Flags().StringVar(&createCmdFlags.locale, "locale", "", "Locale (eg. en-us, languageCode-countryCode)") cmd.Flags().StringVar(&createCmdFlags.template, "template", "", "Template for pre-defined data to preload (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; sandbox will be archived at end of day 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.archiveTTL, "archive-ttl", "", "Time-to-live duration; sandbox will be archived at end of day after this period (eg. 1d, 3w, 2mo). 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 @@ -127,8 +127,7 @@ func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { 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)") + WithMessage("Cannot use both --archive-ttl and --archive-date") } archiveEpochDatetime := int64(0) @@ -169,30 +168,51 @@ func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { 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. +// 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). +// Minimum is 1 day, maximum is 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") + lower := strings.TrimSpace(strings.ToLower(ttl)) + if lower == "" { + return 0, slackerror.New(slackerror.ErrInvalidArchiveTTL) + } + + 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.ErrInvalidArchiveTTL) } - 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") + 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.ErrInvalidArchiveTTL) + } + 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.ErrInvalidArchiveTTL) } + target = now.AddDate(0, n, 0) + default: + return 0, slackerror.New(slackerror.ErrInvalidArchiveTTL) } - return time.Now().Add(d).Unix(), nil + + maxAllowed := now.AddDate(0, 6, 0) + minAllowed := now.AddDate(0, 0, 1) + if target.Before(minAllowed) { + return 0, slackerror.New(slackerror.ErrInvalidArchiveTTL) + } + if target.After(maxAllowed) { + return 0, slackerror.New(slackerror.ErrInvalidArchiveTTL) + } + + return target.Unix(), nil } // getEpochFromDate parses a date in yyyy-mm-dd format and returns the Unix epoch at start of that day (UTC). @@ -202,7 +222,13 @@ func getEpochFromDate(dateStr string) (int64, error) { 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)") + 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 } diff --git a/cmd/sandbox/create_test.go b/cmd/sandbox/create_test.go index 4bd354d2..c6936fb5 100644 --- a/cmd/sandbox/create_test.go +++ b/cmd/sandbox/create_test.go @@ -18,6 +18,7 @@ import ( "context" "errors" "testing" + "time" "github.com/slackapi/slack-cli/internal/experiment" "github.com/slackapi/slack-cli/internal/shared" @@ -165,7 +166,7 @@ func TestCreateCommand(t *testing.T) { "--name", "tmp-box", "--domain", "tmp-box", "--password", "pass", - "--archive-ttl", "24h", + "--archive-ttl", "1d", }, Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { testToken := "xoxb-test-token" @@ -405,9 +406,14 @@ func Test_getEpochFromTTL(t *testing.T) { ttl string wantErr bool }{ - {"24h", "24h", false}, {"1d", "1d", false}, {"7d", "7d", false}, + {"1w", "1w", false}, + {"2w", "2w", false}, + {"1mo", "1mo", false}, + {"6mo", "6mo", false}, + {"hours rejected", "12h", true}, + {"7mo exceeds max", "7mo", true}, {"invalid", "invalid", true}, } for _, tt := range tests { @@ -425,20 +431,30 @@ func Test_getEpochFromTTL(t *testing.T) { func Test_getEpochFromDate(t *testing.T) { tests := []struct { - name string - dateStr string - want int64 - wantErr bool + name string + dateStr string + want int64 + wantErr bool + errContain string }{ - {"valid", "2025-12-31", 1767139200, false}, // 2025-12-31 00:00:00 UTC - {"invalid format", "12-31-2025", 0, true}, - {"invalid date", "not-a-date", 0, true}, + {"valid", "2025-12-31", 1767139200, false, ""}, // 2025-12-31 00:00:00 UTC + {"invalid format", "12-31-2025", 0, true, ""}, + {"invalid date", "not-a-date", 0, true, ""}, + {"date in past", "2020-01-01", 0, true, "at least 1 day"}, + {"date is today", "", 0, true, "at least 1 day"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := getEpochFromDate(tt.dateStr) + dateStr := tt.dateStr + if tt.name == "date is today" { + dateStr = time.Now().UTC().Format("2006-01-02") + } + got, err := getEpochFromDate(dateStr) if tt.wantErr { assert.Error(t, err) + if tt.errContain != "" { + assert.ErrorContains(t, err, tt.errContain) + } return } assert.NoError(t, err) diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 86c97860..9798f5c3 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -270,6 +270,7 @@ const ( ErrWorkflowNotFound = "workflow_not_found" ErrYaml = "yaml_error" ErrSandboxDomainTaken = "domain_taken" + ErrInvalidArchiveTTL = "invalid_archive_ttl" ) var ErrorCodeMap = map[string]Error{ @@ -1626,4 +1627,10 @@ Otherwise start your app for local development with: %s`, Code: ErrSandboxDomainTaken, Message: "This domain has been claimed by another sandbox", }, + + ErrInvalidArchiveTTL: { + Code: ErrInvalidArchiveTTL, + Message: "Invalid TTL", + Remediation: "Use days (1d), weeks (2w), or months (3mo); min 1d, max 6mo", + }, } From 43e9bac416d3e8d34801b9e46bc4a637f6958190 Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 13:52:18 -0400 Subject: [PATCH 08/20] fix tests, alphabetize --- cmd/sandbox/create_test.go | 54 +++++++++++--------------------------- cmd/sandbox/sandbox.go | 2 +- internal/api/api_mock.go | 10 +++---- 3 files changed, 21 insertions(+), 45 deletions(-) diff --git a/cmd/sandbox/create_test.go b/cmd/sandbox/create_test.go index c6936fb5..ce994b2a 100644 --- a/cmd/sandbox/create_test.go +++ b/cmd/sandbox/create_test.go @@ -30,6 +30,10 @@ import ( ) 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{ @@ -133,32 +137,6 @@ func TestCreateCommand(t *testing.T) { cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "My Test Box", "my-test-box", "pass", "", "", 0, "", int64(0), false) }, }, - "create with partner": { - 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("T999", "https://partner-box.slack.com", nil) - - cm.AddDefaultMocks() - cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} - cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) - }, - ExpectedStdoutOutputs: []string{"T999", "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 a relative time-to-live value": { CmdArgs: []string{ "--experiment=sandboxes", @@ -302,7 +280,7 @@ func TestCreateCommand(t *testing.T) { cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) }, - ExpectedErrorStrings: []string{"Invalid template", "default, empty"}, + 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) }, @@ -314,14 +292,14 @@ func TestCreateCommand(t *testing.T) { "--name", "date-box", "--domain", "date-box", "--password", "pass", - "--archive-date", "2025-12-31", + "--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, "", int64(1767139200), false). + 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() @@ -329,7 +307,7 @@ func TestCreateCommand(t *testing.T) { 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, "", int64(1767139200), false) + 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": { @@ -430,6 +408,9 @@ func Test_getEpochFromTTL(t *testing.T) { } 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") + tests := []struct { name string dateStr string @@ -437,18 +418,14 @@ func Test_getEpochFromDate(t *testing.T) { wantErr bool errContain string }{ - {"valid", "2025-12-31", 1767139200, false, ""}, // 2025-12-31 00:00:00 UTC - {"invalid format", "12-31-2025", 0, true, ""}, - {"invalid date", "not-a-date", 0, true, ""}, - {"date in past", "2020-01-01", 0, true, "at least 1 day"}, - {"date is today", "", 0, true, "at least 1 day"}, + {"valid", validDateStr, validDate.Unix(), false, ""}, + {"invalid format", "12-31-2025", 0, true, "invalid"}, + {"invalid date", "not-a-date", 0, true, "invalid"}, + {"date in past", "2020-01-01", 0, true, "Archive date must be in the future"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { dateStr := tt.dateStr - if tt.name == "date is today" { - dateStr = time.Now().UTC().Format("2006-01-02") - } got, err := getEpochFromDate(dateStr) if tt.wantErr { assert.Error(t, err) @@ -503,7 +480,6 @@ func Test_domainFromName(t *testing.T) { {"spaces", "My Test Box", "my-test-box", false}, {"uppercase", "MyBox", "mybox", false}, {"mixed", "Hello_World 123", "hello-world-123", false}, - {"hyphens", "a--b", "a-b", false}, {"leading trailing", "-test-", "test", false}, {"empty", "", "", true}, } diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index b6614fb0..27ed0cfb 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -48,9 +48,9 @@ New to the Developer Program? Sign up at }, } - cmd.AddCommand(NewListCommand(clients)) cmd.AddCommand(NewCreateCommand(clients)) cmd.AddCommand(NewDeleteCommand(clients)) + cmd.AddCommand(NewListCommand(clients)) return cmd } diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index cfcee4df..c68b8953 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -215,11 +215,6 @@ func (m *APIMock) FunctionDistributionRemoveUsers(ctx context.Context, callbackI // SandboxClient -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) -} - 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) @@ -230,6 +225,11 @@ func (m *APIMock) DeleteSandbox(ctx context.Context, token, sandboxID string) er 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) +} + // DatastoresClient func (m *APIMock) AppsDatastorePut(ctx context.Context, token string, request types.AppDatastorePut) (types.AppDatastorePutResult, error) { From bf34f277df96c49a65503ee24c3100c7f1df4365 Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 14:05:08 -0400 Subject: [PATCH 09/20] update spacing for emoji --- cmd/sandbox/create.go | 2 +- cmd/sandbox/list.go | 2 +- internal/style/style.go | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index bdb3d94f..3986553a 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -256,7 +256,7 @@ func printCreateSuccess(cmd *cobra.Command, clients *shared.ClientFactory, teamI ctx := cmd.Context() clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ Emoji: "beach_with_umbrella", - Text: " Sandbox Created", + Text: "Sandbox Created", Secondary: []string{ fmt.Sprintf("Team ID: %s", teamID), fmt.Sprintf("URL: %s", url), diff --git a/cmd/sandbox/list.go b/cmd/sandbox/list.go index 970f70bc..e80ed2d1 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 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 From b1b57a7eee0219a472a0169ab2d573b7b517980d Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 14:35:16 -0400 Subject: [PATCH 10/20] update tests --- cmd/sandbox/create_test.go | 349 +++++++++++++++++++++++++++++-------- 1 file changed, 274 insertions(+), 75 deletions(-) diff --git a/cmd/sandbox/create_test.go b/cmd/sandbox/create_test.go index ce994b2a..d168d917 100644 --- a/cmd/sandbox/create_test.go +++ b/cmd/sandbox/create_test.go @@ -378,86 +378,286 @@ func TestCreateCommand(t *testing.T) { }) } +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) { - tests := []struct { - name string - ttl string - wantErr bool - }{ - {"1d", "1d", false}, - {"7d", "7d", false}, - {"1w", "1w", false}, - {"2w", "2w", false}, - {"1mo", "1mo", false}, - {"6mo", "6mo", false}, - {"hours rejected", "12h", true}, - {"7mo exceeds max", "7mo", true}, - {"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") - }) - } + 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) + }, + }, + "7d": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "ttl-box", + "--domain", "ttl-box", + "--password", "pass", + "--archive-ttl", "7d", + }, + 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) + }, + }, + "2w": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "ttl-box", + "--domain", "ttl-box", + "--password", "pass", + "--archive-ttl", "2w", + }, + 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) + }, + }, + "1mo": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "ttl-box", + "--domain", "ttl-box", + "--password", "pass", + "--archive-ttl", "1mo", + }, + 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) + }, + }, + "hours rejected": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "ttl-box", + "--domain", "ttl-box", + "--password", "pass", + "--archive-ttl", "12h", + }, + 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) + }, + }, + "7mo exceeds max": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "ttl-box", + "--domain", "ttl-box", + "--password", "pass", + "--archive-ttl", "7mo", + }, + 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) + }, + }, + "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() - tests := []struct { - name string - dateStr string - want int64 - wantErr bool - errContain string - }{ - {"valid", validDateStr, validDate.Unix(), false, ""}, - {"invalid format", "12-31-2025", 0, true, "invalid"}, - {"invalid date", "not-a-date", 0, true, "invalid"}, - {"date in past", "2020-01-01", 0, true, "Archive date must be in the future"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dateStr := tt.dateStr - got, err := getEpochFromDate(dateStr) - if tt.wantErr { - assert.Error(t, err) - if tt.errContain != "" { - assert.ErrorContains(t, err, tt.errContain) - } - return - } - assert.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } + 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) { - tests := []struct { - name string + tests := map[string]struct { in string want int wantErr bool }{ - {"empty string", "", 0, false}, - {"default", "default", 1, false}, - {"empty", "empty", 0, false}, - {"default case insensitive", "Default", 1, false}, - {"default case insensitive uppercase", "DEFAULT", 1, false}, - {"empty case insensitive", "Empty", 0, false}, - {"empty case insensitive uppercase", "EMPTY", 0, false}, - {"invalid", "invalid", 0, true}, + "empty string": {"", 0, false}, + "default": {"default", 1, false}, + "empty": {"empty", 0, false}, + "default case insensitive": {"Default", 1, false}, + "default case insensitive uppercase": {"DEFAULT", 1, false}, + "empty case insensitive": {"Empty", 0, false}, + "empty case insensitive uppercase": {"EMPTY", 0, false}, + "invalid": {"invalid", 0, true}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + for name, tt := range tests { + t.Run(name, func(t *testing.T) { got, err := getTemplateID(tt.in) if tt.wantErr { assert.Error(t, err) @@ -470,21 +670,20 @@ func Test_getTemplateID(t *testing.T) { } func Test_domainFromName(t *testing.T) { - tests := []struct { - name string + tests := map[string]struct { in string want string wantErr bool }{ - {"simple", "test-box", "test-box", false}, - {"spaces", "My Test Box", "my-test-box", false}, - {"uppercase", "MyBox", "mybox", false}, - {"mixed", "Hello_World 123", "hello-world-123", false}, - {"leading trailing", "-test-", "test", false}, - {"empty", "", "", true}, + "simple": {"test-box", "test-box", false}, + "spaces": {"My Test Box", "my-test-box", false}, + "uppercase": {"MyBox", "mybox", false}, + "mixed": {"Hello_World 123", "hello-world-123", false}, + "leading trailing": {"-test-", "test", false}, + "empty": {"", "", true}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + for name, tt := range tests { + t.Run(name, func(t *testing.T) { got, err := domainFromName(tt.in) if tt.wantErr { assert.Error(t, err) From 6bd1ac54e8a4011dfec89b94e109b03c1985a8bc Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 14:44:28 -0400 Subject: [PATCH 11/20] lint --- cmd/sandbox/create_test.go | 14 +++++++------- internal/slackerror/errors.go | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/cmd/sandbox/create_test.go b/cmd/sandbox/create_test.go index d168d917..c5b704c1 100644 --- a/cmd/sandbox/create_test.go +++ b/cmd/sandbox/create_test.go @@ -647,14 +647,14 @@ func Test_getTemplateID(t *testing.T) { want int wantErr bool }{ - "empty string": {"", 0, false}, - "default": {"default", 1, false}, - "empty": {"empty", 0, false}, - "default case insensitive": {"Default", 1, false}, + "empty string": {"", 0, false}, + "default": {"default", 1, false}, + "empty": {"empty", 0, false}, + "default case insensitive": {"Default", 1, false}, "default case insensitive uppercase": {"DEFAULT", 1, false}, - "empty case insensitive": {"Empty", 0, false}, - "empty case insensitive uppercase": {"EMPTY", 0, false}, - "invalid": {"invalid", 0, true}, + "empty case insensitive": {"Empty", 0, false}, + "empty case insensitive uppercase": {"EMPTY", 0, false}, + "invalid": {"invalid", 0, true}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 9798f5c3..c83d5f7a 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -270,7 +270,7 @@ const ( ErrWorkflowNotFound = "workflow_not_found" ErrYaml = "yaml_error" ErrSandboxDomainTaken = "domain_taken" - ErrInvalidArchiveTTL = "invalid_archive_ttl" + ErrInvalidArchiveTTL = "invalid_archive_ttl" ) var ErrorCodeMap = map[string]Error{ From 521833bd0f227f80d2728140932bec0791776863 Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 14:53:42 -0400 Subject: [PATCH 12/20] update archive date validation --- cmd/sandbox/create.go | 9 --------- cmd/sandbox/create_test.go | 17 ----------------- 2 files changed, 26 deletions(-) diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index 3986553a..59d74d3b 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -203,15 +203,6 @@ func getEpochFromTTL(ttl string) (int64, error) { return 0, slackerror.New(slackerror.ErrInvalidArchiveTTL) } - maxAllowed := now.AddDate(0, 6, 0) - minAllowed := now.AddDate(0, 0, 1) - if target.Before(minAllowed) { - return 0, slackerror.New(slackerror.ErrInvalidArchiveTTL) - } - if target.After(maxAllowed) { - return 0, slackerror.New(slackerror.ErrInvalidArchiveTTL) - } - return target.Unix(), nil } diff --git a/cmd/sandbox/create_test.go b/cmd/sandbox/create_test.go index c5b704c1..438d0af5 100644 --- a/cmd/sandbox/create_test.go +++ b/cmd/sandbox/create_test.go @@ -523,23 +523,6 @@ func Test_getEpochFromTTL(t *testing.T) { 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) }, }, - "7mo exceeds max": { - CmdArgs: []string{ - "--experiment=sandboxes", - "--token", "xoxb-test-token", - "--name", "ttl-box", - "--domain", "ttl-box", - "--password", "pass", - "--archive-ttl", "7mo", - }, - 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) - }, - }, "invalid": { CmdArgs: []string{ "--experiment=sandboxes", From f6b4d212e26a10b24aee779a4d87814d894a1ce5 Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 15:19:47 -0400 Subject: [PATCH 13/20] prompt for required flags --- cmd/sandbox/create.go | 45 +++++++++++++++++++++++++---------- internal/slackerror/errors.go | 6 +++++ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index 59d74d3b..6fc03f23 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -21,6 +21,7 @@ import ( "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" @@ -98,13 +99,6 @@ func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command { // 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("password"); err != nil { - panic(err) - } - return cmd } @@ -116,10 +110,38 @@ func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { 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(createCmdFlags.name) + domain, err = domainFromName(name) if err != nil { return err } @@ -149,9 +171,9 @@ func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { } teamID, sandboxURL, err := clients.API().CreateSandbox(ctx, auth.Token, - createCmdFlags.name, + name, domain, - createCmdFlags.password, + password, createCmdFlags.locale, createCmdFlags.owningOrgID, templateID, @@ -170,7 +192,6 @@ func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { // 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). -// Minimum is 1 day, maximum is 6 months. func getEpochFromTTL(ttl string) (int64, error) { lower := strings.TrimSpace(strings.ToLower(ttl)) if lower == "" { @@ -206,7 +227,7 @@ func getEpochFromTTL(ttl string) (int64, error) { return target.Unix(), nil } -// getEpochFromDate parses a date in yyyy-mm-dd format and returns the Unix epoch at start of that day (UTC). +// 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) diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index c83d5f7a..948c0180 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -270,6 +270,7 @@ const ( ErrWorkflowNotFound = "workflow_not_found" ErrYaml = "yaml_error" ErrSandboxDomainTaken = "domain_taken" + ErrAtActiveSandboxLimit = "at_active_sandbox_limit" ErrInvalidArchiveTTL = "invalid_archive_ttl" ) @@ -1628,6 +1629,11 @@ Otherwise start your app for local development with: %s`, Message: "This domain has been claimed by another sandbox", }, + ErrAtActiveSandboxLimit: { + Code: ErrAtActiveSandboxLimit, + Message: "You've reached the maximum number of active sandboxes", + }, + ErrInvalidArchiveTTL: { Code: ErrInvalidArchiveTTL, Message: "Invalid TTL", From ee75a8e583b67209141a51120ce74f79186124b4 Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 16:36:23 -0400 Subject: [PATCH 14/20] prompt when id missing for delete cmd --- cmd/sandbox/create.go | 36 +++++++++++++++++------------------ cmd/sandbox/delete.go | 24 ++++++++++++++++------- internal/slackerror/errors.go | 7 +++++++ 3 files changed, 42 insertions(+), 25 deletions(-) diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index 6fc03f23..0ff14302 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -49,24 +49,6 @@ var templateNameToID = map[string]int{ "empty": 0, // The sandbox will be empty if the template param is not set } -// 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.ErrInvalidTemplateID). - WithMessage("Invalid template: %q", template) -} - func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "create [flags]", @@ -264,6 +246,24 @@ func domainFromName(name string) (string, error) { 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.ErrInvalidTemplateID). + 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{ diff --git a/cmd/sandbox/delete.go b/cmd/sandbox/delete.go index 3b5ca8b0..6f38f740 100644 --- a/cmd/sandbox/delete.go +++ b/cmd/sandbox/delete.go @@ -51,10 +51,6 @@ func NewDeleteCommand(clients *shared.ClientFactory) *cobra.Command { 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 } @@ -66,13 +62,27 @@ func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { 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", deleteCmdFlags.sandboxID), + fmt.Sprintf("Sandbox (%s) and all of its data will be permanently deleted", sandboxID), "This cannot be undone", }, })) @@ -93,7 +103,7 @@ func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { } } - if err := clients.API().DeleteSandbox(ctx, auth.Token, deleteCmdFlags.sandboxID); err != nil { + if err := clients.API().DeleteSandbox(ctx, auth.Token, sandboxID); err != nil { return err } @@ -101,7 +111,7 @@ func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { Emoji: "white_check_mark", Text: "Sandbox Deleted", Secondary: []string{ - "Sandbox " + deleteCmdFlags.sandboxID + " has been permanently deleted", + "Sandbox " + sandboxID + " has been permanently deleted", }, })) diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 948c0180..6a332cfa 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -271,6 +271,7 @@ const ( ErrYaml = "yaml_error" ErrSandboxDomainTaken = "domain_taken" ErrAtActiveSandboxLimit = "at_active_sandbox_limit" + ErrInvalidSandboxTeamID = "invalid_sandbox_team_id" ErrInvalidArchiveTTL = "invalid_archive_ttl" ) @@ -1634,6 +1635,12 @@ Otherwise start your app for local development with: %s`, 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)), + }, + ErrInvalidArchiveTTL: { Code: ErrInvalidArchiveTTL, Message: "Invalid TTL", From 1b65aa85d22dbb6e22ccec1d198a7b7fa3cf0b98 Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 16:38:20 -0400 Subject: [PATCH 15/20] rename err --- cmd/sandbox/create.go | 12 ++++++------ internal/slackerror/errors.go | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index 0ff14302..79708271 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -177,7 +177,7 @@ func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { func getEpochFromTTL(ttl string) (int64, error) { lower := strings.TrimSpace(strings.ToLower(ttl)) if lower == "" { - return 0, slackerror.New(slackerror.ErrInvalidArchiveTTL) + return 0, slackerror.New(slackerror.ErrInvalidSandboxArchiveTTL) } var target time.Time @@ -187,23 +187,23 @@ func getEpochFromTTL(ttl string) (int64, error) { case strings.HasSuffix(lower, "d"): n, err := strconv.Atoi(strings.TrimSuffix(lower, "d")) if err != nil || n < 1 { - return 0, slackerror.New(slackerror.ErrInvalidArchiveTTL) + 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.ErrInvalidArchiveTTL) + 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.ErrInvalidArchiveTTL) + return 0, slackerror.New(slackerror.ErrInvalidSandboxArchiveTTL) } target = now.AddDate(0, n, 0) default: - return 0, slackerror.New(slackerror.ErrInvalidArchiveTTL) + return 0, slackerror.New(slackerror.ErrInvalidSandboxArchiveTTL) } return target.Unix(), nil @@ -260,7 +260,7 @@ func getTemplateID(template string) (int, error) { if id, err := strconv.Atoi(key); err == nil { return id, nil } - return 0, slackerror.New(slackerror.ErrInvalidTemplateID). + return 0, slackerror.New(slackerror.ErrInvalidSandboxTemplateID). WithMessage("Invalid template: %q", template) } diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index 6a332cfa..507b75dd 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -161,7 +161,7 @@ const ( ErrInvalidTriggerEventType = "invalid_trigger_event_type" ErrInvalidTriggerInputs = "invalid_trigger_inputs" ErrInvalidTriggerType = "invalid_trigger_type" - ErrInvalidTemplateID = "invalid_template_id" + ErrInvalidSandboxTemplateID = "invalid_template_id" ErrInvalidUserID = "invalid_user_id" ErrInvalidWebhookConfig = "invalid_webhook_config" ErrInvalidWebhookSchemaRef = "invalid_webhook_schema_ref" @@ -272,7 +272,7 @@ const ( ErrSandboxDomainTaken = "domain_taken" ErrAtActiveSandboxLimit = "at_active_sandbox_limit" ErrInvalidSandboxTeamID = "invalid_sandbox_team_id" - ErrInvalidArchiveTTL = "invalid_archive_ttl" + ErrInvalidSandboxArchiveTTL = "invalid_archive_ttl" ) var ErrorCodeMap = map[string]Error{ @@ -1048,8 +1048,8 @@ Otherwise start your app for local development with: %s`, Message: "The provided trigger type is not recognized", }, - ErrInvalidTemplateID: { - Code: ErrInvalidTemplateID, + ErrInvalidSandboxTemplateID: { + Code: ErrInvalidSandboxTemplateID, Message: "The provided sandbox template value is invalid", }, @@ -1641,8 +1641,8 @@ Otherwise start your app for local development with: %s`, Remediation: fmt.Sprintf("List your sandboxes with the %s command to find the ID", style.Commandf("sandbox list", false)), }, - ErrInvalidArchiveTTL: { - Code: ErrInvalidArchiveTTL, + ErrInvalidSandboxArchiveTTL: { + Code: ErrInvalidSandboxArchiveTTL, Message: "Invalid TTL", Remediation: "Use days (1d), weeks (2w), or months (3mo); min 1d, max 6mo", }, From 9b6634f1afc4c1d91536ca7f0d49f05620312e56 Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 16:42:03 -0400 Subject: [PATCH 16/20] rename Sandbox struct fields --- cmd/sandbox/delete_test.go | 6 +++--- cmd/sandbox/list.go | 6 +++--- cmd/sandbox/list_test.go | 12 ++++++------ internal/api/sandbox_test.go | 8 ++++---- internal/shared/types/sandbox.go | 6 +++--- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/cmd/sandbox/delete_test.go b/cmd/sandbox/delete_test.go index 91c0ea71..1dfb06a7 100644 --- a/cmd/sandbox/delete_test.go +++ b/cmd/sandbox/delete_test.go @@ -71,9 +71,9 @@ func TestDeleteCommand(t *testing.T) { cm.API.On("DeleteSandbox", mock.Anything, testToken, "T123").Return(nil) sandboxes := []types.Sandbox{ { - SandboxTeamID: "T456", - SandboxName: "other-sandbox", - SandboxDomain: "other-sandbox", + TeamID: "T456", + Name: "other-sandbox", + Domain: "other-sandbox", Status: "active", DateCreated: 1700000000, DateArchived: 0, diff --git a/cmd/sandbox/list.go b/cmd/sandbox/list.go index e80ed2d1..e35359bd 100644 --- a/cmd/sandbox/list.go +++ b/cmd/sandbox/list.go @@ -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..4404ca95 100644 --- a/cmd/sandbox/list_test.go +++ b/cmd/sandbox/list_test.go @@ -58,9 +58,9 @@ 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", + TeamID: "T123", + Name: "my-sandbox", + Domain: "my-sandbox", Status: "active", DateCreated: 1700000000, DateArchived: 0, @@ -87,9 +87,9 @@ 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", + TeamID: "T456", + Name: "old-sandbox", + Domain: "old-sandbox", Status: "archived", DateCreated: 1700000000, DateArchived: 1710000000, diff --git a/internal/api/sandbox_test.go b/internal/api/sandbox_test.go index 3bf2cf6d..1798f351 100644 --- a/internal/api/sandbox_test.go +++ b/internal/api/sandbox_test.go @@ -84,11 +84,11 @@ func TestClient_ListSandboxes_Ok(t *testing.T) { sandboxes, err := c.ListSandboxes(ctx, "token", "") require.NoError(t, err) require.Len(t, sandboxes, 2) - require.Equal(t, "T1", sandboxes[0].SandboxTeamID) - require.Equal(t, "Sandbox 1", sandboxes[0].SandboxName) - require.Equal(t, "sb1", sandboxes[0].SandboxDomain) + 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].SandboxTeamID) + require.Equal(t, "T2", sandboxes[1].TeamID) } func TestClient_ListSandboxes_Empty(t *testing.T) { diff --git a/internal/shared/types/sandbox.go b/internal/shared/types/sandbox.go index d214e1f4..12a2d888 100644 --- a/internal/shared/types/sandbox.go +++ b/internal/shared/types/sandbox.go @@ -18,8 +18,8 @@ package types type Sandbox struct { 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"` + Domain string `json:"sandbox_domain"` + Name string `json:"sandbox_name"` + TeamID string `json:"sandbox_team_id"` Status string `json:"status"` } From 74248b8b5c214666ff575fca3803ffb9f091b8bb Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 16:50:15 -0400 Subject: [PATCH 17/20] reword --- cmd/sandbox/create.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index 79708271..72a96e5e 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -69,12 +69,12 @@ func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command { } cmd.Flags().StringVar(&createCmdFlags.name, "name", "", "Organization name for the new sandbox") - cmd.Flags().StringVar(&createCmdFlags.domain, "domain", "", "Team domain. If not provided, will be derived from org name") + 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 for pre-defined data to preload (default, empty)") + cmd.Flags().StringVar(&createCmdFlags.template, "template", "", "Template with sample data to apply to the sandbox (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; sandbox will be archived at end of day after this period (eg. 1d, 3w, 2mo). Cannot be used with --archive-date") + cmd.Flags().StringVar(&createCmdFlags.archiveTTL, "archive-ttl", "", "Time-to-live duration (eg. 1d, 3w, 2mo). 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") From 78376a6a56277d1fa96515e6a6ec6bf6c62f8910 Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 17:02:41 -0400 Subject: [PATCH 18/20] lint --- cmd/sandbox/create.go | 4 ++-- cmd/sandbox/delete_test.go | 12 ++++++------ cmd/sandbox/list_test.go | 24 ++++++++++++------------ internal/shared/types/sandbox.go | 12 ++++++------ 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index 72a96e5e..36dc458f 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -72,9 +72,9 @@ func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command { 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 (default, empty)") + 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, 3w, 2mo). Cannot be used with --archive-date") + 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") diff --git a/cmd/sandbox/delete_test.go b/cmd/sandbox/delete_test.go index 1dfb06a7..c5b0a33f 100644 --- a/cmd/sandbox/delete_test.go +++ b/cmd/sandbox/delete_test.go @@ -71,12 +71,12 @@ func TestDeleteCommand(t *testing.T) { 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, + TeamID: "T456", + Name: "other-sandbox", + Domain: "other-sandbox", + Status: "active", + DateCreated: 1700000000, + DateArchived: 0, }, } cm.API.On("ListSandboxes", mock.Anything, testToken, "").Return(sandboxes, nil) diff --git a/cmd/sandbox/list_test.go b/cmd/sandbox/list_test.go index 4404ca95..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{ { - TeamID: "T123", - Name: "my-sandbox", - Domain: "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{ { - TeamID: "T456", - Name: "old-sandbox", - Domain: "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/internal/shared/types/sandbox.go b/internal/shared/types/sandbox.go index 12a2d888..7ec1033b 100644 --- a/internal/shared/types/sandbox.go +++ b/internal/shared/types/sandbox.go @@ -16,10 +16,10 @@ package types // Sandbox represents a Slack Developer Sandbox type Sandbox struct { - 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"` + 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"` } From 4d7894c761d61fe2313092caa36753e42559e640 Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 17:14:20 -0400 Subject: [PATCH 19/20] clean up tests --- cmd/sandbox/create_test.go | 244 +++++++++++-------------------------- 1 file changed, 73 insertions(+), 171 deletions(-) diff --git a/cmd/sandbox/create_test.go b/cmd/sandbox/create_test.go index 438d0af5..be26c403 100644 --- a/cmd/sandbox/create_test.go +++ b/cmd/sandbox/create_test.go @@ -62,57 +62,6 @@ func TestCreateCommand(t *testing.T) { cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "test-box", "test-box", "mypass", "", "", 0, "", int64(0), false) }, }, - "create with json-box": { - CmdArgs: []string{ - "--experiment=sandboxes", - "--token", "xoxb-test-token", - "--name", "json-box", - "--domain", "json-box", - "--password", "secret", - }, - 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", "", "", 0, "", int64(0), false). - 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{"T456", "https://json-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", "json-box", "json-box", "secret", "", "", 0, "", int64(0), false) - }, - }, - "create with partner": { - 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("T999", "https://partner-box.slack.com", nil) - - cm.AddDefaultMocks() - cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} - cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) - }, - ExpectedStdoutOutputs: []string{"T999", "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 derived domain": { CmdArgs: []string{ "--experiment=sandboxes", @@ -184,7 +133,7 @@ func TestCreateCommand(t *testing.T) { }, ExpectedErrorStrings: []string{"api_error"}, }, - "create with template default": { + "create with 'default' template": { CmdArgs: []string{ "--experiment=sandboxes", "--token", "xoxb-test-token", @@ -236,31 +185,6 @@ func TestCreateCommand(t *testing.T) { cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "partner-box", "partner-box", "pass", "", "", 0, "", int64(0), true) }, }, - "create with template empty": { - CmdArgs: []string{ - "--experiment=sandboxes", - "--token", "xoxb-test-token", - "--name", "tmpl-box", - "--domain", "tmpl-box", - "--password", "pass", - "--template", "empty", - }, - 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, "tmpl-box", "tmpl-box", "pass", "", "", 0, "", int64(0), false). - Return("T444", "https://tmpl-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", "tmpl-box", "tmpl-box", "pass", "", "", 0, "", int64(0), false) - }, - }, "create with invalid template fails": { CmdArgs: []string{ "--experiment=sandboxes", @@ -335,7 +259,7 @@ func TestCreateCommand(t *testing.T) { 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 archive value": { + "create with invalid archive-ttl value fails": { CmdArgs: []string{ "--experiment=sandboxes", "--token", "xoxb-test-token", @@ -421,23 +345,6 @@ func Test_getEpochFromTTL(t *testing.T) { 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) }, }, - "7d": { - CmdArgs: []string{ - "--experiment=sandboxes", - "--token", "xoxb-test-token", - "--name", "ttl-box", - "--domain", "ttl-box", - "--password", "pass", - "--archive-ttl", "7d", - }, - 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", @@ -455,40 +362,6 @@ func Test_getEpochFromTTL(t *testing.T) { 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) }, }, - "2w": { - CmdArgs: []string{ - "--experiment=sandboxes", - "--token", "xoxb-test-token", - "--name", "ttl-box", - "--domain", "ttl-box", - "--password", "pass", - "--archive-ttl", "2w", - }, - 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) - }, - }, - "1mo": { - CmdArgs: []string{ - "--experiment=sandboxes", - "--token", "xoxb-test-token", - "--name", "ttl-box", - "--domain", "ttl-box", - "--password", "pass", - "--archive-ttl", "1mo", - }, - 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", @@ -506,23 +379,6 @@ func Test_getEpochFromTTL(t *testing.T) { 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) }, }, - "hours rejected": { - CmdArgs: []string{ - "--experiment=sandboxes", - "--token", "xoxb-test-token", - "--name", "ttl-box", - "--domain", "ttl-box", - "--password", "pass", - "--archive-ttl", "12h", - }, - 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) - }, - }, "invalid": { CmdArgs: []string{ "--experiment=sandboxes", @@ -625,31 +481,77 @@ func Test_getEpochFromDate(t *testing.T) { } func Test_getTemplateID(t *testing.T) { - tests := map[string]struct { - in string - want int - wantErr bool - }{ - "empty string": {"", 0, false}, - "default": {"default", 1, false}, - "empty": {"empty", 0, false}, - "default case insensitive": {"Default", 1, false}, - "default case insensitive uppercase": {"DEFAULT", 1, false}, - "empty case insensitive": {"Empty", 0, false}, - "empty case insensitive uppercase": {"EMPTY", 0, false}, - "invalid": {"invalid", 0, true}, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - got, err := getTemplateID(tt.in) - if tt.wantErr { - assert.Error(t, err) - return - } - assert.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } + 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) { From b5de063f51952be66398a31b37e2086dfbdcdd27 Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 19 Mar 2026 17:39:37 -0400 Subject: [PATCH 20/20] update test --- cmd/sandbox/create_test.go | 59 ++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/cmd/sandbox/create_test.go b/cmd/sandbox/create_test.go index be26c403..33b6677e 100644 --- a/cmd/sandbox/create_test.go +++ b/cmd/sandbox/create_test.go @@ -25,7 +25,6 @@ import ( "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" ) @@ -555,27 +554,39 @@ func Test_getTemplateID(t *testing.T) { } func Test_domainFromName(t *testing.T) { - tests := map[string]struct { - in string - want string - wantErr bool - }{ - "simple": {"test-box", "test-box", false}, - "spaces": {"My Test Box", "my-test-box", false}, - "uppercase": {"MyBox", "mybox", false}, - "mixed": {"Hello_World 123", "hello-world-123", false}, - "leading trailing": {"-test-", "test", false}, - "empty": {"", "", true}, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - got, err := domainFromName(tt.in) - if tt.wantErr { - assert.Error(t, err) - return - } - assert.NoError(t, err) - assert.Equal(t, tt.want, got) - }) - } + 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) + }) }