diff --git a/pkg/cmd/sandbox.go b/pkg/cmd/sandbox.go index 5d727dbc..54c4d83c 100644 --- a/pkg/cmd/sandbox.go +++ b/pkg/cmd/sandbox.go @@ -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 } @@ -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{ @@ -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") != "" } diff --git a/pkg/cmd/sandbox_test.go b/pkg/cmd/sandbox_test.go index 2577f9e1..d03174bf 100644 --- a/pkg/cmd/sandbox_test.go +++ b/pkg/cmd/sandbox_test.go @@ -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") +}