diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..e1feabf --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +language: python +python: + - "3.6" +install: pip install tox-travis +script: + - tox -e pep8,py3 diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py index 3a5e9a3..c775b9b 100644 --- a/unit_tests/__init__.py +++ b/unit_tests/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2016 Canonical Ltd +# Copyright 2019 Canonical Ltd # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -16,7 +16,15 @@ import sys sys.path.append('src') sys.path.append('src/lib') +sys.path.append('src/actions') # Mock out charmhelpers so that we can test without it. import charms_openstack.test_mocks # noqa charms_openstack.test_mocks.mock_charmhelpers() + +import mock +import charms +keystoneauth1 = mock.MagicMock() +sys.modules['keystoneauth1'] = keystoneauth1 +charms.leadership = mock.MagicMock() +sys.modules['charms.leadership'] = charms.leadership diff --git a/unit_tests/test_actions.py b/unit_tests/test_actions.py new file mode 100644 index 0000000..e216199 --- /dev/null +++ b/unit_tests/test_actions.py @@ -0,0 +1,62 @@ +# Copyright 2019 Canonical Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import +from __future__ import print_function + +import mock + +import charms_openstack.test_utils as test_utils + +import actions + + +class TestKeystoneSAMLMellonActions(test_utils.PatchHelper): + + def setUp(self): + super().setUp() + self.patch_object(actions.hookenv, 'action_set') + self.patch_object(actions.hookenv, 'action_fail') + self.patch_object(actions, "os") + + self.patch( + "builtins.open", new_callable=mock.mock_open(), name="open") + self.file = mock.MagicMock() + self.fileobj = mock.MagicMock() + self.fileobj.__enter__.return_value = self.file + self.open.return_value = self.fileobj + + def test_get_sp_metadata(self): + # Valid XML + self.sp_metadata_xml = ( + "" + "" + " " + " ") + self.file.readlines.return_value = self.sp_metadata_xml + self.metadata_file = ("/etc/apache2/mellon/" + "sp-meta.keystone-saml-mellon.xml") + + # File Does not exist + self.os.path.exists.return_value = False + actions.get_sp_metadata() + self.action_fail.assert_called_once_with( + "The SP metadata file {} does not exist" + .format(self.metadata_file)) + + # File exists + self.os.path.exists.return_value = True + actions.get_sp_metadata() + self.action_set.assert_called_once_with( + {"output": self.sp_metadata_xml}) diff --git a/unit_tests/test_keystone_saml_mellon_handlers.py b/unit_tests/test_keystone_saml_mellon_handlers.py new file mode 100644 index 0000000..ef9387a --- /dev/null +++ b/unit_tests/test_keystone_saml_mellon_handlers.py @@ -0,0 +1,151 @@ +# Copyright 2019 Canonical Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import +from __future__ import print_function + +import mock + +import charm.openstack.keystone_saml_mellon as keystone_saml_mellon +import reactive.keystone_saml_mellon_handlers as handlers + +import charms_openstack.test_utils as test_utils + + +class TestRegisteredHooks(test_utils.TestRegisteredHooks): + + def test_hooks(self): + defaults = [ + 'charm.installed', + 'update-status'] + hook_set = { + 'hook': { + 'default_upgrade_charm': ('upgrade-charm',), + }, + 'when': { + 'render_config': ( + 'endpoint.keystone-fid-service-provider.joined', + 'config.complete', + 'keystone-data.complete',), + 'config_changed': ( + 'endpoint.keystone-fid-service-provider.joined',), + 'keystone_data_changed': ( + 'endpoint.keystone-fid-service-provider.joined',), + 'configure_websso': ( + 'endpoint.websso-fid-service-provider.joined', + 'config.complete', + 'keystone-data.complete', + 'config.rendered',), + }, + 'when_not': { + 'config_changed': ('config.complete',), + 'keystone_departed': ( + 'endpoint.keystone-fid-service-provider.joined',), + 'keystone_data_changed': ('keystone-data.complete',), + 'render_config': ('config.rendered',), + 'assess_status': ('always.run',), + }, + } + # test that the hooks were registered via the + # reactive.keystone_saml_mellon_handlers + self.registered_hooks_test_helper(handlers, hook_set, defaults) + + +class TestKeystoneSAMLMellonHandlers(test_utils.PatchHelper): + + def setUp(self): + super().setUp() + self.patch_release( + keystone_saml_mellon.KeystoneSAMLMellonCharm.release) + self.keystone_saml_mellon_charm = mock.MagicMock() + self.patch_object(handlers.charm, 'provide_charm_instance', + new=mock.MagicMock()) + self.provide_charm_instance().__enter__.return_value = ( + self.keystone_saml_mellon_charm) + self.provide_charm_instance().__exit__.return_value = None + + self.patch_object(handlers, 'flags') + + self.uuid = 'uuid-uuid' + self.patch_object(handlers.uuid, 'uuid4') + self.uuid4.return_value = self.uuid + + self.patch_object(handlers, 'unitdata', + new=mock.MagicMock()) + self.kv = mock.MagicMock() + self.unitdata.kv.return_value = self.kv + + self.patch_object(handlers, 'endpoint_from_flag', + new=mock.MagicMock()) + self.endpoint = mock.MagicMock() + self.endpoint_from_flag.return_value = self.endpoint + + self.protocol_name = "mapped" + self.remote_id_attribute = "https://samltest.id" + self.idp_name = "samltest" + self.user_facing_name = "samltest.id" + self.keystone_saml_mellon_charm.options.protocol_name = ( + self.protocol_name) + self.keystone_saml_mellon_charm.options.remote_id_attribute = ( + self.remote_id_attribute) + self.keystone_saml_mellon_charm.options.idp_name = self.idp_name + self.keystone_saml_mellon_charm.options.user_facing_name = ( + self.user_facing_name) + + self.all_joined_units = [] + for i in range(0, 2): + unit = mock.MagicMock() + unit.name = "keystone-{}".format(i) + unit.recieved = {"hostname": unit.name, + "port": "5000", + "tls-enabled": True} + self.all_joined_units.append(unit) + + def test_keystone_departed(self): + handlers.keystone_departed() + self.keystone_saml_mellon_charm.remove_config.assert_called_once_with() + + def test_keystone_data_changed(self): + kv_set_calls = [ + mock.call("tls-enabled", True), + mock.call("port", "5000"), + mock.call("hostname", "keystone-0"), + ] + + handlers.keystone_data_changed(self.endpoint) + + self.kv.set.has_calls(kv_set_calls) + self.flags.set_flag.assert_called_once_with('keystone-data.complete') + + def test_render_config(self): + handlers.render_config() + self.keystone_saml_mellon_charm.render_config.assert_called_once_with() + self.flags.set_flag.assert_called_once_with('config.rendered') + self.endpoint.publish.assert_called_once_with( + self.uuid, self.protocol_name, self.remote_id_attribute) + + def test_config_changed(self): + handlers.config_changed() + (self.keystone_saml_mellon_charm.configuration_complete + .return_value) = True + self.flags.set_flag.assert_called_once_with('config.complete') + + def test_configure_websso(self): + handlers.configure_websso() + self.endpoint.publish.assert_called_once_with( + self.protocol_name, self.idp_name, self.user_facing_name) + + def test_assess_status(self): + handlers.assess_status() + self.keystone_saml_mellon_charm.assess_status.assert_called_once_with() diff --git a/unit_tests/test_lib_charm_openstack_keystone_saml_mellon.py b/unit_tests/test_lib_charm_openstack_keystone_saml_mellon.py new file mode 100644 index 0000000..cf67e77 --- /dev/null +++ b/unit_tests/test_lib_charm_openstack_keystone_saml_mellon.py @@ -0,0 +1,378 @@ +# Copyright 2019 Canonical Ltd +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import absolute_import +from __future__ import print_function + +import mock + +import charms_openstack.test_utils as test_utils + +import charm.openstack.keystone_saml_mellon as keystone_saml_mellon + + +def FakeConfig(init_dict): + + def _config(key=None): + return init_dict[key] if key else init_dict + + return _config + + +def FakeResourceGet(init_dict): + + def _config(key=None): + return init_dict[key] if key else init_dict + + return _config + + +class Helper(test_utils.PatchHelper): + + def setUp(self): + super().setUp() + self.patch_release( + keystone_saml_mellon.KeystoneSAMLMellonCharm.release) + + self.patch_object(keystone_saml_mellon, 'unitdata', + new=mock.MagicMock()) + self.kv = mock.MagicMock() + self.unitdata.kv.return_value = self.kv + + self.patch_object(keystone_saml_mellon.os_utils, 'os_release', + new=mock.MagicMock()) + + self.idp_name = "samltest" + self.protocol_name = "mapped" + self.user_facing_name = "samltest.id" + self.nameid_formats = "fake:name:id:format1,fake:name:id:format2" + self.test_config = { + "idp-name": self.idp_name, + "protocol-name": self.protocol_name, + "user-facing-name": self.user_facing_name, + "nameid-formats": self.nameid_formats, + "subject-confirmation-data-address-check": False + } + self.resources = { + "idp-metadata": "/path/to/idp-metadata.xml", + "sp-private-key": "/path/to/sp-private-key.pem", + "sp-signing-keyinfo": "/path/to/sp-signing-keyinfo.xml" + } + self.patch_object(keystone_saml_mellon.hookenv, 'config', + side_effect=FakeConfig(self.test_config)) + self.patch_object(keystone_saml_mellon.hookenv, 'resource_get', + side_effect=FakeResourceGet(self.resources)) + self.patch_object( + keystone_saml_mellon.hookenv, 'application_version_set') + self.patch_object(keystone_saml_mellon.hookenv, 'status_set') + self.patch_object(keystone_saml_mellon.ch_host, 'mkdir') + self.patch_object(keystone_saml_mellon.core.templating, 'render') + + self.template_loader = mock.MagicMock() + self.patch_object(keystone_saml_mellon.os_templating, 'get_loader', + return_value=self.template_loader) + self.patch_object( + keystone_saml_mellon.KeystoneSAMLMellonCharm, + 'application_version', + return_value="1.0.0") + + self.patch_object( + keystone_saml_mellon.KeystoneSAMLMellonCharm, 'render_configs') + self.patch_object(keystone_saml_mellon, 'os') + self.patch_object(keystone_saml_mellon, 'subprocess') + + self.patch( + "builtins.open", new_callable=mock.mock_open(), name="open") + self.file = mock.MagicMock() + self.fileobj = mock.MagicMock() + self.fileobj.__enter__.return_value = self.file + self.open.return_value = self.fileobj + + +class TestKeystoneSAMLMellonUtils(Helper): + + def test_select_release(self): + self.kv.get.return_value = 'mitaka' + self.assertEqual( + keystone_saml_mellon.select_release(), 'mitaka') + + self.kv.get.return_value = None + self.os_release.return_value = 'rocky' + self.assertEqual( + keystone_saml_mellon.select_release(), 'rocky') + + +class TestKeystoneSAMLMellonConfigurationAdapter(Helper): + + def setUp(self): + super().setUp() + self.hostname = "keystone-sp.local" + self.port = "5000" + self.tls_enabled = True + self.unitdata_data = { + "hostname": self.hostname, + "port": self.port, + "tls-enabled": self.tls_enabled, + } + self.kv.get.side_effect = FakeConfig(self.unitdata_data) + self.base_url = "https://{}:{}".format(self.hostname, self.port) + + def test_validation_errors(self): + errors = {"idp-metadata": "Bad XML"} + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + ksmca._validation_errors = errors + self.assertEqual(ksmca.validation_errors, errors) + + def test_remote_id_attribute(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual(ksmca.remote_id_attribute, "MELLON_IDP") + + def test_idp_metadata_file(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.idp_metadata_file, keystone_saml_mellon.IDP_METADATA) + + def test_sp_metadata_file(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.sp_metadata_file, keystone_saml_mellon.SP_METADATA) + + def test_sp_private_key_file(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.sp_private_key_file, keystone_saml_mellon.SP_PRIVATE_KEY) + + def test_keystone_host(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual(ksmca.keystone_host, self.hostname) + + def test_keystone_port(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual(ksmca.keystone_port, self.port) + + def test_keystone_tls_enabled(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual(ksmca.tls_enabled, self.tls_enabled) + + def test_keystone_base_url(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual(ksmca.keystone_base_url, self.base_url) + + def test_sp_idp_path(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.sp_idp_path, + '/v3/OS-FEDERATION/identity_providers/{}'.format(self.idp_name)) + + def test_sp_protocol_path(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.sp_protocol_path, + '{}/protocols/{}'.format(ksmca.sp_idp_path, self.protocol_name)) + + def test_sp_auth_path(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.sp_auth_path, '{}/auth'.format(ksmca.sp_protocol_path)) + + def test_mellon_endpoint_path(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.mellon_endpoint_path, '{}/mellon'.format(ksmca.sp_auth_path)) + + def test_websso_auth_idp_protocol_path(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.websso_auth_idp_protocol_path, + ('/v3/auth/OS-FEDERATION/identity_providers/{}/protocols/{}/websso' + .format(self.idp_name, self.protocol_name))) + + def test_sp_post_response_path(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.sp_post_response_path, + '{}/postResponse'.format(ksmca.mellon_endpoint_path)) + + def test_sp_logout_path(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.sp_logout_path, + '{}/logout'.format(ksmca.mellon_endpoint_path)) + + def test_sp_auth_url(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.sp_auth_url, + '{}{}'.format(ksmca.keystone_base_url, ksmca.sp_auth_path)) + + def test_sp_logout_url(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.sp_logout_url, + '{}{}'.format(ksmca.keystone_base_url, ksmca.sp_logout_path)) + + def test_sp_post_response_url(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.sp_post_response_url, + '{}{}'.format(ksmca.keystone_base_url, + ksmca.sp_post_response_path)) + + def test_mellon_subject_confirmation_data_address_check(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.mellon_subject_confirmation_data_address_check, + 'Off') + + def test_supported_nameid_formats(self): + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + self.assertEqual( + ksmca.supported_nameid_formats, self.nameid_formats.split(",")) + + def test_idp_metadata(self): + self.os.path.exists.return_value = True + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + # Valid XML + self.idp_metadata_xml = ( + "" + " " + "") + self.file.read.return_value = self.idp_metadata_xml + self.assertEqual(ksmca.idp_metadata, self.idp_metadata_xml) + self.open.assert_called_with(self.resources["idp-metadata"]) + + # Inalid XML + ksmca._idp_metadata = None + self.file.read.return_value = "INVALID XML" + self.assertEqual(ksmca.idp_metadata, "") + self.assertEqual( + ksmca._validation_errors, + {"idp-metadata": ksmca.IDP_METADATA_INVALID}) + + def test_sp_signing_keyinfo(self): + self.os.path.exists.return_value = True + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + # Valid XML + self.sp_signing_keyinfo_xml = ( + "" + "" + " " + " ") + self.file.read.return_value = self.sp_signing_keyinfo_xml + self.assertEqual(ksmca.sp_signing_keyinfo, self.sp_signing_keyinfo_xml) + self.open.assert_called_with(self.resources["sp-signing-keyinfo"]) + + # Inalid XML + ksmca._sp_signing_keyinfo = None + self.file.read.return_value = "INVALID XML" + self.assertEqual(ksmca.sp_signing_keyinfo, "") + self.assertEqual( + ksmca._validation_errors, + {"sp-signing-keyinfo": ksmca.SP_SIGNING_KEYINFO_INVALID}) + + def test_sp_private_key(self): + self.os.path.exists.return_value = True + ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() + # Valid Key + self.sp_private_key_pem = (""" +-----BEGIN RSA PRIVATE KEY----- +MIIBPAIBAAJBANLUtlT9JMQ/RcGEipW6MBtUoFBGMOclUmOpP1BbaFJoBn19J0UG +STj29M9nDLDRdfP0O/JiisG6ejxmO0A0xTsCAwEAAQJBAKT0IKRmW3ngN2etl/CF ++FWp5LRp9qEjJk8rgIoSupCdvuT0Q6XLk/ygHeiBYcKTf2pT/PWjQxg1pD7So5K8 +YcECIQD5SKfItJ5YC9mD+6H28UqQATPehRPhQEEFIl/lJCrFgwIhANiC14XvcuWc +xMy1Lcc5lFkrB+b+oWVKJyMpNTHgXivpAiEAqh0FurZfNDBp8GJgpbcFrf3UGq7v +4RBLDqjljeY/decCIEk3/lDCCFYULQ2ZW9Da7Qs2nSaGB+isKg4e+mlSmiY5AiEA +lAoUNjDHWBOlyXziqZiufMURqbPPbRkEjWwN8G2r15A= +-----END RSA PRIVATE KEY----- + """) + self.file.read.return_value = self.sp_private_key_pem + self.assertEqual(ksmca.sp_private_key, self.sp_private_key_pem) + self.open.assert_called_with(self.resources["sp-private-key"]) + + # Invalid Key + ksmca._sp_private_key = None + self.file.read.return_value = "INVALID PEM KEY" + self.assertEqual(ksmca.sp_private_key, '') + self.assertEqual( + ksmca._validation_errors, + {"sp-private-key": ksmca.SP_PRIVATE_KEY_INVALID}) + + +class TestKeystoneSAMLMellonCharm(Helper): + + def setUp(self): + super().setUp() + self.patch_object( + keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter, + 'idp_metadata') + self.patch_object( + keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter, + 'sp_private_key') + self.patch_object( + keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter, + 'sp_signing_keyinfo') + self.idp_metadata.return_value = self.resources["idp-metadata"] + self.idp_metadata.__bool__.return_value = True + self.sp_private_key.return_value = self.resources["sp-private-key"] + self.sp_private_key.__bool__.return_value = True + self.sp_signing_keyinfo.return_value = self.resources[ + "sp-signing-keyinfo"] + self.sp_signing_keyinfo.__bool__.return_value = True + + def test_configuration_complete(self): + ksm = keystone_saml_mellon.KeystoneSAMLMellonCharm() + self.assertTrue(ksm.configuration_complete()) + + # One option not ready + self.sp_signing_keyinfo.__bool__.return_value = False + self.assertFalse(ksm.configuration_complete()) + + def test_assess_status(self): + ksm = keystone_saml_mellon.KeystoneSAMLMellonCharm() + ksm.assess_status() + self.application_version_set.asert_called_once_with() + self.status_set.assert_called_once_with("active", "Unit is ready") + + # One option not ready + self.status_set.reset_mock() + self.sp_signing_keyinfo.__bool__.return_value = False + ksm.options._validation_errors = {"idp-metadata": "malformed"} + ksm.assess_status() + self.status_set.assert_called_once_with( + "blocked", "Configuration is incomplete. idp-metadata: malformed") + + def test_render_config(self): + ksm = keystone_saml_mellon.KeystoneSAMLMellonCharm() + ksm.render_config() + self.assertEqual(self.render_configs.call_count, 1) + self.assertEqual(self.render.call_count, 2) + + def test_remove_config(self): + self.os.path.exists.return_value = True + ksm = keystone_saml_mellon.KeystoneSAMLMellonCharm() + ksm.remove_config() + self.assertEqual(self.os.path.exists.call_count, 4) + self.assertEqual(self.os.unlink.call_count, 4) + + def test_enable_module(self): + ksm = keystone_saml_mellon.KeystoneSAMLMellonCharm() + ksm.enable_module() + self.subprocess.check_call.assert_called_once_with( + ['a2enmod', 'auth_mellon']) + + def test_disable_module(self): + ksm = keystone_saml_mellon.KeystoneSAMLMellonCharm() + ksm.disable_module() + self.subprocess.check_call.assert_called_once_with( + ['a2dismod', 'auth_mellon'])