Repository: plone/Products.CMFPlone

Issue:
Can't add an user in UserGroupsControlpanelView to a Group with pas.plugin.ldap with plone.many_users=True
### What I did:

- add plone group `testgroup1` via /@@usergroup-groupprefs
- set registry entries for `plone.many_users` and `plone.many_groups` to `True`
- add a Folder
- go to the folder and call the sharing view /myfolder/@@sharing
- add the group `testgroup1` with reader, editor roles
- click on the group to add some users `/@@usergroup-groupmembership?groupname=testgroup1`
- search a user
- select a user (should be a LDAP User) and save

### What I expect to happen:
- the user is added to the group

### What actually happened:
- endless waiting for response after click on "Add" Button

### What version of Plone/ Addons I am using:

- Plone 6.0.4
- pas.plugin.ldap 1.8.2
- node.ext.ldap 1.2
- node 1.2.1
- LDAP Backend user objects > 10000

### Some investigations

- in the ZMI acl_users -> source_groups the action is possible without errors
- with loglevel DEBUG i see thousands of queries to the LDAP Backend
- the task run many minutes
- its a limitation of [pas.plugin.ldap](https://github.com/collective/pas.plugins.ldap#limitations-and-future-optimizations)
 

the [GroupMembershipControlPanel](https://github.com/plone/Products.CMFPlone/blob/a5b48c0c24e6eebbe01aa2874eaaa9aa3d49f155/Products/CMFPlone/controlpanel/browser/usergroups_groupmembership.py#L65) clean the searchstring in the request and pass it to the membership search in UsersGroupsControlPanelView.

An empty searchstring is like a intention "list all users" and a violation against the option `many_users = True`. The search with empty searchstring should not be performed.

With the following patch, all is fine ;-)

```
if searchGroups:
  if not self.many_groups or bool(searchString):
    groupResults = searchView.merge(chain(*[searchView.searchGroups(**{field: searchString}) for field in ['id', 'title']]), 'groupid')
    groupResults = [gtool.getGroupById(g['id']) for g in groupResults if g['id'] not in ignore]
    groupResults.sort(key=lambda x: x is not None and normalizeString(x.getGroupTitleOrName()))
        
  if searchUsers:
    if not self.many_users or bool(searchString):
      userResults = searchView.merge(chain(*[searchView.searchUsers(**{field: searchString}) for field in ['login', 'fullname', 'email']]), 'userid')
      userResults = [mtool.getMemberById(u['id']) for u in userResults if u['id'] not in ignore]
      userResults.sort(key=lambda x: x is not None and x.getProperty('fullname') is not None and normalizeString(x.getProperty('fullname')) or '')
```



Discussion:

Resolving patch:
diff --git a/Products/CMFPlone/controlpanel/browser/usergroups.py b/Products/CMFPlone/controlpanel/browser/usergroups.py
--- a/Products/CMFPlone/controlpanel/browser/usergroups.py
+++ b/Products/CMFPlone/controlpanel/browser/usergroups.py
@@ -86,45 +86,49 @@ def membershipSearch(
         )
 
         if searchGroups:
-            groupResults = searchView.merge(
-                chain(
-                    *[
-                        searchView.searchGroups(**{field: searchString})
-                        for field in ["id", "title"]
-                    ]
-                ),
-                "groupid",
-            )
-            groupResults = [
-                gtool.getGroupById(g["id"])
-                for g in groupResults
-                if g["id"] not in ignore
-            ]
-            groupResults.sort(
-                key=lambda x: x is not None and normalizeString(x.getGroupTitleOrName())
-            )
+            # Only search for all ('') if the many_users flag is not set.
+            if not (self.many_groups) or bool(self.searchString):
+                groupResults = searchView.merge(
+                    chain(
+                        *[
+                            searchView.searchGroups(**{field: searchString})
+                            for field in ["id", "title"]
+                        ]
+                    ),
+                    "groupid",
+                )
+                groupResults = [
+                    gtool.getGroupById(g["id"])
+                    for g in groupResults
+                    if g["id"] not in ignore
+                ]
+                groupResults.sort(
+                    key=lambda x: x is not None and normalizeString(x.getGroupTitleOrName())
+                )
 
         if searchUsers:
-            userResults = searchView.merge(
-                chain(
-                    *[
-                        searchView.searchUsers(**{field: searchString})
-                        for field in ["login", "fullname", "email"]
-                    ]
-                ),
-                "userid",
-            )
-            userResults = [
-                mtool.getMemberById(u["id"])
-                for u in userResults
-                if u["id"] not in ignore
-            ]
-            userResults.sort(
-                key=lambda x: x is not None
-                and x.getProperty("fullname") is not None
-                and normalizeString(x.getProperty("fullname"))
-                or ""
-            )
+            # Only search for all ('') if the many_users flag is not set.
+            if not (self.many_users) or bool(self.searchString):
+                userResults = searchView.merge(
+                    chain(
+                        *[
+                            searchView.searchUsers(**{field: searchString})
+                            for field in ["login", "fullname", "email"]
+                        ]
+                    ),
+                    "userid",
+                )
+                userResults = [
+                    mtool.getMemberById(u["id"])
+                    for u in userResults
+                    if u["id"] not in ignore
+                ]
+                userResults.sort(
+                    key=lambda x: x is not None
+                    and x.getProperty("fullname") is not None
+                    and normalizeString(x.getProperty("fullname"))
+                    or ""
+                )
 
         return groupResults + userResults
 


Test patch:
diff --git a/Products/CMFPlone/controlpanel/tests/test_controlpanel_browser_usergroups.py b/Products/CMFPlone/controlpanel/tests/test_controlpanel_browser_usergroups.py
--- a/Products/CMFPlone/controlpanel/tests/test_controlpanel_browser_usergroups.py
+++ b/Products/CMFPlone/controlpanel/tests/test_controlpanel_browser_usergroups.py
@@ -1,13 +1,12 @@
-from plone.app.testing import SITE_OWNER_NAME
-from plone.app.testing import SITE_OWNER_PASSWORD
+import unittest
+
+import transaction
+from plone.app.testing import SITE_OWNER_NAME, SITE_OWNER_PASSWORD
 from plone.testing.zope import Browser
 from Products.CMFCore.utils import getToolByName
 from Products.CMFPlone.testing import PRODUCTS_CMFPLONE_FUNCTIONAL_TESTING
 from Products.CMFPlone.utils import normalizeString
 
-import transaction
-import unittest
-
 
 class UserGroupsControlPanelFunctionalTest(unittest.TestCase):
     """Test that changes in the user groups control panel are actually
@@ -305,7 +304,7 @@ def setUp(self):
         self.browser.addHeader(
             "Authorization", f"Basic {SITE_OWNER_NAME}:{SITE_OWNER_PASSWORD}"
         )
-
+    
     def test_usergroups_control_panel_link_users(self):
         self.browser.open("%s/@@overview-controlpanel" % self.portal_url)
         # There are two Users links.  The first is the Users tab.
@@ -552,3 +551,63 @@ def test_usergroups_settings_many_groups(self):
         self.browser.open(self.groups_url)
         self.assertNotIn("Show all", self.browser.contents)
         self.assertNotIn("DIispfuF", self.browser.contents)
+    
+    def test_usergroups_membership_many_users(self):
+        from io import StringIO
+
+        from lxml import etree
+
+        # add user | many_users=False | many_groups=False
+        self.browser.open("%s/@@usergroup-groupmembership?groupname=group1" % self.portal_url)
+        self.browser.getControl(name="searchstring").value = "TWrMCLIo"
+        self.browser.getControl(name="form.button.Search").click()
+        self.browser.getControl(name="add:list").getControl(
+            value="TWrMCLIo"
+        ).selected = True
+        self.browser.getControl("Add selected groups and users to this group").click()
+
+        tree = etree.parse(StringIO(self.browser.contents), etree.HTMLParser())
+        result = tree.xpath(
+            "count(//table[@summary='Groups']/tbody/tr)"
+        )
+        
+        # Rows with User Entries exists
+        self.assertGreater(result, 1.0, "Table should contain User Entries")
+
+        # delete the user
+        self.browser.open("%s/@@usergroup-groupmembership?groupname=group1" % self.portal_url)
+        self.browser.getControl(name="searchstring").value = "TWrMCLIo"
+        self.browser.getControl(name="form.button.Search").click()
+        self.browser.getControl(name="delete:list").getControl(
+            value="TWrMCLIo"
+        ).selected = True
+        self.browser.getControl("Remove selected groups / users").click()        
+        
+        # set many_user and many_groups to True        
+        self.browser.open("%s/@@usergroup-controlpanel" % self.portal_url)
+
+        self.browser.getControl(name="form.widgets.many_users:list").controls[
+            0
+        ].selected = True
+        self.browser.getControl(name="form.widgets.many_groups:list").controls[
+            0
+        ].selected = True
+        self.browser.getControl("Save").click()
+
+        # add user | many_users=True | many_groups=True
+        self.browser.open("%s/@@usergroup-groupmembership?groupname=group1" % self.portal_url)
+        self.browser.getControl(name="searchstring").value = "j5g0xPmr"
+        self.browser.getControl(name="form.button.Search").click()
+        self.browser.getControl(name="add:list").getControl(
+            value="j5g0xPmr"
+        ).selected = True
+        self.browser.getControl("Add selected groups and users to this group").click()
+
+        tree = etree.parse(StringIO(self.browser.contents), etree.HTMLParser())
+        result = tree.xpath(
+            "count(//table[@summary='Groups']/tbody/tr)"
+        )
+        
+        # No Rows with User Entries exists, only a row with a hint-text is visible
+        self.assertEqual(1.0, result, "Table should contain no User Entries")
+        
\ No newline at end of file