diff --git a/distributedcloud/dcorch/engine/sync_services/identity.py b/distributedcloud/dcorch/engine/sync_services/identity.py index 51dda7ae0..d04419f52 100644 --- a/distributedcloud/dcorch/engine/sync_services/identity.py +++ b/distributedcloud/dcorch/engine/sync_services/identity.py @@ -1029,7 +1029,7 @@ class IdentitySyncThread(SyncThread): update_role(sc_role_id, role_records) if not role_ref: LOG.error("No role data returned when updating role {} in" - " subcloud.".format(role_id), extra=self.log_extra) + " subcloud.".format(sc_role_id), extra=self.log_extra) raise exceptions.SyncRequestFailed # Persist the subcloud resource. @@ -1349,7 +1349,7 @@ class IdentitySyncThread(SyncThread): get('revocation_event').get('audit_id') subcloud_rsrc_id = self.\ persist_db_subcloud_resource(rsrc.id, revoke_event_ref_id) - LOG.info("Created Keystone token revoke event {}:{}" + LOG.info("Created Keystone token revocation event {}:{}" .format(rsrc.id, subcloud_rsrc_id), extra=self.log_extra) @@ -1424,7 +1424,7 @@ class IdentitySyncThread(SyncThread): subcloud_rsrc_id = self.persist_db_subcloud_resource(rsrc.id, event_id) - LOG.info("Created Keystone token revoke event {}:{}" + LOG.info("Created Keystone token revocation event {}:{}" .format(rsrc.id, subcloud_rsrc_id), extra=self.log_extra) diff --git a/distributedcloud/dcorch/tests/base.py b/distributedcloud/dcorch/tests/base.py index c785c54ef..637077bc8 100644 --- a/distributedcloud/dcorch/tests/base.py +++ b/distributedcloud/dcorch/tests/base.py @@ -24,6 +24,7 @@ from oslo_db import options from oslotest import base import sqlalchemy +from dcmanager.rpc import client as dcmanager_rpc_client from dcorch.db import api from dcorch.db.sqlalchemy import api as db_api from dcorch.rpc import client as rpc_client @@ -84,8 +85,24 @@ class OrchestratorTestCase(base.BaseTestCase): self.mock_rpc_client = mock_patch.start() self.addCleanup(mock_patch.stop) - def _mock_openstack_driver(self, target): - mock_patch = mock.patch.object(target, 'OpenStackDriver') + def _mock_rpc_client_subcloud_state_client(self): + mock_patch = mock.patch.object(dcmanager_rpc_client, 'SubcloudStateClient') + self.rpc_client_subcloud_state_client = mock_patch.start() + self.addCleanup(mock_patch.stop) + + def _mock_rpc_client_manager(self): + mock_patch = mock.patch.object(dcmanager_rpc_client, 'ManagerClient') + self.rpc_client_manager = mock_patch.start() + self.addCleanup(mock_patch.stop) + + def _mock_log(self, target): + mock_patch = mock.patch.object(target, 'LOG') + self.log = mock_patch.start() + self.addCleanup(mock_patch.stop) + + def _mock_openstack_driver(self): + mock_patch = \ + mock.patch('dccommon.drivers.openstack.sdk_platform.OpenStackDriver') self.mock_openstack_driver = mock_patch.start() self.addCleanup(mock_patch.stop) diff --git a/distributedcloud/dcorch/tests/unit/engine/sync_services/__init__.py b/distributedcloud/dcorch/tests/unit/engine/sync_services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/distributedcloud/dcorch/tests/unit/engine/sync_services/mixins.py b/distributedcloud/dcorch/tests/unit/engine/sync_services/mixins.py new file mode 100644 index 000000000..4ccbdcad4 --- /dev/null +++ b/distributedcloud/dcorch/tests/unit/engine/sync_services/mixins.py @@ -0,0 +1,366 @@ +# Copyright (c) 2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +import mock + +from keystoneauth1 import exceptions as keystone_exceptions + +from oslo_serialization import jsonutils + +import dcorch.common.exceptions as exceptions + +from dcdbsync.dbsyncclient import exceptions as dbsync_exceptions + + +class BaseMixin(object): + """Base mixin class to declare common methods for generic resource requests""" + + def _get_request(self): + """Returns the request object""" + + raise NotImplementedError + + def _get_rsrc(self): + """Returns the rsrc object""" + + raise NotImplementedError + + def _get_log(self): + """Returns the log object""" + + raise NotImplementedError + + def _get_subcloud(self): + """Returns the subcloud object""" + + raise NotImplementedError + + def _get_subcloud_resource(self): + """Returns the subcloud resouce object""" + + raise NotImplementedError + + def _get_resource_name(self): + """Returns the resource name""" + + raise NotImplementedError + + def _get_resource_ref(self): + """Returns the resource ref mock""" + + raise NotImplementedError + + def _get_resource_ref_name(self): + """Returns the resource ref name path""" + + raise NotImplementedError + + def _resource_add(self): + """Returns the resource's add method""" + + raise NotImplementedError + + def _resource_detail(self): + """Returns the resource's detail method""" + + raise NotImplementedError + + def _resource_update(self): + """Returns the resource's update method""" + + raise NotImplementedError + + def _resource_keystone_update(self): + """Returns the resource's update method from Keystone""" + + raise NotImplementedError + + def _resource_keystone_delete(self): + """Returns the resource's delete method from Keystone""" + + raise NotImplementedError + + def _execute(self): + """Executes the method""" + + raise NotImplementedError + + def _execute_and_assert_exception(self, exception): + """Executes the method""" + + raise NotImplementedError + + def _assert_log(self, level, message, extra=mock.ANY): + """Asserts the log's call""" + + raise NotImplementedError + + +class PostResourceMixin(BaseMixin): + """Base mixin class for post requests to a resource""" + + def test_post_succeeds(self): + """Test post succeeds""" + + self._resource_add().return_value = self._get_resource_ref() + + self._execute() + + self._resource_detail().assert_called_once() + self._resource_add().assert_called_once() + + self._assert_log( + 'info', f"Created Keystone {self._get_resource_name()} " + f"{self._get_rsrc().id}:" + f"{self._get_resource_ref().get(self._get_resource_name()).get('id')} " + f"[{self._get_resource_ref_name()}]" + ) + + def test_post_fails_without_source_resource_id(self): + """Test post fails without source resource id""" + + self._get_request().orch_job.source_resource_id = None + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', f"Received {self._get_resource_name()} create request " + "without required 'source_resource_id' field" + ) + + def test_post_fails_with_dbsync_unauthorized_exception(self): + """Test post fails with dbsync unauthorized exception""" + + self._resource_detail().side_effect = dbsync_exceptions.Unauthorized() + + self._execute_and_assert_exception(dbsync_exceptions.UnauthorizedMaster) + + self._resource_detail().assert_called_once() + self._resource_add().assert_not_called() + + def test_post_fails_with_empty_resource_ref(self): + """Test post fails with empty resource ref""" + + self._resource_add().return_value = None + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', f"No {self._get_resource_name()} data returned when creating " + f"{self._get_resource_name()} " + f"{self._get_request().orch_job.source_resource_id} in subcloud." + ) + + def test_post_fails_without_resource_records(self): + """Test post fails without resource records""" + + self._resource_detail().return_value = None + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', "No data retrieved from master cloud for " + f"{self._get_resource_name()} " + f"{self._get_request().orch_job.source_resource_id} to create its " + "equivalent in subcloud." + ) + + +class PutResourceMixin(BaseMixin): + """Base mixin class for put requests to a resource""" + + def test_put_succeeds(self): + """Test put succeeds""" + + self._resource_update().return_value = self._get_resource_ref() + + self._execute() + + self._resource_detail().assert_called_once() + self._resource_update().assert_called_once() + self._assert_log( + 'info', f"Updated Keystone {self._get_resource_name()} {self.rsrc.id}:" + f"{self._get_resource_ref().get(self._get_resource_name()).get('id')} " + f"[{self._get_resource_ref_name()}]" + ) + + def test_put_fails_without_source_resource_id(self): + """Test put fails without source resource id""" + + self._get_request().orch_job.source_resource_id = None + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', f"Received {self._get_resource_name()} update request " + "without required source resource id" + ) + + def test_put_fails_without_id_in_resource_info(self): + """Test put fails without id in resource info""" + + print(f"{{{self._get_resource_name()}: {{}}}}") + self._get_request().orch_job.resource_info = \ + f'{{"{self._get_resource_name()}": {{}}}}' + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', f"Received {self._get_resource_name()} update request " + "without required subcloud resource id" + ) + + def test_put_fails_with_dbsync_unauthorized_exception(self): + """Test put fails with dbsync unauthorized exception""" + + self._resource_detail().side_effect = dbsync_exceptions.Unauthorized + + self._execute_and_assert_exception(dbsync_exceptions.UnauthorizedMaster) + + self._resource_detail().assert_called_once() + self._resource_update().assert_not_called() + + def test_put_fails_without_resource_records(self): + """Test put fails without resource records""" + + self._resource_detail().return_value = None + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', "No data retrieved from master cloud for " + f"{self._get_resource_name()} " + f"{self._get_request().orch_job.source_resource_id} " + "to update its equivalent in subcloud." + ) + + def test_put_fails_without_resource_ref(self): + """Test put fails without resource ref""" + + self._resource_update().return_value = None + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', f"No {self._get_resource_name()} data returned when updating " + f"{self._get_resource_name()} " + f"{self._get_resource_ref().get(self._get_resource_name()).get('id')} " + "in subcloud." + ) + + +class PatchResourceMixin(BaseMixin): + """Base mixin class for patch requests to a resource""" + + def test_patch_succeeds(self): + """Test patch succeeds""" + + mock_update = mock.Mock() + mock_update.id = self._get_subcloud_resource().subcloud_resource_id + self._resource_keystone_update().return_value = mock_update + + self._execute() + + self._resource_keystone_update().assert_called_once() + self._assert_log( + 'info', f"Updated Keystone {self._get_resource_name()}: " + f"{self._get_rsrc().id}:{mock_update.id}" + ) + + def test_patch_fails_with_empty_resource_update_dict(self): + """Test patch fails with empty resource update dict""" + + self._get_request().orch_job.resource_info = "{}" + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', f"Received {self._get_resource_name()} update request " + "without any update fields" + ) + + def test_patch_fails_without_resource_subcloud_rsrc(self): + """Test patch fails with empty resource update dict + + When the resource id and subcloud id does not match to a subcloud resource, + the resource subcloud rsrc is not found + """ + + loaded_resource_info = \ + jsonutils.loads(self._get_request().orch_job.resource_info) + + self._get_rsrc().id = 9999 + + self._execute() + + self._assert_log( + 'error', f"Unable to update {self._get_resource_name()} reference " + f"{self.rsrc}:{loaded_resource_info[self._get_resource_name()]}, cannot " + f"find equivalent Keystone {self._get_resource_name()} in subcloud." + ) + + def test_patch_fails_with_resource_ref_id_not_equal_resource_id(self): + """Test patch fails with resource ref id not equal resource id""" + + mock_update = mock.Mock() + mock_update.id = 9999 + self._resource_keystone_update().return_value = mock_update + + self._execute() + + self._assert_log( + 'error', f"Unable to update Keystone {self._get_resource_name()} " + f"{self._get_rsrc().id}:" + f"{self._get_subcloud_resource().subcloud_resource_id} for subcloud" + ) + + +class DeleteResourceMixin(BaseMixin): + """Base mixin class for delete requests to a resource""" + + def test_delete_succeeds(self): + """Test delete succeeds""" + + self._execute() + + self._resource_keystone_delete().assert_called_once() + self._assert_log( + 'info', f"Keystone {self._get_resource_name()} {self._get_rsrc().id}:" + f"{self._get_subcloud_resource().id} " + f"[{self._get_subcloud_resource().subcloud_resource_id}] deleted" + ) + + def test_delete_succeeds_with_keystone_not_found_exception(self): + """Test delete succeeds with keystone's not found exception""" + + self._resource_keystone_delete().side_effect = keystone_exceptions.NotFound() + + self._execute() + + self._resource_keystone_delete().assert_called_once() + self._get_log().assert_has_calls([ + mock.call.info( + f"Delete {self._get_resource_name()}: {self._get_resource_name()} " + f"{self._get_subcloud_resource().subcloud_resource_id} " + f"not found in {self._get_subcloud().region_name}, " + "considered as deleted.", extra=mock.ANY + ), + mock.call.info( + f"Keystone {self._get_resource_name()} {self._get_rsrc().id}:" + f"{self._get_subcloud_resource().id} " + f"[{self._get_subcloud_resource().subcloud_resource_id}] deleted", + extra=mock.ANY + )], + any_order=False + ) + + def test_delete_fails_without_resource_subcloud_rsrc(self): + """Test delete fails without resource subcloud rsrc + + When the resource id and subcloud id does not match to a subcloud resource, + the user subcloud rsrc is not found + """ + + self._get_rsrc().id = 9999 + + self._execute() + + self._assert_log( + 'error', f"Unable to delete {self._get_resource_name()} reference " + f"{self._get_rsrc()}, cannot find equivalent Keystone " + f"{self._get_resource_name()} in subcloud." + ) diff --git a/distributedcloud/dcorch/tests/unit/engine/sync_services/test_identity.py b/distributedcloud/dcorch/tests/unit/engine/sync_services/test_identity.py new file mode 100644 index 000000000..ce40f5420 --- /dev/null +++ b/distributedcloud/dcorch/tests/unit/engine/sync_services/test_identity.py @@ -0,0 +1,921 @@ +# Copyright (c) 2024 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +import mock + +from keystoneauth1 import exceptions as keystone_exceptions +from oslo_serialization import jsonutils + +from dccommon import consts as dccommon_consts +from dcdbsync.dbsyncclient import exceptions as dbsync_exceptions +import dcorch.common.exceptions as exceptions +import dcorch.db.api as db_api +import dcorch.engine.sync_services.identity as identity_service +import dcorch.objects.subcloud_resource as subcloud_resource +from dcorch.tests.base import OrchestratorTestCase +import dcorch.tests.unit.engine.sync_services.mixins as mixins + +SOURCE_RESOURCE_ID = 2 +RESOURCE_ID = 3 +MASTER_ID = 4 + + +class BaseTestIdentitySyncThread(OrchestratorTestCase, mixins.BaseMixin): + """Base test class for IdentitySyncThread""" + + def setUp(self): + super().setUp() + + self._mock_openstack_driver() + self._mock_rpc_client_subcloud_state_client() + self._mock_rpc_client_manager() + self._mock_log(identity_service) + + self._create_request_and_resource_mocks() + self._create_subcloud_and_subcloud_resource() + + self.identity_sync_thread = identity_service.IdentitySyncThread( + self.subcloud.region_name + ) + + self.method = lambda *args: None + self.resource_name = '' + self.resource_ref = None + self.resource_ref_name = None + self.resource_add = lambda: None + self.resource_detail = lambda: None + self.resource_update = lambda: None + self.resource_keystone_update = lambda: None + self.resource_keystone_delete = lambda: None + + def _create_request_and_resource_mocks(self): + self.request = mock.MagicMock() + self.request.orch_job.resource_info = f'{{\"id\": {RESOURCE_ID}}}' + self.request.orch_job.source_resource_id = SOURCE_RESOURCE_ID + + self.rsrc = mock.MagicMock + self.rsrc.id = RESOURCE_ID + self.rsrc.master_id = MASTER_ID + + def _create_subcloud_and_subcloud_resource(self): + values = { + 'software_version': '10.04', + 'management_state': dccommon_consts.MANAGEMENT_MANAGED, + 'availability_status': dccommon_consts.AVAILABILITY_ONLINE, + 'initial_sync_state': '', + 'capabilities': {} + } + self.subcloud = db_api.subcloud_create(self.ctx, 'subcloud', values) + self.subcloud_resource = subcloud_resource.SubcloudResource( + self.ctx, subcloud_resource_id=self.rsrc.master_id, + resource_id=self.rsrc.id, subcloud_id=self.subcloud.id + ) + self.subcloud_resource.create() + + def _get_request(self): + return self.request + + def _get_rsrc(self): + return self.rsrc + + def _get_log(self): + return self.log + + def _get_subcloud(self): + return self.subcloud + + def _get_subcloud_resource(self): + return self.subcloud_resource + + def _get_resource_name(self): + return self.resource_name + + def _get_resource_ref(self): + return self.resource_ref + + def _get_resource_ref_name(self): + return self.resource_ref_name + + def _resource_add(self): + return self.resource_add + + def _resource_detail(self): + return self.resource_detail + + def _resource_update(self): + return self.resource_update + + def _resource_keystone_update(self): + return self.resource_keystone_update + + def _resource_keystone_delete(self): + return self.resource_keystone_delete + + def _execute(self): + self.method(self.request, self.rsrc) + + def _execute_and_assert_exception(self, exception): + self.assertRaises( + exception, + self.method, + self.request, + self.rsrc + ) + + def _assert_log(self, level, message, extra=mock.ANY): + if level == 'info': + self.log.info.assert_called_with(message, extra=extra) + elif level == 'error': + self.log.error.assert_called_with(message, extra=extra) + + +class BaseTestIdentitySyncThreadUsers(BaseTestIdentitySyncThread): + """Base test class for users' requests""" + + def setUp(self): + super().setUp() + + self.resource_name = 'user' + self.resource_ref = { + self.resource_name: {'id': RESOURCE_ID}, + 'local_user': {'name': 'fake value'} + } + self.resource_ref_name = self.resource_ref.get('local_user').get('name') + self.resource_detail = self.mock_openstack_driver().dbsync_client.\ + identity_user_manager.user_detail + + +class TestIdentitySyncThreadUsersPost( + BaseTestIdentitySyncThreadUsers, mixins.PostResourceMixin +): + """Test class for users' post method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.post_users + self.resource_add = self.mock_openstack_driver().dbsync_client.\ + identity_user_manager.add_user + + +class TestIdentitySyncThreadUsersPut( + BaseTestIdentitySyncThreadUsers, mixins.PutResourceMixin +): + """Test class for users' put method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.put_users + self.resource_update = self.mock_openstack_driver().dbsync_client.\ + identity_user_manager.update_user + + +class TestIdentitySyncThreadUsersPatch( + BaseTestIdentitySyncThreadUsers, mixins.PatchResourceMixin +): + """Test class for users' patch method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.patch_users + self.request.orch_job.resource_info = f'{{"{self.resource_name}": {{}}}}' + self.resource_keystone_update = self.mock_openstack_driver().\ + keystone_client.keystone_client.users.update + + +class TestIdentitySyncThreadUsersDelete( + BaseTestIdentitySyncThreadUsers, mixins.DeleteResourceMixin +): + """Test class for users' delete method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.delete_users + self.resource_keystone_delete = self.mock_openstack_driver().\ + keystone_client.keystone_client.users.delete + + +class BaseTestIdentitySyncThreadGroups(BaseTestIdentitySyncThread): + """Base test class for groups' methods""" + + def setUp(self): + super().setUp() + + self.resource_name = 'group' + self.resource_ref = \ + {self.resource_name: {'id': RESOURCE_ID, 'name': 'fake value'}} + self.resource_ref_name = \ + self.resource_ref.get(self.resource_name).get('name') + self.resource_detail = self.mock_openstack_driver().dbsync_client.\ + identity_group_manager.group_detail + + +class TestIdentitySyncThreadGroupsPost( + BaseTestIdentitySyncThreadGroups, mixins.PostResourceMixin +): + """Test class for groups' post method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.post_groups + self.resource_add = self.mock_openstack_driver().dbsync_client.\ + identity_group_manager.add_group + + +class TestIdentitySyncThreadGroupsPut( + BaseTestIdentitySyncThreadGroups, mixins.PutResourceMixin +): + """Test class for groups' put method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.put_groups + self.resource_update = self.mock_openstack_driver().dbsync_client.\ + identity_group_manager.update_group + + +class TestIdentitySyncThreadGroupsPatch( + BaseTestIdentitySyncThreadGroups, mixins.PatchResourceMixin +): + """Test class for groups' patch method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.patch_groups + self.request.orch_job.resource_info = f'{{"{self.resource_name}": {{}}}}' + self.resource_keystone_update = self.mock_openstack_driver().\ + keystone_client.keystone_client.groups.update + + +class TestIdentitySyncThreadGroupsDelete( + BaseTestIdentitySyncThreadGroups, mixins.DeleteResourceMixin +): + """Test class for groups' delete method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.delete_groups + self.resource_keystone_delete = self.mock_openstack_driver().\ + keystone_client.keystone_client.groups.delete + + +class BaseTestIdentitySyncThreadProjects(BaseTestIdentitySyncThread): + """Base test class for projects' methods""" + + def setUp(self): + super().setUp() + + self.resource_name = 'project' + self.resource_ref = { + self.resource_name: {'id': RESOURCE_ID, 'name': 'fake value'} + } + self.resource_ref_name = \ + self.resource_ref.get(self.resource_name).get('name') + self.resource_detail = self.mock_openstack_driver().dbsync_client.\ + project_manager.project_detail + + +class TestIdentitySyncThreadProjectsPost( + BaseTestIdentitySyncThreadProjects, mixins.PostResourceMixin +): + """Test class for projects' post method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.post_projects + self.resource_add = self.mock_openstack_driver().dbsync_client.\ + project_manager.add_project + + +class TestIdentitySyncThreadProjectsPut( + BaseTestIdentitySyncThreadProjects, mixins.PutResourceMixin +): + """Test class for projects' put method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.put_projects + self.resource_update = self.mock_openstack_driver().dbsync_client.\ + project_manager.update_project + + +class TestIdentitySyncThreadProjectsPatch( + BaseTestIdentitySyncThreadProjects, mixins.PatchResourceMixin +): + """Test class for projects' patch method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.patch_projects + self.request.orch_job.resource_info = f'{{"{self.resource_name}": {{}}}}' + self.resource_keystone_update = self.mock_openstack_driver().\ + keystone_client.keystone_client.projects.update + + +class TestIdentitySyncThreadProjectsDelete( + BaseTestIdentitySyncThreadProjects, mixins.DeleteResourceMixin +): + """Test class for projects' delete method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.delete_projects + self.resource_keystone_delete = self.mock_openstack_driver().\ + keystone_client.keystone_client.projects.delete + + +class BaseTestIdentitySyncThreadRoles(BaseTestIdentitySyncThread): + """Base test class for roles' methods""" + + def setUp(self): + super().setUp() + + self.resource_name = 'role' + self.resource_ref = { + self.resource_name: {'id': RESOURCE_ID, 'name': 'fake value'} + } + self.resource_ref_name = \ + self.resource_ref.get(self.resource_name).get('name') + self.resource_detail = self.mock_openstack_driver().dbsync_client.\ + role_manager.role_detail + + +class TestIdentitySyncThreadRolesPost( + BaseTestIdentitySyncThreadRoles, mixins.PostResourceMixin +): + """Test class for roles' post method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.post_roles + self.resource_add = self.mock_openstack_driver().dbsync_client.\ + role_manager.add_role + + +class TestIdentitySyncThreadRolesPut( + BaseTestIdentitySyncThreadRoles, mixins.PutResourceMixin +): + """Test class for roles' put method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.put_roles + self.resource_update = self.mock_openstack_driver().dbsync_client.\ + role_manager.update_role + + +class TestIdentitySyncThreadRolesPatch( + BaseTestIdentitySyncThreadRoles, mixins.PatchResourceMixin +): + """Test class for roles' patch method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.patch_roles + self.request.orch_job.resource_info = f'{{"{self.resource_name}": {{}}}}' + self.resource_keystone_update = self.mock_openstack_driver().\ + keystone_client.keystone_client.roles.update + + +class TestIdentitySyncThreadRolesDelete( + BaseTestIdentitySyncThreadRoles, mixins.DeleteResourceMixin +): + """Test class for roles' delete method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.delete_roles + self.resource_keystone_delete = self.mock_openstack_driver().\ + keystone_client.keystone_client.roles.delete + + +class BaseTestIdentitySyncThreadProjectRoleAssignments(BaseTestIdentitySyncThread): + """Base test class for project role assignments' methods""" + + def setUp(self): + super().setUp() + + self.project_id = 10 + self.actor_id = 11 + self.role_id = 12 + self.domain = 13 + + self.resource_tags = f'{self.project_id}_{self.actor_id}_{self.role_id}' + + +class TestIdentitySyncThreadProjectRoleAssignmentsPost( + BaseTestIdentitySyncThreadProjectRoleAssignments +): + """Test class for project role assignments' post method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.post_project_role_assignments + self.rsrc.master_id = self.resource_tags + + self.mock_sc_role = self._create_mock_object(self.role_id) + self.mock_openstack_driver().keystone_client.keystone_client.\ + roles.list.return_value = [self.mock_sc_role] + self.mock_openstack_driver().keystone_client.keystone_client.\ + projects.list.return_value = [self._create_mock_object(self.project_id)] + self.mock_openstack_driver().keystone_client.keystone_client.\ + domains.list.return_value = [self._create_mock_object(self.project_id)] + self.mock_openstack_driver().keystone_client.keystone_client.\ + users.list.return_value = [self._create_mock_object(self.actor_id)] + + def _create_mock_object(self, id): + mock_object = mock.MagicMock() + mock_object.id = str(id) + + return mock_object + + def test_post_succeeds_with_sc_user(self): + """Test post succeeds with sc user""" + + self._execute() + self._assert_log( + 'info', f"Created Keystone role assignment {self.rsrc.id}:" + f"{self.rsrc.master_id} [{self.rsrc.master_id}]" + ) + + def test_post_succeeds_with_sc_group(self): + """Test post succeeds with sc group""" + + self.mock_openstack_driver().keystone_client.keystone_client.\ + users.list.return_value = [] + self.mock_openstack_driver().keystone_client.keystone_client.\ + groups.list.return_value = [self._create_mock_object(self.actor_id)] + + self._execute() + self._assert_log( + 'info', f"Created Keystone role assignment {self.rsrc.id}:" + f"{self.rsrc.master_id} [{self.rsrc.master_id}]" + ) + + def test_post_fails_with_invalid_resource_tags(self): + """Test post fails with invalid resource tags""" + + self.rsrc.master_id = f'{self.project_id}_{self.actor_id}' + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', f"Malformed resource tag {self.rsrc.id} expected to be in " + "format: ProjectID_UserID_RoleID." + ) + + def test_post_fails_without_sc_role(self): + """Test post fails without sc role""" + + self.mock_openstack_driver().keystone_client.keystone_client.\ + roles.list.return_value = [] + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', "Unable to assign role to user on project reference " + f"{self.rsrc}:{self.role_id}, cannot " + "find equivalent Keystone Role in subcloud." + ) + + def test_post_fails_without_sc_proj(self): + """Test post fails without sc proj""" + + self.mock_openstack_driver().keystone_client.keystone_client.\ + projects.list.return_value = [] + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', "Unable to assign role to user on project reference " + f"{self.rsrc}:{self.project_id}, cannot " + "find equivalent Keystone Project in subcloud" + ) + + def test_post_fails_wihtout_sc_user_and_sc_group(self): + """Test post fails without sc user and sc group""" + + self.mock_openstack_driver().keystone_client.keystone_client.\ + users.list.return_value = [] + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', "Unable to assign role to user/group on project " + f"reference {self.rsrc}:{self.actor_id}, cannot find " + "equivalent Keystone User/Group in subcloud." + ) + + def test_post_fails_without_role_ref(self): + """Test post fails without role ref""" + + self.mock_openstack_driver().keystone_client.keystone_client.\ + role_assignments.list.return_value = [] + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', "Unable to update Keystone role assignment " + f"{self.rsrc.id}:{self.mock_sc_role} " + ) + + +class TestIdentitySyncThreadProjectRoleAssignmentsPut( + BaseTestIdentitySyncThreadProjectRoleAssignments +): + """Test class for project role assignments' put method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.put_project_role_assignments + self.subcloud_resource.subcloud_resource_id = self.resource_tags + + def test_put_succeeds(self): + """Test put succeeds + + Currently, there isn't an implementation for the put method. Because of + that, it only returns an empty response. + """ + + self._execute() + + self._assert_log('info', 'IdentitySyncThread initialized') + self.log.error.assert_not_called() + + +class TestIdentitySyncThreadProjectRoleAssignmentsDelete( + BaseTestIdentitySyncThreadProjectRoleAssignments +): + """Test class for project role assignments' delete method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.delete_project_role_assignments + + self.subcloud_resource.subcloud_resource_id = self.resource_tags + self.subcloud_resource.save() + + def test_delete_succeeds(self): + """Test delete succeeds""" + + self.mock_openstack_driver().keystone_client.keystone_client.\ + role_assignments.list.return_value = [] + + self._execute() + + self._assert_log( + 'info', "Deleted Keystone role assignment: " + f"{self.rsrc.id}:{self.subcloud_resource}" + ) + + def test_delete_succeeds_without_assignment_subcloud_rsrc(self): + """Test delete succeeds without assignment subcloud rsrc""" + + self.rsrc.id = 999 + + self._execute() + + self._assert_log( + 'error', f"Unable to delete assignment {self.rsrc}, " + "cannot find Keystone Role Assignment in subcloud." + ) + + def test_delete_succeeds_with_invalid_resource_tags(self): + """Test delete succeeds with invalid resource tags""" + + self.subcloud_resource.subcloud_resource_id = MASTER_ID + self.subcloud_resource.save() + + self._execute() + + self._assert_log( + 'error', f"Malformed subcloud resource tag {self.subcloud_resource}, " + "expected to be in format: ProjectID_UserID_RoleID or " + "ProjectID_GroupID_RoleID." + ) + + def test_delete_for_user_succeeds_with_keystone_not_found_exception(self): + """Test delete fails for user with keystone not found exception""" + + self.mock_openstack_driver().keystone_client.keystone_client.\ + roles.revoke.side_effect = [keystone_exceptions.NotFound, None] + self.mock_openstack_driver().keystone_client.keystone_client.\ + role_assignments.list.return_value = [] + + self._execute() + + self.log.assert_has_calls([ + mock.call.info( + f"Revoke role assignment: (role {self.role_id}, " + f"user {self.actor_id}, project {self.project_id}) " + f"not found in {self.subcloud.region_name}, " + "considered as deleted.", extra=mock.ANY + ), + mock.call.info( + f"Deleted Keystone role assignment: {self.rsrc.id}:" + f"{self.subcloud_resource}", extra=mock.ANY + )], + any_order=False + ) + + def test_delete_for_group_succeeds_with_keystone_not_found_exception(self): + """Test delete fails for group with keystone not found exception""" + + self.mock_openstack_driver().keystone_client.keystone_client.\ + roles.revoke.side_effect = keystone_exceptions.NotFound + + self._execute() + + self.log.assert_has_calls([ + mock.call.info( + f"Revoke role assignment: (role {self.role_id}, " + f"group {self.actor_id}, project {self.project_id}) " + f"not found in {self.subcloud.region_name}, " + "considered as deleted.", extra=mock.ANY + ), + mock.call.info( + f"Deleted Keystone role assignment: {self.rsrc.id}:" + f"{self.subcloud_resource}", extra=mock.ANY + )], + any_order=False + ) + + def test_delete_fails_without_role_ref(self): + """Test delete fails without role ref""" + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + + self._assert_log( + 'error', "Unable to delete Keystone role assignment " + f"{self.rsrc.id}:{self.role_id} " + ) + + +class BaseTestIdentitySyncThreadRevokeEvents(BaseTestIdentitySyncThread): + """Base test class for revoke events' methods""" + + def setUp(self): + super().setUp() + + self.resource_name = 'token revocation event' + self.resource_ref = { + 'revocation_event': {'audit_id': RESOURCE_ID, 'name': 'fake value'} + } + self.resource_ref_name = \ + self.resource_ref.get('revocation_event').get('name') + self.resource_detail = self.mock_openstack_driver().dbsync_client.\ + revoke_event_manager.revoke_event_detail + + +class BaseTestIdentitySyncThreadRevokeEventsPost( + BaseTestIdentitySyncThreadRevokeEvents, mixins.PostResourceMixin +): + """Test class for revoke events' post method""" + + def setUp(self): + super().setUp() + + self.resource_info = {"token_revoke_event": {"audit_id": RESOURCE_ID}} + self.request.orch_job.resource_info = jsonutils.dumps(self.resource_info) + self.method = self.identity_sync_thread.post_revoke_events + self.resource_add = self.mock_openstack_driver().dbsync_client.\ + revoke_event_manager.add_revoke_event + + def test_post_succeeds(self): + """Test post succeeds""" + + self._resource_add().return_value = self._get_resource_ref() + + self._execute() + + self._resource_detail().assert_called_once() + self._resource_add().assert_called_once() + + self._assert_log( + 'info', f"Created Keystone {self._get_resource_name()} " + f"{self._get_rsrc().id}:" + f"{self.resource_info.get('token_revoke_event').get('audit_id')}" + ) + + def test_post_fails_without_source_resource_id(self): + """Test post fails without source resource id""" + + self._get_request().orch_job.resource_info = "{}" + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', f"Received {self._get_resource_name()} create request " + "without required subcloud resource id" + ) + + def test_post_fails_with_empty_resource_ref(self): + """Test post fails with empty resource ref""" + + self._resource_add().return_value = None + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', f"No {self._get_resource_name()} data returned when creating " + f"{self._get_resource_name()} with audit_id " + f"{self.resource_info.get('token_revoke_event').get('audit_id')} " + "in subcloud." + ) + + def test_post_fails_without_resource_records(self): + """Test post fails without resource records""" + + self._resource_detail().return_value = None + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', "No data retrieved from master cloud for " + f"{self._get_resource_name()} with audit_id " + f"{self.resource_info.get('token_revoke_event').get('audit_id')} " + "to create its equivalent in subcloud." + ) + + +class BaseTestIdentitySyncThreadRevokeEventsDelete( + BaseTestIdentitySyncThreadRevokeEvents, mixins.DeleteResourceMixin +): + """Test class for revoke events' delete method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.delete_revoke_events + self.resource_keystone_delete = self.mock_openstack_driver().dbsync_client.\ + revoke_event_manager.delete_revoke_event + + def test_delete_succeeds_with_keystone_not_found_exception(self): + """Test delete succeeds with keystone's not found exception + + The revoke events doesn't use the keystone client + """ + + pass + + def test_delete_succeeds_with_dbsync_not_found_exception(self): + """Test delete succeeds with dbsync's not found exception""" + + self._resource_keystone_delete().side_effect = dbsync_exceptions.NotFound() + + self._execute() + + self._resource_keystone_delete().assert_called_once() + self._get_log().assert_has_calls([ + mock.call.info( + f"Delete {self._get_resource_name()}: event " + f"{self._get_subcloud_resource().subcloud_resource_id} " + f"not found in {self._get_subcloud().region_name}, " + "considered as deleted.", extra=mock.ANY + ), + mock.call.info( + f"Keystone {self._get_resource_name()} {self._get_rsrc().id}:" + f"{self._get_subcloud().id} " + f"[{self._get_subcloud_resource().subcloud_resource_id}] deleted", + extra=mock.ANY + )], + any_order=False + ) + + +class BaseTestIdentitySyncThreadRevokeEventsForUser(BaseTestIdentitySyncThread): + """Base test class for revoke events for user' methods""" + + def setUp(self): + super().setUp() + + self.resource_name = 'token revocation event' + self.resource_ref = { + 'revocation_event': {'audit_id': RESOURCE_ID, 'name': 'fake value'} + } + self.resource_ref_name = \ + self.resource_ref.get('revocation_event').get('name') + self.resource_detail = self.mock_openstack_driver().dbsync_client.\ + revoke_event_manager.revoke_event_detail + + +class TestIdentitySyncThreadRevokeEventsForUserPost( + BaseTestIdentitySyncThreadRevokeEventsForUser, mixins.PostResourceMixin +): + """Test class for revoke events for user' post method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.post_revoke_events_for_user + self.resource_add = self.mock_openstack_driver().dbsync_client.\ + revoke_event_manager.add_revoke_event + + def test_post_succeeds(self): + """Test post succeeds""" + + self._resource_add().return_value = self._get_resource_ref() + + self._execute() + + self._resource_detail().assert_called_once() + self._resource_add().assert_called_once() + + self._assert_log( + 'info', f"Created Keystone {self._get_resource_name()} " + f"{self._get_rsrc().id}:" + f"{self._get_request().orch_job.source_resource_id}" + ) + + def test_post_fails_without_source_resource_id(self): + """Test post fails without source resource id""" + + self._get_request().orch_job.source_resource_id = None + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', f"Received {self._get_resource_name()} create request " + "without required subcloud resource id" + ) + + def test_post_fails_with_empty_resource_ref(self): + """Test post fails with empty resource ref""" + + self._resource_add().return_value = None + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', f"No {self._get_resource_name()} data returned when creating " + f"{self._get_resource_name()} with event_id " + f"{self._get_request().orch_job.source_resource_id} in subcloud." + ) + + def test_post_fails_without_resource_records(self): + """Test post fails without resource records""" + + self._resource_detail().return_value = None + + self._execute_and_assert_exception(exceptions.SyncRequestFailed) + self._assert_log( + 'error', "No data retrieved from master cloud for " + f"{self._get_resource_name()} with event_id " + f"{self._get_request().orch_job.source_resource_id} to create its " + "equivalent in subcloud." + ) + + +class TestIdentitySyncThreadRevokeEventsForUserDelete( + BaseTestIdentitySyncThreadRevokeEventsForUser, mixins.DeleteResourceMixin +): + """Test class for revoke events for user' delete method""" + + def setUp(self): + super().setUp() + + self.method = self.identity_sync_thread.delete_revoke_events_for_user + self.resource_keystone_delete = self.mock_openstack_driver().dbsync_client.\ + revoke_event_manager.delete_revoke_event + + def test_delete_succeeds_with_keystone_not_found_exception(self): + """Test delete succeeds with keystone's not found exception + + The revoke events for users doesn't use the keystone client + """ + + pass + + def test_delete_succeeds_with_dbsync_not_found_exception(self): + """Test delete succeeds with dbsync's not found exception""" + + self._resource_keystone_delete().side_effect = dbsync_exceptions.NotFound() + + self._execute() + + self._resource_keystone_delete().assert_called_once() + self._get_log().assert_has_calls([ + mock.call.info( + f"Delete {self._get_resource_name()}: event " + f"{self._get_subcloud_resource().subcloud_resource_id} " + f"not found in {self._get_subcloud().region_name}, " + "considered as deleted.", extra=mock.ANY + ), + mock.call.info( + f"Keystone {self._get_resource_name()} {self._get_rsrc().id}:" + f"{self._get_subcloud().id} " + f"[{self._get_subcloud_resource().subcloud_resource_id}] deleted", + extra=mock.ANY + )], + any_order=False + )