diff --git a/fence/resources/google/access_utils.py b/fence/resources/google/access_utils.py index 212688845..002d342b1 100644 --- a/fence/resources/google/access_utils.py +++ b/fence/resources/google/access_utils.py @@ -4,7 +4,6 @@ """ import backoff import time -import flask from urllib.parse import unquote import traceback @@ -17,7 +16,7 @@ from cdislogging import get_logger from fence.config import config -from fence.errors import NotFound, NotSupported, UserError +from fence.errors import NotFound, NotSupported from fence.models import ( User, Project, @@ -29,7 +28,6 @@ ) from fence.resources.google.utils import ( get_db_session, - get_users_from_google_members, get_monitoring_service_account_email, is_google_managed_service_account, ) @@ -42,93 +40,80 @@ class GoogleUpdateException(Exception): pass -def bulk_update_google_groups(google_bulk_mapping): +def update_google_groups_for_users(google_single_user_mapping): """ - Update Google Groups based on mapping provided from Group -> Users. + Update Google Groups for a single user based on the provided mapping. Args: - google_bulk_mapping (dict): {"googlegroup@google.com": ["member1", "member2"]} + google_single_user_mapping (dict): {"user_email": ["googlegroup@google.com"]} """ google_project_id = ( config["STORAGE_CREDENTIALS"].get("google", {}).get("google_project_id") ) google_update_failures = False with GoogleCloudManager(google_project_id) as gcm: - for group, expected_members in google_bulk_mapping.items(): - expected_members = set(expected_members) - logger.debug(f"Starting diff for group {group}...") - - # get members list from google - members_from_google = [] - + for user_email, groups in google_single_user_mapping.items(): + logger.debug(f"Updating groups for user {user_email}...") + expected_groups = set(groups) + # Get the groups the user is currently in try: - members_from_google = _get_members_from_google_group(gcm, group) + user_current_groups = _get_groups_for_user(gcm, user_email) except Exception as exc: logger.error( - f"ERROR: FAILED TO GET MEMBERS FROM GOOGLE GROUP {group}! " - f"This sync will SKIP " - f"the above user to try and update other authorization " - f"(rather than failing early). The error will be re-raised " - f"after attempting to update all other users. Exc: " + f"ERROR: FAILED TO GET GROUPS FOR USER {user_email}! " f"{traceback.format_exc()}" ) google_update_failures = True + user_current_groups = [] - google_members = set(member.get("email") for member in members_from_google) + logger.info(f"User's current groups: {user_current_groups}") - logger.debug(f"Google membership for {group}: {google_members}") - logger.debug(f"Expected membership for {group}: {expected_members}") + # Determine which groups to add the user to and which to remove them from + current_groups = set(user_current_groups) - # diff between expected group membership and actual membership - to_delete = set.difference(google_members, expected_members) - to_add = set.difference(expected_members, google_members) - no_update = set.intersection(google_members, expected_members) + groups_to_add = expected_groups - current_groups + groups_to_remove = current_groups - expected_groups - logger.info(f"All already in group {group}: {no_update}") + logger.info(f"Groups to add for {user_email}: {groups_to_add}") + logger.info(f"Groups to remove for {user_email}: {groups_to_remove}") - # do add - for member_email in to_add: - logger.info(f"Adding to group {group}: {member_email}") + for group in groups_to_add: + logger.info(f"Adding {user_email} to group {group}") try: - _add_member_to_google_group(gcm, member_email, group) + _add_member_to_google_group(gcm, user_email, group) except Exception as exc: logger.error( - f"ERROR: FAILED TO ADD MEMBER {member_email} TO GOOGLE " - f"GROUP {group}! This sync will SKIP " - f"the above user to try and update other authorization " - f"(rather than failing early). The error will be re-raised " - f"after attempting to update all other users. Exc: " + f"ERROR: FAILED TO ADD USER {user_email} TO GOOGLE " + f"GROUP {group}! This sync will continue to update other users. Exc: " f"{traceback.format_exc()}" ) google_update_failures = True - # do remove - for member_email in to_delete: - logger.info(f"Removing from group {group}: {member_email}") - + # Remove the user from groups they should not be in + for group in groups_to_remove: + logger.info(f"Removing {user_email} from group {group}") try: - _remove_member_from_google_group(gcm, member_email, group) + _remove_member_from_google_group(gcm, user_email, group) except Exception as exc: logger.error( - f"ERROR: FAILED TO REMOVE MEMBER {member_email} FROM " - f"GOOGLE GROUP {group}! This sync will SKIP " - f"the above user to try and update other authorization " - f"(rather than failing early). The error will be re-raised " - f"after attempting to update all other users. Exc: " + f"ERROR: FAILED TO REMOVE USER {user_email} FROM " + f"GOOGLE GROUP {group}! This sync will continue to update other users. Exc: " f"{traceback.format_exc()}" ) google_update_failures = True - if google_update_failures: - raise GoogleUpdateException( - f"FAILED TO UPDATE GOOGLE GROUPS (see previous errors)." - ) - + if google_update_failures: + raise GoogleUpdateException( + f"FAILED TO UPDATE GOOGLE GROUPS FOR USER {user_email} (see previous errors)." + ) @backoff.on_exception(backoff.expo, Exception, **DEFAULT_BACKOFF_SETTINGS) def _get_members_from_google_group(gcm, group): return gcm.get_group_members(group) +@backoff.on_exception(backoff.expo, Exception, **DEFAULT_BACKOFF_SETTINGS) +def _get_groups_for_user(gcm, user): + return gcm.get_groups_for_user(user) @backoff.on_exception(backoff.expo, Exception, **DEFAULT_BACKOFF_SETTINGS) def _add_member_to_google_group(gcm, add_member_to_group, group): diff --git a/fence/resources/google/utils.py b/fence/resources/google/utils.py index ae1be0a94..5c45790ba 100644 --- a/fence/resources/google/utils.py +++ b/fence/resources/google/utils.py @@ -582,21 +582,33 @@ def _update_service_account_db_entry( return service_account_db_entry -def get_or_create_proxy_group_id(expires=None, user_id=None, username=None): +def get_or_create_proxy_group_id(expires=None, user_id=None, username=None, session=None, storage_manager=None): """ If no username returned from token or database, create a new proxy group for the given user. Also, add the access privileges. + Args: + user_id: Fence user ID of the user + username: username for the user + session: a sqlalchemy session + storage_manager: StorageManager for the backend to create proxy groups, usually google. Returns: int: id of (possibly newly created) proxy group associated with user """ - proxy_group_id = _get_proxy_group_id(user_id=user_id, username=username) + db_session = session or current_app.scoped_session() + manager = storage_manager or flask.current_app.storage_manager + + logger.info(f"Proxy Group: {user_id}, {username}") + proxy_group_id = _get_proxy_group_id(user_id=user_id, username=username, session=db_session) + logger.info(f"{proxy_group_id}") if not proxy_group_id: try: - user_by_id = query_for_user_by_id(current_app.scoped_session(), user_id) + user_by_id = query_for_user_by_id(db_session, user_id) + logger.info(f"user_by_id: {user_by_id}") user_by_username = query_for_user( - session=current_app.scoped_session(), username=username + session=db_session, username=username ) + logger.info(f"user_by_username: {user_by_username}") except Exception: user_by_id = None user_by_username = None @@ -616,10 +628,10 @@ def get_or_create_proxy_group_id(expires=None, user_id=None, username=None): f"username={username}, nor was there a current_token" ) - proxy_group_id = _create_proxy_group(user_id, username).id + proxy_group_id = _create_proxy_group(user_id, username, session=db_session).id privileges = ( - current_app.scoped_session() + db_session .query(AccessPrivilege) .filter(AccessPrivilege.user_id == user_id) ) @@ -629,25 +641,25 @@ def get_or_create_proxy_group_id(expires=None, user_id=None, username=None): for sa in storage_accesses: if sa.provider.name == STORAGE_ACCESS_PROVIDER_NAME: - flask.current_app.storage_manager.logger.info( + manager.logger.info( "grant {} access {} to {} in {}".format( username, p.privilege, p.project_id, p.auth_provider ) ) - flask.current_app.storage_manager.grant_access( + manager.grant_access( provider=(sa.provider.name), username=username, project=p.project, access=p.privilege, - session=current_app.scoped_session(), + session=db_session, expires=expires, ) return proxy_group_id -def _get_proxy_group_id(user_id=None, username=None): +def _get_proxy_group_id(user_id=None, username=None, session=None): """ Get users proxy group id from the current token, if possible. Otherwise, check the database for it. @@ -661,9 +673,9 @@ def _get_proxy_group_id(user_id=None, username=None): user_id = user_id or current_token["sub"] try: - user = query_for_user_by_id(current_app.scoped_session(), user_id) + user = query_for_user_by_id(session, user_id) if not user: - user = query_for_user(current_app.scoped_session(), username) + user = query_for_user(session, username) except Exception: user = None @@ -673,7 +685,7 @@ def _get_proxy_group_id(user_id=None, username=None): return proxy_group_id -def _create_proxy_group(user_id, username): +def _create_proxy_group(user_id, username, session): """ Create a proxy group for the given user @@ -696,11 +708,11 @@ def _create_proxy_group(user_id, username): ) # link proxy group to user - user = current_app.scoped_session().query(User).filter_by(id=user_id).first() + user = session.query(User).filter_by(id=user_id).first() user.google_proxy_group_id = proxy_group.id - current_app.scoped_session().add(proxy_group) - current_app.scoped_session().commit() + session.add(proxy_group) + session.commit() logger.info( "Created proxy group {} for user {} with id {}.".format( diff --git a/fence/resources/storage/__init__.py b/fence/resources/storage/__init__.py index 4aeaad268..3e2d53b2c 100644 --- a/fence/resources/storage/__init__.py +++ b/fence/resources/storage/__init__.py @@ -398,10 +398,9 @@ def _update_access_to_bucket( bucket_privileges = bucket_access_group.privileges or [] if set(bucket_privileges).issubset(access): bucket_name = bucket_access_group.email - if google_bulk_mapping is not None: - google_bulk_mapping.setdefault(bucket_name, []).append( - storage_username + google_bulk_mapping.setdefault(storage_username, []).append( + bucket_name ) self.logger.info( "User {}'s Google proxy group ({}) added to bulk mapping for Google Bucket Access Group {}.".format( diff --git a/fence/sync/sync_users.py b/fence/sync/sync_users.py index 7b1b164ea..5a11c4ac6 100644 --- a/fence/sync/sync_users.py +++ b/fence/sync/sync_users.py @@ -38,8 +38,9 @@ IdentityProvider, get_project_to_authz_mapping, ) +from fence.resources.google.utils import get_or_create_proxy_group_id from fence.resources.storage import StorageManager -from fence.resources.google.access_utils import bulk_update_google_groups +from fence.resources.google.access_utils import update_google_groups_for_users from fence.resources.google.access_utils import GoogleUpdateException from fence.sync import utils from fence.sync.passport_sync.ras_sync import RASVisa @@ -931,7 +932,7 @@ def sync_to_db_and_storage_backend( if config["GOOGLE_BULK_UPDATES"]: self.logger.info("Doing bulk Google update...") - bulk_update_google_groups(google_bulk_mapping) + update_google_groups_for_users(google_bulk_mapping) self.logger.info("Bulk Google update done!") sess.commit() @@ -950,7 +951,7 @@ def sync_to_storage_backend(self, user_project, user_info, sess, expires): } } - user_info (dict): a dictionary of {username: user_info{}} + user_info (dict): a dictionary of attributes for a user. sess: a sqlalchemy session Return: @@ -961,9 +962,16 @@ def sync_to_storage_backend(self, user_project, user_info, sess, expires): f"sync to storage backend requires an expiration. you provided: {expires}" ) - google_bulk_mapping = None + google_group_user_mapping = None if config["GOOGLE_BULK_UPDATES"]: - google_bulk_mapping = {} + google_group_user_mapping = {} + get_or_create_proxy_group_id( + expires=expires, + user_id=user_info['user_id'], + username=user_info['username'], + session=sess, + storage_manager=self.storage_manager + ) # TODO: eventually it'd be nice to remove this step but it's required # so that grant_from_storage can determine what storage backends @@ -979,28 +987,27 @@ def sync_to_storage_backend(self, user_project, user_info, sess, expires): for project, _ in projects.items(): syncing_user_project_list.add((username.lower(), project)) - user_info_lowercase = { - username.lower(): info for username, info in user_info.items() - } to_add = set(syncing_user_project_list) - # when updating users we want to maintain case sesitivity in the username so + # when updating users we want to maintain case sensitivity in the username so # pass the original, non-lowered user_info dict - self._upsert_userinfo(sess, user_info) + self._upsert_userinfo(sess, { + user_info['username'].lower(): user_info + }) self._grant_from_storage( to_add, user_project_lowercase, sess, - google_bulk_mapping=google_bulk_mapping, + google_bulk_mapping=google_group_user_mapping, expires=expires, ) if config["GOOGLE_BULK_UPDATES"]: - self.logger.info("Doing bulk Google update...") - bulk_update_google_groups(google_bulk_mapping) - self.logger.info("Bulk Google update done!") + self.logger.info("Updating user's google groups ...") + update_google_groups_for_users(google_group_user_mapping) + self.logger.info("Google groups update done!!") sess.commit() @@ -1230,8 +1237,9 @@ def _grant_from_storage( {username: {phsid: {'read-storage','write-storage'}}} Return: - None + dict of the users' storage usernames to their user_projects and the respective storage access. """ + storage_user_to_sa_and_user_project = defaultdict() for username, project_auth_id in to_add: project = self._projects[project_auth_id] for sa in project.storage_access: @@ -1251,7 +1259,7 @@ def _grant_from_storage( username, access, project_auth_id, sa.provider.name ) ) - self.storage_manager.grant_access( + storage_username = self.storage_manager.grant_access( provider=sa.provider.name, username=username, project=project, @@ -1261,6 +1269,9 @@ def _grant_from_storage( expires=expires, ) + storage_user_to_sa_and_user_project[storage_username] = (sa, project) + return storage_user_to_sa_and_user_project + def _init_projects(self, user_project, sess): """ initialize projects @@ -2449,7 +2460,6 @@ def sync_single_user_visas(self, user, ga4gh_visas, sess=None, expires=None): raise user_projects = dict() - user_info = dict() projects = {} info = {} parsed_visas = [] @@ -2475,8 +2485,9 @@ def sync_single_user_visas(self, user, ga4gh_visas, sess=None, expires=None): projects = {**projects, **project} parsed_visas.append(visa) + info['user_id'] = user.id + info['username'] = user.username user_projects[user.username] = projects - user_info[user.username] = info user_projects = self.parse_projects(user_projects) @@ -2501,7 +2512,7 @@ def sync_single_user_visas(self, user, ga4gh_visas, sess=None, expires=None): if user_projects: self.logger.info("Sync to storage backend [sync_single_user_visas]") self.sync_to_storage_backend( - user_projects, user_info, sess, expires=expires + user_projects, info, sess, expires=expires ) else: self.logger.info("No users for syncing") diff --git a/tests/dbgap_sync/test_user_sync.py b/tests/dbgap_sync/test_user_sync.py index 49c69e88a..f85cc28e5 100644 --- a/tests/dbgap_sync/test_user_sync.py +++ b/tests/dbgap_sync/test_user_sync.py @@ -490,7 +490,7 @@ def test_sync_with_google_errors(syncer, monkeypatch): syncer._update_arborist = MagicMock() syncer._update_authz_in_arborist = MagicMock() - with patch("fence.sync.sync_users.bulk_update_google_groups") as mock_bulk_update: + with patch("fence.sync.sync_users.update_google_groups_for_users") as mock_bulk_update: mock_bulk_update.side_effect = GoogleUpdateException("Something's Wrong!") with pytest.raises(GoogleUpdateException): syncer.sync() diff --git a/tests/google/conftest.py b/tests/google/conftest.py index 778924694..6dc7931cf 100644 --- a/tests/google/conftest.py +++ b/tests/google/conftest.py @@ -221,12 +221,6 @@ def valid_google_project_patcher(): ) get_users_from_members_mock = MagicMock() - patches.append( - patch( - "fence.resources.google.access_utils.get_users_from_google_members", - get_users_from_members_mock, - ) - ) patches.append( patch( "fence.resources.google.validity.get_users_from_google_members", diff --git a/tests/google/test_access_utils.py b/tests/google/test_access_utils.py index 0c8b6f4f7..8df13ec55 100644 --- a/tests/google/test_access_utils.py +++ b/tests/google/test_access_utils.py @@ -24,8 +24,7 @@ force_remove_service_account_from_access, extend_service_account_access, patch_user_service_account, - remove_white_listed_service_account_ids, - bulk_update_google_groups, + remove_white_listed_service_account_ids, update_google_groups_for_users, GoogleUpdateException ) from fence.utils import DEFAULT_BACKOFF_SETTINGS @@ -182,15 +181,10 @@ def test_project_has_valid_membership(cloud_manager, db_session): # not error out on the members created above. e.g. this is faking # that these users exist in our db get_users_mock.return_value = [0, 1] - get_users_patcher = patch( - "fence.resources.google.access_utils.get_users_from_google_members", - get_users_mock, - ) - get_users_patcher.start() + assert get_google_project_valid_users_and_service_accounts( cloud_manager.project_id, cloud_manager ) - get_users_patcher.stop() def test_project_has_invalid_membership(cloud_manager, db_session): @@ -544,78 +538,76 @@ def test_whitelisted_service_accounts( assert "test@456" not in service_account_ids assert "test@789" in service_account_ids - -def test_bulk_update_google_groups_get_group_members(cloud_manager): +def test_update_google_groups_for_users_get_group_members(cloud_manager): """ Tests backoff for when the get_group_member google group API calls error out. """ - test_mapping = {"googlegroup@google.com": ["member1", "member2"]} + test_mapping = {"member1": ["googlegroup@google.com"], "member2": ["googlegroup@google.com"]} cloud_manager_instance = cloud_manager.return_value.__enter__.return_value - cloud_manager_instance.get_group_members.side_effect = Exception( - "Something's wrong with get_group_members" + cloud_manager_instance.get_groups_for_user.side_effect = Exception( + "Something's wrong with get_groups_for_user" ) with pytest.raises(Exception): - bulk_update_google_groups(test_mapping) + update_google_groups_for_users(test_mapping) assert ( - cloud_manager_instance.get_group_members.call_count - == DEFAULT_BACKOFF_SETTINGS["max_tries"] + cloud_manager_instance.get_groups_for_user.call_count + == DEFAULT_BACKOFF_SETTINGS["max_tries"] * len(test_mapping.keys()) ) -def test_bulk_update_google_groups_add_group_members(cloud_manager): +def test_update_google_groups_for_users_add_group_members(cloud_manager): """ Tests backoff for when the add_member_to_group google group API calls error. """ - test_mapping = {"googlegroup@google.com": ["member1", "member2"]} + test_mapping = {"member1": ["googlegroup@google.com"], "member2": ["googlegroup@google.com"]} cloud_manager_instance = cloud_manager.return_value.__enter__.return_value - cloud_manager_instance.get_group_members.return_value = [] + cloud_manager_instance.get_groups_for_user.return_value = [] cloud_manager_instance.add_member_to_group.side_effect = Exception( "Something's wrong with add_member_to_group" ) with pytest.raises(Exception): - bulk_update_google_groups(test_mapping) + update_google_groups_for_users(test_mapping) - assert cloud_manager_instance.get_group_members.call_count == 1 + assert cloud_manager_instance.get_groups_for_user.call_count == len(test_mapping.keys()) assert ( cloud_manager_instance.add_member_to_group.call_count == DEFAULT_BACKOFF_SETTINGS["max_tries"] - * len(test_mapping["googlegroup@google.com"]) + * len(test_mapping.keys()) ) -def test_bulk_update_google_groups_remove_group_members(cloud_manager): +def test_update_google_groups_for_users_remove_group_members(cloud_manager): """ Tests backoff for when the remove_member_to_group group API calls error out. """ - test_mapping = {"googlegroup@google.com": []} - to_remove = [{"email": "member1"}, {"email": "member2"}] + test_mapping = {"member1": [], "member2": []} + to_remove = ["googlegroup@google.com"] cloud_manager_instance = cloud_manager.return_value.__enter__.return_value - cloud_manager_instance.get_group_members.return_value = to_remove + cloud_manager_instance.get_groups_for_user.return_value = to_remove cloud_manager_instance.remove_member_from_group.side_effect = Exception( "Something's wrong with remove_member_from_group" ) with pytest.raises(Exception): - bulk_update_google_groups(test_mapping) + update_google_groups_for_users(test_mapping) - assert cloud_manager_instance.get_group_members.call_count == 1 + assert cloud_manager_instance.get_groups_for_user.call_count == len(test_mapping.keys()) assert ( cloud_manager_instance.remove_member_from_group.call_count - == DEFAULT_BACKOFF_SETTINGS["max_tries"] * len(to_remove) + == DEFAULT_BACKOFF_SETTINGS["max_tries"] * len(test_mapping.keys()) ) - -def test_bulk_update_google_groups_add_remove_group_members(cloud_manager): +def test_update_google_groups_for_users_add_remove_group_members(cloud_manager): """ Tests backoff for when both the add_member_to_group and remove_member_to_group group API calls error out. """ - test_mapping = {"googlegroup@google.com": ["member1"]} - to_remove = [{"email": "member2"}] + test_mapping = {"member1": ["googlegroup@google.com"]} + to_remove = ["email"] cloud_manager_instance = cloud_manager.return_value.__enter__.return_value - cloud_manager_instance.get_group_members.return_value = to_remove + cloud_manager_instance.get_groups_for_user.return_value = to_remove cloud_manager_instance.add_member_to_group.side_effect = Exception( "Something's wrong with add_member_to_group" ) @@ -624,9 +616,9 @@ def test_bulk_update_google_groups_add_remove_group_members(cloud_manager): ) with pytest.raises(Exception): - bulk_update_google_groups(test_mapping) + update_google_groups_for_users(test_mapping) - assert cloud_manager_instance.get_group_members.call_count == 1 + assert cloud_manager_instance.get_groups_for_user.call_count == 1 assert ( cloud_manager_instance.add_member_to_group.call_count == DEFAULT_BACKOFF_SETTINGS["max_tries"] @@ -635,3 +627,148 @@ def test_bulk_update_google_groups_add_remove_group_members(cloud_manager): cloud_manager_instance.remove_member_from_group.call_count == DEFAULT_BACKOFF_SETTINGS["max_tries"] ) + + +def test_update_google_groups_for_users_success(cloud_manager): + """ + Test successful update of Google Groups for a single user. + """ + google_single_user_mapping = {"user@test.com": ["group1@google.com", "group2@google.com"]} + mock_gcm_instance = cloud_manager.return_value.__enter__.return_value + mock_gcm_instance.get_groups_for_user.return_value = ["group2@google.com"] + + update_google_groups_for_users(google_single_user_mapping) + + mock_gcm_instance.get_groups_for_user.assert_called_once_with("user@test.com") + mock_gcm_instance.add_member_to_group.assert_called_once_with("user@test.com", "group1@google.com") + mock_gcm_instance.remove_member_from_group.assert_not_called() + + +def test_update_google_groups_for_users_remove_groups(cloud_manager): + """ + Test successful removal of user from groups. + """ + google_single_user_mapping = {"user@test.com": ["group1@google.com"]} + mock_gcm_instance = cloud_manager.return_value.__enter__.return_value + mock_gcm_instance.get_groups_for_user.return_value = ["group1@google.com", "group2@google.com"] + + update_google_groups_for_users(google_single_user_mapping) + + mock_gcm_instance.get_groups_for_user.assert_called_once_with("user@test.com") + mock_gcm_instance.add_member_to_group.assert_not_called() + mock_gcm_instance.remove_member_from_group.assert_called_once_with("user@test.com", "group2@google.com") + + +def test_update_google_groups_for_multiple_users_success(cloud_manager): + """ + Test successful update of Google Groups for multiple users. + """ + google_single_user_mapping = { + "user1@test.com": ["group1@google.com", "group2@google.com"], + "user2@test.com": ["group3@google.com", "group4@google.com"] + } + mock_gcm_instance = cloud_manager.return_value.__enter__.return_value + mock_gcm_instance.get_groups_for_user.side_effect = [ + ["group2@google.com"], # user1 current groups + ["group3@google.com"] # user2 current groups + ] + + update_google_groups_for_users(google_single_user_mapping) + + # Assertions for user1@test.com + mock_gcm_instance.get_groups_for_user.assert_any_call("user1@test.com") + mock_gcm_instance.add_member_to_group.assert_any_call("user1@test.com", "group1@google.com") + mock_gcm_instance.remove_member_from_group.assert_not_called() + + # Assertions for user2@test.com + mock_gcm_instance.get_groups_for_user.assert_any_call("user2@test.com") + mock_gcm_instance.add_member_to_group.assert_called_with("user2@test.com", "group4@google.com") + mock_gcm_instance.remove_member_from_group.assert_not_called() + + +def test_update_google_groups_for_multiple_users_with_removals(cloud_manager): + """ + Test update of Google Groups for multiple users with group removals. + """ + google_single_user_mapping = { + "user1@test.com": ["group1@google.com"], + "user2@test.com": ["group3@google.com"] + } + mock_gcm_instance = cloud_manager.return_value.__enter__.return_value + mock_gcm_instance.get_groups_for_user.side_effect = [ + ["group1@google.com", "group2@google.com"], # user1 current groups + ["group3@google.com", "group4@google.com"] # user2 current groups + ] + + update_google_groups_for_users(google_single_user_mapping) + + # Assertions for user1@test.com (removing group2) + mock_gcm_instance.get_groups_for_user.assert_any_call("user1@test.com") + mock_gcm_instance.add_member_to_group.assert_not_called() + mock_gcm_instance.remove_member_from_group.assert_any_call("user1@test.com", "group2@google.com") + + # Assertions for user2@test.com (removing group4) + mock_gcm_instance.get_groups_for_user.assert_any_call("user2@test.com") + mock_gcm_instance.add_member_to_group.assert_not_called() + mock_gcm_instance.remove_member_from_group.assert_any_call("user2@test.com", "group4@google.com") + + +def test_update_google_groups_for_multiple_users_partial_failure(cloud_manager): + """ + Test partial failure when updating groups for multiple users, ensuring sync continues. + """ + google_single_user_mapping = { + "user1@test.com": ["group1@google.com", "group2@google.com"], + "user2@test.com": ["group3@google.com", "group4@google.com"] + } + mock_gcm_instance = cloud_manager.return_value.__enter__.return_value + mock_gcm_instance.get_groups_for_user.side_effect = [ + ["group2@google.com"], # user1 current groups + ["group3@google.com"] # user2 current groups + ] + mock_gcm_instance.add_member_to_group.side_effect = [ + None, # Success for user1 group1 + Exception("Error adding group for user2") # Failure for user2 group4 + ] + + with pytest.raises(GoogleUpdateException): + update_google_groups_for_users(google_single_user_mapping) + + # Assertions for user1@test.com (successful) + mock_gcm_instance.get_groups_for_user.assert_any_call("user1@test.com") + mock_gcm_instance.add_member_to_group.assert_any_call("user1@test.com", "group1@google.com") + mock_gcm_instance.remove_member_from_group.assert_not_called() + + # Assertions for user2@test.com (failed) + mock_gcm_instance.get_groups_for_user.assert_any_call("user2@test.com") + mock_gcm_instance.add_member_to_group.assert_any_call("user2@test.com", "group4@google.com") + + +def test_update_google_groups_for_multiple_users_with_removals_failure(cloud_manager): + """ + Test failure when removing groups for multiple users, ensuring sync continues. + """ + google_single_user_mapping = { + "user1@test.com": ["group1@google.com"], + "user2@test.com": ["group3@google.com"] + } + mock_gcm_instance = cloud_manager.return_value.__enter__.return_value + mock_gcm_instance.get_groups_for_user.side_effect = [ + ["group1@google.com", "group2@google.com"], # user1 current groups + ["group3@google.com", "group4@google.com"] # user2 current groups + ] + mock_gcm_instance.remove_member_from_group.side_effect = [ + None, # Success for user1 removal + Exception("Error removing group for user2") # Failure for user2 removal + ] + + with pytest.raises(GoogleUpdateException): + update_google_groups_for_users(google_single_user_mapping) + + # Assertions for user1@test.com (removal success) + mock_gcm_instance.get_groups_for_user.assert_any_call("user1@test.com") + mock_gcm_instance.remove_member_from_group.assert_any_call("user1@test.com", "group2@google.com") + + # Assertions for user2@test.com (removal failure) + mock_gcm_instance.get_groups_for_user.assert_any_call("user2@test.com") + mock_gcm_instance.remove_member_from_group.assert_any_call("user2@test.com", "group4@google.com") \ No newline at end of file