Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions pkg/cmd/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ func newSandboxCmd() *sandboxCmd {
// server-side (the UAT allowlist + hzn_sandbox_create), not the client.
sc.cmd.AddCommand(newSandboxNewCmd().cmd)
sc.cmd.AddCommand(newSandboxListCmd().cmd)
sc.cmd.AddCommand(newSandboxDeleteCmd().cmd)
return sc
}

Expand Down Expand Up @@ -434,6 +435,13 @@ type sandboxListCmd struct {
apiBase string
}

type sandboxDeleteCmd struct {
cmd *cobra.Command
stripeAccount string
stripeVersion string
apiBase string
}

func newSandboxClaimCmd() *sandboxClaimCmd {
scc := &sandboxClaimCmd{}
scc.cmd = &cobra.Command{
Expand Down Expand Up @@ -1019,6 +1027,176 @@ func (slc *sandboxListCmd) runSandboxListCmd(cmd *cobra.Command, args []string)
return nil
}

func newSandboxDeleteCmd() *sandboxDeleteCmd {
sdc := &sandboxDeleteCmd{}
sdc.cmd = &cobra.Command{
Use: "delete",
Short: "Delete a sandbox by its account id",
Long: `Delete a sandbox created for the logged-in account.

Deletes the sandbox whose account (acct_...) you pass via --stripe-account (the
ACCOUNT shown by ` + "`stripe sandbox list`" + `). This closes the sandbox's testmode
workspace, mirroring the dashboard's delete action; it never touches your live account.`,
Args: validators.NoArgs,
RunE: sdc.runSandboxDeleteCmd,
Hidden: true,
}

sdc.cmd.Flags().StringVar(&sdc.stripeAccount, "stripe-account", "", "Account (acct_...) of the sandbox to delete; see `stripe sandbox list`")
_ = sdc.cmd.MarkFlagRequired("stripe-account")

sdc.cmd.Flags().StringVar(&sdc.stripeVersion, "stripe-version", requests.StripeVersionHeaderValue, "Sets the Stripe-Version header")
_ = sdc.cmd.Flags().MarkHidden("stripe-version")

sdc.cmd.Flags().StringVar(&sdc.apiBase, "api-base", stripe.DefaultAPIBaseURL, "Sets the Stripe API base URL")
_ = sdc.cmd.Flags().MarkHidden("api-base")

return sdc
}

func (sdc *sandboxDeleteCmd) runSandboxDeleteCmd(cmd *cobra.Command, args []string) error {
if config.KeyRing == nil {
return fmt.Errorf("credential store unavailable; run `stripe login` first")
}
uatBytes, err := config.KeyRing.Get(config.UATKeychainItemKey)
if err != nil || len(uatBytes) == 0 {
return fmt.Errorf("no user access token found; run `stripe login` first")
}
uat := strings.TrimSpace(string(uatBytes))

// --stripe-account names the sandbox to delete (its acct_), consistent with the
// acct_-centric selector on `new`/`list`. Never an org.
stripeAccount := strings.TrimSpace(sdc.stripeAccount)
switch {
case stripeAccount == "":
return fmt.Errorf("--stripe-account is required (the acct_ of the sandbox to delete; see `stripe sandbox list`)")
case strings.HasPrefix(stripeAccount, "org_"):
return fmt.Errorf("--stripe-account must be an account (acct_...), not an organization (org_...)")
case !strings.HasPrefix(stripeAccount, "acct_"):
return fmt.Errorf("--stripe-account must be an account id (acct_...), got %q", stripeAccount)
}

baseURL, err := url.Parse(sdc.apiBase)
if err != nil {
return fmt.Errorf("invalid --api-base %q: %w", sdc.apiBase, err)
}

// Empty APIKey so no Bearer header is set; the UAT is injected as STRIPE-V2-SIG.
client := &stripe.Client{
BaseURL: baseURL,
APIKey: "",
}
authConfigure := func(req *http.Request) error {
req.Header.Set("Authorization", "STRIPE-V2-SIG "+uat)
req.Header.Set("Stripe-Version", sdc.stripeVersion)
req.Header.Set("Content-Type", stripe.V2ContentType)
return nil
}

// The delete endpoint takes the sandbox's testmode workspace (wksp_test). We only have
// its account (acct_), so resolve acct_ -> wksp_test by finding the sandbox under the
// caller's accessible live parents.
sandboxWorkspace, sandboxName, err := resolveSandboxWorkspaceByAccount(cmd.Context(), client, authConfigure, stripeAccount)
if err != nil {
return err
}
// Guard: only ever close a sandbox (testmode) workspace, never something else.
if !strings.HasPrefix(sandboxWorkspace, "wksp_test") {
return fmt.Errorf("resolved sandbox id %q is not a testmode workspace (wksp_test...)", sandboxWorkspace)
}

if sandboxName != "" {
fmt.Fprintf(cmd.ErrOrStderr(), "Deleting sandbox %q (%s)\n", sandboxName, stripeAccount)
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "Deleting sandbox %s\n", stripeAccount)
}

// Close the testmode workspace, mirroring the dashboard's v2CloseTestmodeWorkspace
// mutation. The workspace id is in the path; the call is self-scoped by the UAT (no
// Stripe-Context), like the compartment resolution GETs.
path := "/v2/workspaces/undocumented/testmode/" + url.PathEscape(sandboxWorkspace) + "/close"
resp, err := client.PerformRequest(cmd.Context(), http.MethodPost, path, "", authConfigure)
if err != nil {
return err
}
defer resp.Body.Close()

respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("delete sandbox failed: %s\n%s", resp.Status, string(respBytes))
}

out := cmd.OutOrStdout()
if sandboxName != "" {
fmt.Fprintf(out, "Deleted sandbox %q (%s)\n", sandboxName, stripeAccount)
} else {
fmt.Fprintf(out, "Deleted sandbox %s\n", stripeAccount)
}
return nil
}

// fetchSandboxesForParent lists the sandboxes under one live parent workspace via
// GET /v2/compartments/user_accessible_sandboxes, flattening standalone and org-nested
// sandbox entries.
func fetchSandboxesForParent(ctx context.Context, client *stripe.Client, configure func(*http.Request) error, liveWorkspace string) ([]accessibleWorkspace, error) {
query := "live_compartment_parent_id=" + url.QueryEscape(liveWorkspace)
resp, err := client.PerformRequest(ctx, http.MethodGet, "/v2/compartments/user_accessible_sandboxes", query, configure)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("could not list sandboxes: %s\n%s", resp.Status, string(respBytes))
}
var parsed struct {
Workspaces []accessibleWorkspace `json:"workspaces"`
Organizations []struct {
Workspaces []accessibleWorkspace `json:"workspaces"`
} `json:"organizations"`
}
if err := json.Unmarshal(respBytes, &parsed); err != nil {
return nil, fmt.Errorf("could not parse sandboxes response: %w", err)
}
out := append([]accessibleWorkspace{}, parsed.Workspaces...)
for _, org := range parsed.Organizations {
out = append(out, org.Workspaces...)
}
return out, nil
}

// resolveSandboxWorkspaceByAccount finds the sandbox workspace (wksp_test) whose account
// (merchant_id) matches sandboxAccount. Sandboxes are listed per live parent, so it
// searches each accessible live workspace's sandboxes. Returns the sandbox workspace id
// and its name.
func resolveSandboxWorkspaceByAccount(ctx context.Context, client *stripe.Client, configure func(*http.Request) error, sandboxAccount string) (string, string, error) {
liveWorkspaces, err := fetchAccessibleWorkspaces(ctx, client, configure)
if err != nil {
return "", "", err
}
for _, lw := range liveWorkspaces {
if !strings.HasPrefix(lw.ID, "wksp_") {
continue
}
sandboxes, err := fetchSandboxesForParent(ctx, client, configure, lw.ID)
if err != nil {
return "", "", err
}
for _, s := range sandboxes {
if s.MerchantID == sandboxAccount && strings.HasPrefix(s.ID, "wksp_test") {
return s.ID, s.Name, nil
}
}
}
return "", "", fmt.Errorf("no sandbox found for %s under your accessible live accounts; run `stripe sandbox list` to see available sandboxes", sandboxAccount)
}

func isSSHSession() bool {
return os.Getenv("SSH_TTY") != "" || os.Getenv("SSH_CONNECTION") != "" || os.Getenv("SSH_CLIENT") != ""
}
197 changes: 197 additions & 0 deletions pkg/cmd/sandbox_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1384,3 +1384,200 @@ func TestSandboxListCmd_NoUAT(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "stripe login")
}

func TestSandboxDeleteCmd_Success(t *testing.T) {
cleanup := setupSandboxTestConfig(t)
defer cleanup()

err := config.KeyRing.Set(config.UATKeychainItemKey, []byte("keyinfo_live_faketoken"), "test uat")
require.NoError(t, err)

// The sandbox to delete is identified by its acct_. The command resolves that
// acct_ to its testmode workspace (wksp_) via the live parent's sandbox list,
// then POSTs the close. Server serves user_accessible, user_accessible_sandboxes,
// and the close.
var closedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v2/compartments/user_accessible":
assert.Equal(t, "STRIPE-V2-SIG keyinfo_live_faketoken", r.Header.Get("Authorization"))
json.NewEncoder(w).Encode(map[string]interface{}{
"standalone_workspaces": []map[string]interface{}{
{"id": "wksp_live", "name": "Live", "merchant_id": "acct_live"},
},
})
case r.Method == http.MethodGet && r.URL.Path == "/v2/compartments/user_accessible_sandboxes":
if r.URL.RawQuery != "live_compartment_parent_id=wksp_live" {
t.Errorf("expected live_compartment_parent_id=wksp_live, got %s", r.URL.RawQuery)
w.WriteHeader(http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"workspaces": []map[string]interface{}{
{"id": "wksp_test_a", "name": "sbxA", "merchant_id": "acct_a", "replica_of": "wksp_live"},
{"id": "wksp_test_b", "name": "sbxB", "merchant_id": "acct_b", "replica_of": "wksp_live"},
},
})
case r.Method == http.MethodPost && r.URL.Path == "/v2/workspaces/undocumented/testmode/wksp_test_a/close":
assert.Equal(t, "STRIPE-V2-SIG keyinfo_live_faketoken", r.Header.Get("Authorization"))
closedPath = r.URL.Path
json.NewEncoder(w).Encode(map[string]interface{}{"id": "wksp_test_a"})
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()

output, err := executeCommand(
rootCmd,
"sandbox", "delete",
"--api-base="+server.URL,
"--stripe-account=acct_a",
)

require.NoError(t, err)
// Only the targeted sandbox's testmode workspace was closed.
assert.Equal(t, "/v2/workspaces/undocumented/testmode/wksp_test_a/close", closedPath)
assert.Contains(t, output, "Deleted")
assert.Contains(t, output, "acct_a")
assert.Contains(t, output, "sbxA")
}

func TestSandboxDeleteCmd_IgnoresNonTestWorkspace(t *testing.T) {
cleanup := setupSandboxTestConfig(t)
defer cleanup()

err := config.KeyRing.Set(config.UATKeychainItemKey, []byte("keyinfo_live_faketoken"), "test uat")
require.NoError(t, err)

var closeRequested bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v2/compartments/user_accessible":
json.NewEncoder(w).Encode(map[string]interface{}{
"standalone_workspaces": []map[string]interface{}{
{"id": "wksp_live", "name": "Live", "merchant_id": "acct_live"},
},
})
case r.Method == http.MethodGet && r.URL.Path == "/v2/compartments/user_accessible_sandboxes":
json.NewEncoder(w).Encode(map[string]interface{}{
"workspaces": []map[string]interface{}{
{"id": "wksp_live_child", "name": "notTestmode", "merchant_id": "acct_a", "replica_of": "wksp_live"},
},
})
case r.Method == http.MethodPost:
closeRequested = true
w.WriteHeader(http.StatusOK)
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()

_, err = executeCommand(
rootCmd,
"sandbox", "delete",
"--api-base="+server.URL,
"--stripe-account=acct_a",
)

require.Error(t, err)
assert.Contains(t, err.Error(), "no sandbox found")
assert.False(t, closeRequested)
}

func TestSandboxDeleteCmd_NotFound(t *testing.T) {
cleanup := setupSandboxTestConfig(t)
defer cleanup()

err := config.KeyRing.Set(config.UATKeychainItemKey, []byte("keyinfo_live_faketoken"), "test uat")
require.NoError(t, err)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v2/compartments/user_accessible":
json.NewEncoder(w).Encode(map[string]interface{}{
"standalone_workspaces": []map[string]interface{}{
{"id": "wksp_live", "name": "Live", "merchant_id": "acct_live"},
},
})
case r.Method == http.MethodGet && r.URL.Path == "/v2/compartments/user_accessible_sandboxes":
json.NewEncoder(w).Encode(map[string]interface{}{
"workspaces": []map[string]interface{}{
{"id": "wksp_test_other", "name": "sbxOther", "merchant_id": "acct_other", "replica_of": "wksp_live"},
},
})
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()

_, err = executeCommand(
rootCmd,
"sandbox", "delete",
"--api-base="+server.URL,
"--stripe-account=acct_missing",
)

require.Error(t, err)
assert.Contains(t, err.Error(), "no sandbox found")
}

func TestSandboxDeleteCmd_RejectsOrg(t *testing.T) {
cleanup := setupSandboxTestConfig(t)
defer cleanup()

err := config.KeyRing.Set(config.UATKeychainItemKey, []byte("keyinfo_live_faketoken"), "test uat")
require.NoError(t, err)

// Rejected client-side before any network call.
_, err = executeCommand(
rootCmd,
"sandbox", "delete",
"--stripe-account=org_123",
)

require.Error(t, err)
assert.Contains(t, err.Error(), "not an organization")
}

func TestSandboxDeleteCmd_RequiresAccount(t *testing.T) {
cleanup := setupSandboxTestConfig(t)
defer cleanup()

err := config.KeyRing.Set(config.UATKeychainItemKey, []byte("keyinfo_live_faketoken"), "test uat")
require.NoError(t, err)

// Explicit empty value (Changed=true satisfies cobra's required-flag check, so the
// command's own emptiness guard is what fires here).
_, err = executeCommand(
rootCmd,
"sandbox", "delete",
"--stripe-account=",
)

require.Error(t, err)
assert.Contains(t, err.Error(), "required")
}

func TestSandboxDeleteCmd_NoUAT(t *testing.T) {
cleanup := setupSandboxTestConfig(t)
defer cleanup()

// No UAT seeded — keyring is empty.
_, err := executeCommand(
rootCmd,
"sandbox", "delete",
"--stripe-account=acct_a",
)

require.Error(t, err)
assert.Contains(t, err.Error(), "stripe login")
}
Loading