diff --git a/.gitignore b/.gitignore index d40fdde..1a1c7cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ *.egg-info +*.pyc .cache .coverage +/build +/dist +/htmlcov diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 396fe66..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -flatdict==1.2.0 -pyyaml==3.11 \ No newline at end of file diff --git a/setup.py b/setup.py index 358d0d4..5ea4612 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ def run(self): 'pyyaml>=3.11', ], extras_require = { - 'test': ['codacy-coverage', 'mock', 'pytest', 'pytest-cov', 'python-coveralls', 'stormpath'], + 'test': ['codacy-coverage', 'mock', 'pytest', 'pytest-cov', 'pytest-env', 'pytest-xdist', 'python-coveralls', 'stormpath'], }, packages = find_packages(exclude=['*.tests', '*.tests.*', 'tests.*', 'tests']), classifiers = [ diff --git a/stormpath_config/errors.py b/stormpath_config/errors.py new file mode 100644 index 0000000..799c11a --- /dev/null +++ b/stormpath_config/errors.py @@ -0,0 +1,8 @@ +"""Custom errors.""" + + +class ConfigurationError(Exception): + """ + This exception is raised if a user has stormpath configuration. + """ + pass diff --git a/stormpath_config/loader.py b/stormpath_config/loader.py index d36666d..0f6ac56 100644 --- a/stormpath_config/loader.py +++ b/stormpath_config/loader.py @@ -1,3 +1,5 @@ + + """Configuration Loader.""" diff --git a/stormpath_config/strategies/__init__.py b/stormpath_config/strategies/__init__.py index a04fda2..0ee8095 100644 --- a/stormpath_config/strategies/__init__.py +++ b/stormpath_config/strategies/__init__.py @@ -9,3 +9,5 @@ from .load_file_config import LoadFileConfigStrategy from .load_file_path import LoadFilePathStrategy from .validate_client_config import ValidateClientConfigStrategy +from .move_apikey_to_client import MoveAPIKeyToClientAPIKeyStrategy +from .move_settings_to_config import MoveSettingsToConfigStrategy diff --git a/stormpath_config/strategies/enrich_integration_from_remote_config.py b/stormpath_config/strategies/enrich_integration_from_remote_config.py index 2650e35..5e740b9 100644 --- a/stormpath_config/strategies/enrich_integration_from_remote_config.py +++ b/stormpath_config/strategies/enrich_integration_from_remote_config.py @@ -1,5 +1,7 @@ from datetime import timedelta +from stormpath_config.errors import ConfigurationError +from .enrich_client_from_remote_config import _resolve_application_by_name from ..helpers import _extend_dict, to_camel_case @@ -162,6 +164,99 @@ class EnrichIntegrationFromRemoteConfigStrategy(object): def __init__(self, client_factory): self.client_factory = client_factory + def social_enabled_and_empty(self, config): + if not config or not isinstance(config, dict): + return False + return config.get('enabled') and not all([ + config.get('clientId'), + config.get('clientSecret') + ]) + + def validate(self, config): + """ + Ensure the user-specified settings are valid. + This will raise a ConfigurationError if anything mandatory is not + specified. + + :param dict config: The Flask app config. + """ + client = self.client_factory(config) + + # Check if application information is present. + application = config.get('application') + if not application: + raise ConfigurationError('Application cannot be empty.') + + href = application.get('href') + name = application.get('name') + + if href: + if '/applications/' not in href: + raise ConfigurationError( + 'Application HREF "%s" is not a valid Stormpath ' % href + + 'Application HREF.' + ) + elif name: + href = _resolve_application_by_name(client, config, name) + else: + raise ConfigurationError( + 'You must specify application name or href.') + application = client.applications.get(href) + + + # Check if google social information is present. + google_config = config['web']['social'].get('google') + + if not google_config or self.social_enabled_and_empty(google_config): + raise ConfigurationError( + 'You must define your Google app settings.') + + # Check if facebook social information is present. + facebook_config = config['web']['social'].get('facebook') + + if not facebook_config or self.social_enabled_and_empty(facebook_config): + raise ConfigurationError( + 'You must define your Facebook app settings.') + + # Check if default account store is present. + if ( + config['web']['register']['enabled'] and + not application.default_account_store_mapping): + raise ConfigurationError( + "No default account store is mapped to the specified " + "application. A default account store is required for " + "registration.") + + # Ensure that autologin and verify email cannot be active at the same + # time. + if all([config['web']['register']['autoLogin'], + config['web']['verifyEmail']['enabled']]): + raise ConfigurationError( + "Invalid configuration: stormpath.web.register.autoLogin " + "is true, but the default account store of the " + "specified application has the email verification " + "workflow enabled. Auto login is only possible if email " + "verification is disabled. " + "Please disable this workflow on this application's default " + "account store.") + + # Check if cookie information is present. + cookie = config.get('cookie') + + # Check cookie settings. + if not cookie or not isinstance(cookie, dict): + raise ConfigurationError('Cookie settings cannot be empty.') + + # Check cookie domain. + if cookie.get('domain') and not isinstance( + config['cookie']['domain'], str): + raise ConfigurationError('Cookie domain must be a string.') + + # Check cookie duration. + if cookie.get('duration') and not isinstance( + config['cookie']['duration'], timedelta): + raise ConfigurationError('Cookie duration must be a string.') + def process(self, config): if config.get('skipRemoteConfig'): return config @@ -179,4 +274,5 @@ def process(self, config): if policy_config: _extend_dict(config, policy_config) + self.validate(config) return config diff --git a/stormpath_config/strategies/move_apikey_to_client.py b/stormpath_config/strategies/move_apikey_to_client.py new file mode 100644 index 0000000..279da6b --- /dev/null +++ b/stormpath_config/strategies/move_apikey_to_client.py @@ -0,0 +1,21 @@ +class MoveAPIKeyToClientAPIKeyStrategy(object): + """ Represents a strategy that checks if our config has an outer key named + `apiKey'. If it does, we move it to client.apiKey. """ + def process(self, config=None): + if config is None: + config = {} + + apiKey = config.get('apiKey', {}) + if apiKey: + api_key_id = apiKey.get('id') + api_key_secret = apiKey.get('secret') + if not (api_key_id and api_key_secret): + raise Exception('Unable to load apiKey id and secret.') + + config.setdefault('client', {}) + config['client'].setdefault('apiKey', {}) + config['client']['apiKey']['id'] = api_key_id + config['client']['apiKey']['secret'] = api_key_secret + + config.pop('apiKey', {}) + return config diff --git a/stormpath_config/strategies/move_settings_to_config.py b/stormpath_config/strategies/move_settings_to_config.py new file mode 100644 index 0000000..fd0a05f --- /dev/null +++ b/stormpath_config/strategies/move_settings_to_config.py @@ -0,0 +1,75 @@ +from ..helpers import _extend_dict + + +class MoveSettingsToConfigStrategy(object): + """ + Checks the outer config and retrieves values whose keys start with + 'STORMPATH' prefix, and stores them in the configuration object properly. + """ + STORMPATH_PREFIX = 'STORMPATH' + KEY_DELIMITER = '*' + MAPPINGS = { + 'APPLICATION': 'application', + 'API_KEY_ID': 'client*apiKey*id', + 'API_KEY_SECRET': 'client*apiKey*secret', + 'API_KEY_FILE': 'client*apiKey*file', + 'ENABLE_FACEBOOK': 'web*social*facebook*enabled', + 'ENABLE_GOOGLE': 'web*social*google*enabled', + 'FACEBOOK_LOGIN_URL': 'web*social*facebook*login_url', + 'GOOGLE_LOGIN_URL': 'web*social*google*login_url', + 'CACHE': 'cache', + 'BASE_TEMPLATE': 'base_template', + 'COOKIE_DOMAIN': 'cookie*domain', + 'COOKIE_DURATION': 'cookie*duration', + } + + def __init__(self, config={}): + self.config = config + + def set_key(self, config, key, value): + """ + We use this method to properly map values into stormpath config object. + Some values are nested, in which case we create sub-dictionaries if + needed. + """ + subkeys = key.split(self.KEY_DELIMITER) + if len(subkeys) > 1: + attr = subkeys.pop(-1) + subdict = config + for key in subkeys: + subdict.setdefault(key, {}) + subdict = subdict[key] + subdict[attr] = value + else: + config[key] = value + + def get_updated_config(self, config): + """ + Creates a dictionary with new values whose keys are properly formated + to fit into stormpath config object. + """ + updated_config = {} + for key, value in config.items(): + if key.startswith(self.STORMPATH_PREFIX): + stormpath_key = self.MAPPINGS.get(key.split('STORMPATH_')[1]) + + # Check the format of application information. + if stormpath_key == 'application': + if 'http' in value: + stormpath_key = 'application*href' + else: + stormpath_key = 'application*name' + + if stormpath_key: + self.set_key(updated_config, stormpath_key, value) + + return updated_config + + def process(self, config=None): + if config is None: + config = {} + + updated_config = self.get_updated_config(self.config) + _extend_dict(config, updated_config) + + return config diff --git a/stormpath_config/strategies/validate_client_config.py b/stormpath_config/strategies/validate_client_config.py index 527694c..9edd6d6 100644 --- a/stormpath_config/strategies/validate_client_config.py +++ b/stormpath_config/strategies/validate_client_config.py @@ -21,14 +21,6 @@ def process(self, config=None): if not apiKey.get('id') or not apiKey.get('secret'): raise ValueError('API key ID and secret are required.') - application = config.get('application') - if not application: - raise ValueError('Application cannot be empty.') - - href = application.get('href') - if href and '/applications/' not in href: - raise ValueError('Application HREF "%s" is not a valid Stormpath Application HREF.' % href) - web_spa = config.get('web', {}).get('spa', {}) if web_spa and web_spa.get('enabled') and web_spa.get('view') is None: raise ValueError('SPA mode is enabled but stormpath.web.spa.view isn\'t ' diff --git a/tests/assets/secondary_apiKey.properties b/tests/assets/secondary_apiKey.properties new file mode 100644 index 0000000..d14424b --- /dev/null +++ b/tests/assets/secondary_apiKey.properties @@ -0,0 +1,2 @@ +apiKey.id = SECONDARY_API_KEY_PROPERTIES_ID +apiKey.secret = SECONDARY_API_KEY_PROPERTIES_SECRET diff --git a/tests/strategies/test_edge_cases.py b/tests/strategies/test_edge_cases.py index ed2cffe..0a77b0d 100644 --- a/tests/strategies/test_edge_cases.py +++ b/tests/strategies/test_edge_cases.py @@ -9,7 +9,8 @@ LoadAPIKeyFromConfigStrategy, \ LoadEnvConfigStrategy, \ LoadFileConfigStrategy, \ - ValidateClientConfigStrategy + ValidateClientConfigStrategy, \ + MoveAPIKeyToClientAPIKeyStrategy class EdgeCasesTest(TestCase): @@ -35,7 +36,8 @@ def test_config_extending(self): # constructor. ExtendConfigStrategy(extend_with=client_config) ] - post_processing_strategies = [LoadAPIKeyFromConfigStrategy()] + post_processing_strategies = [ + LoadAPIKeyFromConfigStrategy(), MoveAPIKeyToClientAPIKeyStrategy()] validation_strategies = [ValidateClientConfigStrategy()] cl = ConfigLoader(load_strategies, post_processing_strategies, validation_strategies) @@ -66,7 +68,8 @@ def test_api_key_file_from_config_with_lesser_loading_order(self): LoadEnvConfigStrategy(prefix='STORMPATH'), ExtendConfigStrategy(extend_with={}) ] - post_processing_strategies = [LoadAPIKeyFromConfigStrategy()] + post_processing_strategies = [ + LoadAPIKeyFromConfigStrategy(), MoveAPIKeyToClientAPIKeyStrategy()] validation_strategies = [ValidateClientConfigStrategy()] cl = ConfigLoader(load_strategies, post_processing_strategies, validation_strategies) @@ -101,7 +104,8 @@ def test_api_key_from_config_with_lesser_loading_order(self): # constructor. ExtendConfigStrategy(extend_with=client_config) ] - post_processing_strategies = [LoadAPIKeyFromConfigStrategy()] + post_processing_strategies = [ + LoadAPIKeyFromConfigStrategy(), MoveAPIKeyToClientAPIKeyStrategy()] validation_strategies = [ValidateClientConfigStrategy()] cl = ConfigLoader(load_strategies, post_processing_strategies, validation_strategies) diff --git a/tests/strategies/test_enrich_integration_from_remote_config.py b/tests/strategies/test_enrich_integration_from_remote_config.py index 597c5f9..aa412a7 100644 --- a/tests/strategies/test_enrich_integration_from_remote_config.py +++ b/tests/strategies/test_enrich_integration_from_remote_config.py @@ -1,26 +1,41 @@ from unittest import TestCase +from datetime import timedelta from stormpath_config.strategies import EnrichIntegrationFromRemoteConfigStrategy +from stormpath_config.errors import ConfigurationError from ..base import Application, Client class EnrichIntegrationFromRemoteConfigStrategyTest(TestCase): def setUp(self): - self.application = Application('My named application', 'https://api.stormpath.com/v1/applications/a') - - def test_enrich_client_from_remote_config(self): def _create_client_from_config(config): return Client([self.application]) - config = { + self.application = Application( + 'My named application', + 'https://api.stormpath.com/v1/applications/a') + self.config = { 'application': { 'href': 'https://api.stormpath.com/v1/applications/a' + }, + 'web': { + 'social': {'facebook': {'enabled': False}}, + 'register': { + 'enabled': True, + 'autoLogin': False + } + }, + 'cookie': { + 'domain': 'cookie_domain', + 'duration': timedelta(minutes=30) } } + self.ecfrcs = EnrichIntegrationFromRemoteConfigStrategy( + client_factory=_create_client_from_config) - ecfrcs = EnrichIntegrationFromRemoteConfigStrategy(client_factory=_create_client_from_config) - config = ecfrcs.process(config) + def test_enrich_client_from_remote_config(self): + config = self.ecfrcs.process(self.config) self.assertTrue('oAuthPolicy' in config['application']) self.assertEqual(config['application']['oAuthPolicy'], { @@ -40,6 +55,7 @@ def _create_client_from_config(config): 'maxLength': 100 }) self.assertEqual(config['web']['social'], { + 'facebook': {'enabled': False}, 'google': { 'providerId': 'google', 'clientId': 'id', @@ -52,6 +68,7 @@ def _create_client_from_config(config): }) self.assertEqual(config['web'], { 'social': { + 'facebook': {'enabled': False}, 'google': { 'providerId': 'google', 'clientId': 'id', @@ -65,4 +82,236 @@ def _create_client_from_config(config): 'changePassword': {'enabled': True}, 'forgotPassword': {'enabled': True}, 'verifyEmail': {'enabled': False}, + 'register': {'autoLogin': False, 'enabled': True} }) + + +class ValidateTest(EnrichIntegrationFromRemoteConfigStrategyTest): + """Ensure that our config passes final validation.""" + + def setUp(self): + super(ValidateTest, self).setUp() + self.config['web']['social'] = { + 'google': {'enabled': False}, + 'facebook': {'enabled': False} + } + self.config['web']['register']['autoLogin'] = False + self.config['web']['verifyEmail'] = {'enabled': False} + + def test_social_enabled_and_emtpy(self): + # Ensure that social_enabled_and empty returns proper boolean values. + + social_config = { + 'enabled': True, + 'clientId': 'xxx', + 'clientSecret': 'yyy' + } + + # Empty config. + self.assertFalse(self.ecfrcs.social_enabled_and_empty({})) + + # Invalid config value. + self.assertFalse(self.ecfrcs.social_enabled_and_empty(True)) + + # Social enabled with id and secret. + self.assertFalse(self.ecfrcs.social_enabled_and_empty(social_config)) + + # Social enabled but missing secret. + social_config.pop('clientSecret') + self.assertTrue(self.ecfrcs.social_enabled_and_empty(social_config)) + + def test_application(self): + # Ensure that validation fails if application settings are missing or + # invalid. + + # Invalid application href. + self.config['application']['href'] = 'https://api.stormpath.com/v1/a' + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), + 'Application HREF "https://api.stormpath.com/v1/a" is not a ' + + 'valid Stormpath Application HREF.') + + # No application name or href. + self.config['application'] = {} + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), 'Application cannot be empty.') + + # No application settings. + self.config.pop('application') + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), 'Application cannot be empty.') + + # Ensure that we can resolve application by name. + self.config['application'] = {'name': 'My named application'} + self.ecfrcs.validate(self.config) + + def test_google_settings(self): + # Ensure that validation fails if google config is invalid. + + # Turn off facebook social. + self.config['web']['social'] = {'facebook': {'enabled': False}} + + # Empty google settings. + self.config['web']['social']['google'] = {} + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), + 'You must define your Google app settings.' + ) + + # Enabled google settings, but otherwise empty. + self.config['web']['social']['google']['enabled'] = True + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), + 'You must define your Google app settings.' + ) + + # Enabled and clientId provided, but secret missing. + self.config['web']['social']['google']['clientId'] = 'xxx' + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), + 'You must define your Google app settings.' + ) + + # Now that we've configured things properly, it should work. + self.config['web']['social']['google']['clientSecret'] = 'yyy' + self.ecfrcs.validate(self.config) + + def test_facebook_settings(self): + # Ensure that validation fails if facebook config is invalid. + + # Turn off google social. + self.config['web']['social'] = {'google': {'enabled': False}} + + # No facebook settings. + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), + 'You must define your Facebook app settings.' + ) + + # Empty facebook settings. + self.config['web']['social']['facebook'] = {} + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), + 'You must define your Facebook app settings.' + ) + + # Enabled facebook settings, but otherwise empty. + self.config['web']['social']['facebook']['enabled'] = True + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), + 'You must define your Facebook app settings.' + ) + + # Enabled and clientId provided, but secret missing. + self.config['web']['social']['facebook']['clientId'] = 'xxx' + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), + 'You must define your Facebook app settings.' + ) + + # Now that we've configured things properly, it should work. + self.config['web']['social']['facebook']['clientSecret'] = 'yyy' + self.ecfrcs.validate(self.config) + + def test_cookie_settings(self): + # Ensure that validation fails if cookie settings are invalid. + + # Missing cookie settings. + self.config.pop('cookie') + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), 'Cookie settings cannot be empty.') + + # Empty cookie settings. + self.config['cookie'] = {} + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), 'Cookie settings cannot be empty.') + + # Invalid cookie domain. + self.config['cookie'] = {'domain': 55} + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), 'Cookie domain must be a string.') + + # Invalid cookie duration. + self.config['cookie'] = { + 'domain': '55', + 'duration': 55 + } + + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), 'Cookie duration must be a string.') + + # Now that we've configured things properly, it should work. + self.config['cookie'] = { + 'domain': 'cookie_domain', + 'duration': timedelta(minutes=1) + } + self.ecfrcs.validate(self.config) + + def test_verify_email_autologin(self): + # Ensure that validation fails if both autologin and email verification + # are enabled. + + # Turn on verify email and autologin. + self.config['web']['register']['autoLogin'] = True + self.config['web']['verifyEmail']['enabled'] = True + + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), + 'Invalid configuration: stormpath.web.register.autoLogin is' + + ' true, but the default account store of the specified' + + ' application has the email verification workflow enabled.' + + ' Auto login is only possible if email verification is' + + ' disabled. Please disable this workflow on this' + + ' application\'s default account store.') + + # Turn off one of the settings, and configuration should be valid. + self.config['web']['register']['autoLogin'] = False + self.ecfrcs.validate(self.config) + + def test_register_default_account_store(self): + # Ensure that validation fails if register view is enabled, but default + # account store mapping is missing. + + self.application.default_account_store_mapping = False + with self.assertRaises(ConfigurationError) as error: + self.ecfrcs.validate(self.config) + self.assertEqual( + str(error.exception), + 'No default account store is mapped to the specified ' + + 'application. A default account store is required for ' + + 'registration.' + ) + + # Now that we've configured things properly, it should work. + self.application.default_account_store_mapping = object() + self.ecfrcs.validate(self.config) diff --git a/tests/strategies/test_load_apikey_from_config.py b/tests/strategies/test_load_apikey_from_config.py new file mode 100644 index 0000000..d9dcba7 --- /dev/null +++ b/tests/strategies/test_load_apikey_from_config.py @@ -0,0 +1,42 @@ +from unittest import TestCase +from stormpath_config.loader import ConfigLoader +from stormpath_config.strategies import ( + LoadAPIKeyConfigStrategy, + LoadFileConfigStrategy, + LoadEnvConfigStrategy, + ExtendConfigStrategy, + LoadAPIKeyFromConfigStrategy, + ValidateClientConfigStrategy, + MoveAPIKeyToClientAPIKeyStrategy) + + +class LoadAPIKeyFromConfigStrategyTest(TestCase): + def test_api_key_from_config_with_lesser_loading_order(self): + """ Ensure that api key and secret are properly loaded from file. """ + + load_strategies = [ + # 1. We load the default configuration. + LoadFileConfigStrategy( + 'tests/assets/default_config.yml', must_exist=True), + LoadAPIKeyConfigStrategy('i-do-not-exist'), + # 3. We load apiKeyFile.yml file with apiKey.properties file. + LoadFileConfigStrategy('tests/assets/apiKeyFile.yml'), + LoadAPIKeyConfigStrategy('i-do-not-exist'), + LoadFileConfigStrategy('i-do-not-exist'), + LoadEnvConfigStrategy(prefix='STORMPATH'), + # 7. Configuration provided through the SDK client constructor. + ExtendConfigStrategy(extend_with={}) + ] + post_processing_strategies = [ + LoadAPIKeyFromConfigStrategy(), MoveAPIKeyToClientAPIKeyStrategy()] + validation_strategies = [ValidateClientConfigStrategy()] + + cl = ConfigLoader( + load_strategies, post_processing_strategies, validation_strategies) + config = cl.load() + + self.assertEqual( + config['client']['apiKey']['id'], 'API_KEY_PROPERTIES_ID') + self.assertEqual( + config['client']['apiKey']['secret'], 'API_KEY_PROPERTIES_SECRET') + self.assertFalse('file' in config['client']['apiKey']) diff --git a/tests/strategies/test_move_api_key_to_client.py b/tests/strategies/test_move_api_key_to_client.py new file mode 100644 index 0000000..f0dd557 --- /dev/null +++ b/tests/strategies/test_move_api_key_to_client.py @@ -0,0 +1,57 @@ +from unittest import TestCase +from stormpath_config.loader import ConfigLoader +from stormpath_config.strategies import ( + LoadAPIKeyConfigStrategy, + LoadFileConfigStrategy, + LoadEnvConfigStrategy, + ExtendConfigStrategy, + LoadAPIKeyFromConfigStrategy, + ValidateClientConfigStrategy, + MoveAPIKeyToClientAPIKeyStrategy) + + +class MoveAPIKeyToClientAPIKeyStrategyTest(TestCase): + def generateConfig(self, client_config={}): + load_strategies = [ + # 1. We load the default configuration. + LoadFileConfigStrategy( + 'tests/assets/default_config.yml', must_exist=True), + LoadAPIKeyConfigStrategy('i-do-not-exist'), + # 3. We load apiKeyApiKey.json file with apiKey id and secret. + LoadFileConfigStrategy('tests/assets/apiKeyApiKey.json'), + LoadAPIKeyConfigStrategy('i-do-not-exist'), + LoadFileConfigStrategy('i-do-not-exist'), + LoadEnvConfigStrategy(prefix='STORMPATH'), + # 7. Configuration provided through the SDK client constructor. + ExtendConfigStrategy(extend_with=client_config) + ] + post_processing_strategies = [ + LoadAPIKeyFromConfigStrategy(), MoveAPIKeyToClientAPIKeyStrategy()] + validation_strategies = [ValidateClientConfigStrategy()] + + cl = ConfigLoader( + load_strategies, post_processing_strategies, validation_strategies) + return cl.load() + + def test_move_api_key_to_client(self): + """ Ensure that apiKey key is moved to client if set in config outer + keys. """ + config = self.generateConfig() + + # Ensure that id and secret from outer apiKey key have beed loaded to + # client key. + self.assertEqual( + config['client']['apiKey']['id'], 'MY_JSON_CONFIG_API_KEY_ID') + self.assertEqual( + config['client']['apiKey']['secret'], + 'MY_JSON_CONFIG_API_KEY_SECRET') + self.assertFalse('apiKey' in config.keys()) + + def test_move_api_key_to_client_missing_credentials(self): + """ Ensure that missing id or secret will raise an Exception. """ + client_config = {'apiKey': {'foo': 'bar'}} + + with self.assertRaises(Exception) as error: + self.generateConfig(client_config=client_config) + self.assertEqual( + str(error.exception), 'Unable to load apiKey id and secret.') diff --git a/tests/strategies/test_move_settings_to_config.py b/tests/strategies/test_move_settings_to_config.py new file mode 100644 index 0000000..72d1b90 --- /dev/null +++ b/tests/strategies/test_move_settings_to_config.py @@ -0,0 +1,136 @@ +from unittest import TestCase +from stormpath_config.strategies import MoveSettingsToConfigStrategy + + +class MoveSettingsToConfigStrategyTest(TestCase): + def setUp(self): + self.stormpath_config = { + 'client': { + 'apiKey': {'id': 'api key id', 'secret': 'api key secret'}, + 'cacheManager': {'defaultTtl': 300, 'defaultTti': 300} + }, + 'web': { + 'social': { + 'facebook': { + 'scope': 'email', + 'uri': '/callbacks/facebook'}, + 'google': { + 'scope': 'email profile', + 'uri': '/callbacks/google'} + } + } + } + self.config = {'stormpath': self.stormpath_config} + + def test_regular_mapping(self): + """ + Ensures that settings with 'STORMPATH' prefix are properly + copied to stormpath config object. + """ + self.config['STORMPATH_BASE_TEMPLATE'] = 'flask_stormpath/base.html' + + move_stormpath_settings = MoveSettingsToConfigStrategy( + config=self.config) + move_stormpath_settings.process(self.config['stormpath']) + + self.assertEqual( + self.config['stormpath']['base_template'], + 'flask_stormpath/base.html') + + def test_multiple_key_mapping(self): + """ + Ensures that multiple key settings with 'STORMPATH' prefix are + properly copied to stormpath config object. + """ + self.config['STORMPATH_ENABLE_FACEBOOK'] = False + + move_stormpath_settings = MoveSettingsToConfigStrategy( + config=self.config) + move_stormpath_settings.process(self.config['stormpath']) + + self.assertEqual( + self.config['stormpath']['web']['social']['facebook']['enabled'], + False) + + # Ensure that other values form social_facebook are unaltered. + self.assertEqual( + self.config['stormpath']['web']['social']['facebook']['scope'], + 'email') + self.assertEqual( + self.config['stormpath']['web']['social']['facebook']['uri'], + '/callbacks/facebook') + + def test_empty_key_mapping(self): + """ + Ensures that settings with 'STORMPATH' prefix not specified in + MAPPINGS are ignored. + """ + self.config['STORMPATH_FOO'] = 'bar' + + move_stormpath_settings = MoveSettingsToConfigStrategy( + config=self.config) + move_stormpath_settings.process(self.config['stormpath']) + + self.assertNotIn('foo', self.config['stormpath']) + + def test_non_stormpath_key_mapping(self): + """ + Ensures that settings without 'STORMPATH' prefix are skipped. + """ + self.config['FOO'] = 'bar' + + move_stormpath_settings = MoveSettingsToConfigStrategy( + config=self.config) + move_stormpath_settings.process(self.config['stormpath']) + + self.assertNotIn('foo', self.config['stormpath']) + + def test_default_values(self): + """ + Ensures that creating new values will override old values. + """ + self.config['stormpath']['base_template'] = ( + 'flask_stormpath/default_base.html') + self.config['stormpath']['web']['social']['facebook']['enabled'] = True + + self.config['STORMPATH_BASE_TEMPLATE'] = 'flask_stormpath/base.html' + self.config['STORMPATH_ENABLE_FACEBOOK'] = False + + move_stormpath_settings = MoveSettingsToConfigStrategy( + config=self.config) + move_stormpath_settings.process(self.config['stormpath']) + + self.assertEqual( + self.config['stormpath']['base_template'], + 'flask_stormpath/base.html') + self.assertEqual( + self.config['stormpath']['web']['social']['facebook']['enabled'], + False) + + def test_parsing_application_name_href(self): + """ + Ensure that our strategy can properly differentiate between name and + href stored in STORMPATH_APPLICATION. + """ + + # Ensure that application name is stored as name. + self.config['STORMPATH_APPLICATION'] = 'app_name' + + move_stormpath_settings = MoveSettingsToConfigStrategy( + config=self.config) + move_stormpath_settings.process(self.config['stormpath']) + + self.assertEqual( + self.config['stormpath']['application']['name'], 'app_name') + + # Ensure that application uri is stored as href. + self.config['STORMPATH_APPLICATION'] = ( + 'https://api.stormpath.com/v1/applications/foobar') + + move_stormpath_settings = MoveSettingsToConfigStrategy( + config=self.config) + move_stormpath_settings.process(self.config['stormpath']) + + self.assertEqual( + self.config['stormpath']['application']['href'], + 'https://api.stormpath.com/v1/applications/foobar') diff --git a/tests/test_loader.py b/tests/test_loader.py index 73a5888..733a342 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -3,21 +3,23 @@ from os import environ from unittest import TestCase - from mock import patch - from stormpath_config.loader import ConfigLoader -from stormpath_config.strategies import ExtendConfigStrategy, \ - LoadAPIKeyConfigStrategy, \ - LoadAPIKeyFromConfigStrategy, \ - LoadEnvConfigStrategy, \ - LoadFileConfigStrategy, \ - ValidateClientConfigStrategy +from stormpath_config.strategies import ( + ExtendConfigStrategy, + LoadAPIKeyConfigStrategy, + LoadAPIKeyFromConfigStrategy, + LoadEnvConfigStrategy, + LoadFileConfigStrategy, + ValidateClientConfigStrategy, + MoveAPIKeyToClientAPIKeyStrategy, + MoveSettingsToConfigStrategy +) class ConfigLoaderTest(TestCase): def setUp(self): - client_config = { + self.client_config = { 'application': { 'name': 'CLIENT_CONFIG_APP', 'href': None @@ -32,7 +34,8 @@ def setUp(self): self.load_strategies = [ # 1. Default configuration. - LoadFileConfigStrategy('tests/assets/default_config.yml', must_exist=True), + LoadFileConfigStrategy( + 'tests/assets/default_config.yml', must_exist=True), # 2. apiKey.properties file from ~/.stormpath directory. LoadAPIKeyConfigStrategy('tests/assets/apiKey.properties'), @@ -53,13 +56,14 @@ def setUp(self): # 7. Configuration provided through the SDK client # constructor. - ExtendConfigStrategy(extend_with=client_config) + ExtendConfigStrategy(extend_with=self.client_config) ] self.post_processing_strategies = [ # Post-processing: If the key client.apiKey.file isn't # empty, then a apiKey.properties file should be loaded # from that path. LoadAPIKeyFromConfigStrategy(), + MoveAPIKeyToClientAPIKeyStrategy() ] self.validation_strategies = [ # Post-processing: Validation @@ -77,11 +81,300 @@ def test_empty_config_loader(self): 'STORMPATH_APPLICATION_NAME': 'My app', }) def test_config_loader(self): - cl = ConfigLoader(self.load_strategies, self.post_processing_strategies, self.validation_strategies) + cl = ConfigLoader( + self.load_strategies, + self.post_processing_strategies, + self.validation_strategies + ) config = cl.load() - self.assertEqual(config['client']['apiKey']['id'], 'CLIENT_CONFIG_API_KEY_ID') - self.assertEqual(config['client']['apiKey']['secret'], 'CLIENT_CONFIG_API_KEY_SECRET') - self.assertEqual(config['client']['cacheManager']['defaultTtl'], 302) - self.assertEqual(config['client']['cacheManager']['defaultTti'], 303) + self.assertEqual( + config['client']['apiKey']['id'], 'CLIENT_CONFIG_API_KEY_ID') + self.assertEqual( + config['client']['apiKey']['secret'], + 'CLIENT_CONFIG_API_KEY_SECRET') + self.assertEqual( + config['client']['cacheManager']['defaultTtl'], 302) + self.assertEqual( + config['client']['cacheManager']['defaultTti'], 303) + self.assertEqual(config['application']['name'], 'CLIENT_CONFIG_APP') + + +class OverridingStrategiesTest(TestCase): + """ + This testing class will simulate the loading process for stormpath-flask. + """ + def setUp(self): + self.post_processing_strategies = [ + LoadAPIKeyFromConfigStrategy(), + MoveAPIKeyToClientAPIKeyStrategy() + ] + self.validation_strategies = [ + ValidateClientConfigStrategy() + ] + + def setLoadingStrategies(self, assets={}): + # Our custom strategy loader builder. + + load_strategies = [ + # 1. Default configuration. + LoadFileConfigStrategy( + assets.get('default_config', 'empty'), must_exist=True), + + # 2. apiKey.properties file from ~/.stormpath directory. + LoadAPIKeyConfigStrategy(assets.get('home_apiKey', 'empty')), + + # 3. stormpath.json file from ~/.stormpath directory. + LoadFileConfigStrategy(assets.get('home_stormpath_json', 'empty')), + + # 3.1. stormpath.yaml file from ~/.stormpath directory. + LoadFileConfigStrategy(assets.get('home_stormpath_yaml', 'empty')), + + # 4. apiKey.properties file from application directory. + LoadAPIKeyConfigStrategy(assets.get('app_apiKey', 'empty')), + + # 5. stormpath.json file from application directory. + LoadFileConfigStrategy(assets.get('app_stormpath_json', 'empty')), + + # 5.1. stormpath.yaml file from application directory. + LoadFileConfigStrategy(assets.get('app_stormpath_yaml', 'empty')), + + # 6. Environment variables. + LoadEnvConfigStrategy(prefix=assets.get('env_prefix', 'empty')), + + # 7. Configuration provided through the SDK client constructor. + ExtendConfigStrategy(extend_with=assets.get('client_config', {})), + + # 8. Configuration provided 'STORMPATH' prefix in outer config. + MoveSettingsToConfigStrategy(config=assets.get('outer_config', {})) + ] + return load_strategies + + def getConfig(self): + # Returns the final config. + + cl = ConfigLoader( + self.load_strategies, + self.post_processing_strategies, + self.validation_strategies + ) + return cl.load() + + def test_strategies_override_01(self): + # Ensure that original config file is loaded. + + # Enable only the first asset. + self.load_strategies = self.setLoadingStrategies({ + 'default_config': 'tests/assets/default_config.yml' + }) + + # Ensure that config file was loaded and an error was raised, since + # our testing asset does not have apiKey credentials. + with self.assertRaises(ValueError) as error: + self.getConfig() + self.assertEqual( + str(error.exception), 'API key ID and secret are required.') + + def test_strategies_override_02(self): + # Ensure that apiKey.properties file from HOME directory will override + # any settings from previous config sources. + + # Enable first two assets. + self.load_strategies = self.setLoadingStrategies({ + 'default_config': 'tests/assets/default_config.yml', + 'home_apiKey': 'tests/assets/apiKey.properties' + }) + config = self.getConfig() + + # Ensure that default config file was properly loaded. + self.assertEqual( + config['client']['baseUrl'], 'https://api.stormpath.com/v1') + self.assertIsNone(config['application']['name']) + + # Ensure that apiKey.properties overwrote previous api key id and + # secret. + self.assertEqual( + config['client']['apiKey']['id'], 'API_KEY_PROPERTIES_ID') + self.assertEqual( + config['client']['apiKey']['secret'], 'API_KEY_PROPERTIES_SECRET') + + def test_strategies_override_03(self): + # Ensure that stormpath.json file from HOME stormpath directory will + # override any settings from previous config sources. + + # Enable first three assets. + self.load_strategies = self.setLoadingStrategies({ + 'default_config': 'tests/assets/default_config.yml', + 'home_apiKey': 'tests/assets/apiKey.properties', + 'home_stormpath_json': 'tests/assets/apiKeyApiKey.json' + }) + config = self.getConfig() + + # Ensure that json asset overwrote previous api key id and secret. + self.assertEqual( + config['client']['apiKey']['id'], 'MY_JSON_CONFIG_API_KEY_ID') + self.assertEqual( + config['client']['apiKey']['secret'], + 'MY_JSON_CONFIG_API_KEY_SECRET') + self.assertIsNone(config['client']['apiKey']['file']) + + def test_strategies_override_04(self): + # Ensure that stormpath.yaml file from HOME stormpath directory will + # override any settings from previous config sources. + + # Enable first four assets. + self.load_strategies = self.setLoadingStrategies({ + 'default_config': 'tests/assets/default_config.yml', + 'home_apiKey': 'tests/assets/apiKey.properties', + 'home_stormpath_json': 'tests/assets/apiKeyApiKey.json', + 'home_stormpath_yaml': 'tests/assets/apiKeyFile.yml', + }) + config = self.getConfig() + + # Ensure that yaml asset overwrote previous api key id and secret. + self.assertEqual( + config['client']['apiKey']['id'], 'API_KEY_PROPERTIES_ID') + self.assertEqual( + config['client']['apiKey']['secret'], 'API_KEY_PROPERTIES_SECRET') + self.assertFalse('file' in config['client']['apiKey']) + + def test_strategies_override_05(self): + # Ensure that apiKey.properties file from app directory will override + # any settings from previous config sources. + + # Enable first five assets. + self.load_strategies = self.setLoadingStrategies({ + 'default_config': 'tests/assets/default_config.yml', + 'home_apiKey': 'tests/assets/apiKey.properties', + 'home_stormpath_json': 'tests/assets/apiKeyApiKey.json', + 'home_stormpath_yaml': 'tests/assets/apiKeyFile.yml', + 'app_apiKey': 'tests/assets/secondary_apiKey.properties', + }) + config = self.getConfig() + + # Ensure that apiKey.properties asset overwrote previous api key id + # and secret. + self.assertEqual( + config['client']['apiKey']['id'], + 'SECONDARY_API_KEY_PROPERTIES_ID') + self.assertEqual( + config['client']['apiKey']['secret'], + 'SECONDARY_API_KEY_PROPERTIES_SECRET') + + def test_strategies_override_06(self): + # Ensure that stormpath.json file will override any settings from + # previous config sources. + + # Enable first six assets. + self.load_strategies = self.setLoadingStrategies({ + 'default_config': 'tests/assets/default_config.yml', + 'home_apiKey': 'tests/assets/apiKey.properties', + 'home_stormpath_json': 'tests/assets/apiKeyApiKey.json', + 'home_stormpath_yaml': 'tests/assets/apiKeyFile.yml', + 'app_apiKey': 'tests/assets/secondary_apiKey.properties', + 'app_stormpath_json': 'tests/assets/stormpath.json' + }) + config = self.getConfig() + + # Ensure that stormpath.json asset overwrote previous settings. + self.assertEqual( + config['client']['baseUrl'], 'https://api.stormpath.com/v3') + self.assertEqual(config['application']['name'], 'MY_JSON_APP') + + def test_strategies_override_07(self): + # Ensure that stormpath.yaml file will override any settings from + # previous config sources. + + # Enable first seven assets. + self.load_strategies = self.setLoadingStrategies({ + 'default_config': 'tests/assets/default_config.yml', + 'home_apiKey': 'tests/assets/apiKey.properties', + 'home_stormpath_json': 'tests/assets/apiKeyApiKey.json', + 'home_stormpath_yaml': 'tests/assets/apiKeyFile.yml', + 'app_apiKey': 'tests/assets/secondary_apiKey.properties', + 'app_stormpath_json': 'tests/assets/stormpath.json', + 'app_stormpath_yaml': 'tests/assets/stormpath.yml', + }) + config = self.getConfig() + + # Ensure that stormpath.yaml asset overwrote previous settings. + self.assertEqual( + config['client']['baseUrl'], 'https://api.stormpath.com/v2') + self.assertEqual(config['application']['name'], 'MY_APP') + + def test_strategies_override_08(self): + # Ensure that stormpath environment variables will override any + # settings from previous config sources. + + # Enable first eight assets. + self.load_strategies = self.setLoadingStrategies({ + 'default_config': 'tests/assets/default_config.yml', + 'home_apiKey': 'tests/assets/apiKey.properties', + 'home_stormpath_json': 'tests/assets/apiKeyApiKey.json', + 'home_stormpath_yaml': 'tests/assets/apiKeyFile.yml', + 'app_apiKey': 'tests/assets/secondary_apiKey.properties', + 'app_stormpath_json': 'tests/assets/stormpath.json', + 'app_stormpath_yaml': 'tests/assets/stormpath.yml', + 'env_prefix': 'STORMPATH' + }) + environ['STORMPATH_APPLICATION_NAME'] = 'MY_ENVIRON_APP' + config = self.getConfig() + + # Ensure that stormpath environment variables overwrote previous + # settings. + self.assertEqual(config['application']['name'], 'MY_ENVIRON_APP') + + def test_strategies_override_09(self): + # Ensure that client constructor settings will override any settings + # from previous config sources. + + # Enable all assets. + self.load_strategies = self.setLoadingStrategies({ + 'default_config': 'tests/assets/default_config.yml', + 'home_apiKey': 'tests/assets/apiKey.properties', + 'home_stormpath_json': 'tests/assets/apiKeyApiKey.json', + 'home_stormpath_yaml': 'tests/assets/apiKeyFile.yml', + 'app_apiKey': 'tests/assets/secondary_apiKey.properties', + 'app_stormpath_json': 'tests/assets/stormpath.json', + 'app_stormpath_yaml': 'tests/assets/stormpath.yml', + 'env_prefix': 'STORMPATH', + 'client_config': { + 'application': { + 'name': 'CLIENT_CONFIG_APP' + } + } + }) + config = self.getConfig() + + # Ensure that client config asset overwrote previous settings. self.assertEqual(config['application']['name'], 'CLIENT_CONFIG_APP') + + def test_strategies_override_10(self): + # Ensure that settings from outer config with 'STORMPATH' prefix will + # override any settings from previous config sources. + + # Enable all assets. + self.load_strategies = self.setLoadingStrategies({ + 'default_config': 'tests/assets/default_config.yml', + 'home_apiKey': 'tests/assets/apiKey.properties', + 'home_stormpath_json': 'tests/assets/apiKeyApiKey.json', + 'home_stormpath_yaml': 'tests/assets/apiKeyFile.yml', + 'app_apiKey': 'tests/assets/secondary_apiKey.properties', + 'app_stormpath_json': 'tests/assets/stormpath.json', + 'app_stormpath_yaml': 'tests/assets/stormpath.yml', + 'env_prefix': 'STORMPATH', + 'client_config': { + 'application': { + 'name': 'CLIENT_CONFIG_APP' + } + }, + 'outer_config': { + 'STORMPATH_BASE_TEMPLATE': 'stormpath_base_template', + 'STORMPATH_APPLICATION': 'OUTER_STORMPATH_APP' + } + }) + config = self.getConfig() + + # Ensure that outer config asset overwrote previous settings. + self.assertEqual(config['base_template'], 'stormpath_base_template') + self.assertEqual(config['application']['name'], 'OUTER_STORMPATH_APP')