diff --git a/python/fledge/common/configuration_manager.py b/python/fledge/common/configuration_manager.py index 034cf429bc..31d47c5259 100644 --- a/python/fledge/common/configuration_manager.py +++ b/python/fledge/common/configuration_manager.py @@ -16,6 +16,7 @@ import collections import ast +import aiohttp.web_request from fledge.common.storage_client.payload_builder import PayloadBuilder from fledge.common.storage_client.storage_client import StorageClientAsync from fledge.common.storage_client.exceptions import StorageServerError @@ -37,7 +38,7 @@ 'JSON', 'URL', 'enumeration', 'script', 'code', 'northTask', 'ACL', 'bucket', 'list', 'kvlist']) _optional_items = sorted(['readonly', 'order', 'length', 'maximum', 'minimum', 'rule', 'deprecated', 'displayName', - 'validity', 'mandatory', 'group', 'listSize', 'listName']) + 'validity', 'mandatory', 'group', 'listSize', 'listName', 'permissions']) RESERVED_CATG = ['South', 'North', 'General', 'Advanced', 'Utilities', 'rest_api', 'Security', 'service', 'SCHEDULER', 'SMNTR', 'PURGE_READ', 'Notifications'] @@ -266,7 +267,7 @@ async def _validate_category_val(self, category_name, category_val, set_value_va optional_item_entries = {'readonly': 0, 'order': 0, 'length': 0, 'maximum': 0, 'minimum': 0, 'deprecated': 0, 'displayName': 0, 'rule': 0, 'validity': 0, 'mandatory': 0, - 'group': 0, 'listSize': 0, 'listName': 0} + 'group': 0, 'listSize': 0, 'listName': 0, 'permissions': 0} expected_item_entries = {'description': 0, 'default': 0, 'type': 0} if require_entry_value: @@ -301,6 +302,19 @@ def get_entry_val(k): else: d = {entry_name: entry_val} expected_item_entries.update(d) + elif entry_name == "permissions": + if not isinstance(entry_val, list): + raise ValueError( + 'For {} category, {} entry value must be a list of string for item name {}; got {}.' + ''.format(category_name, entry_name, item_name, type(entry_val))) + if not entry_val: + raise ValueError( + 'For {} category, {} entry value must not be empty for item name ' + '{}.'.format(category_name, entry_name, item_name)) + else: + if not all(isinstance(ev, str) and ev != '' for ev in entry_val): + raise ValueError('For {} category, {} entry values must be a string and non-empty ' + 'for item name {}.'.format(category_name, entry_name, item_name)) else: if type(entry_val) is not str: raise TypeError('For {} category, entry value must be a string for item name {} and ' @@ -331,7 +345,7 @@ def get_entry_val(k): type(entry_val))) # Validate list type and mandatory items elif 'type' in item_val and get_entry_val("type") in ('list', 'kvlist'): - if entry_name not in ('properties', 'options') and not isinstance(entry_val, str): + if entry_name not in ('properties', 'options', 'permissions') and not isinstance(entry_val, str): raise TypeError('For {} category, entry value must be a string for item name {} and ' 'entry name {}; got {}'.format(category_name, item_name, entry_name, type(entry_val))) @@ -348,6 +362,20 @@ def get_entry_val(k): raise ValueError('For {} category, listName cannot be empty for item name ' '{}'.format(category_name, item_name)) item_val['listName'] = list_name + elif "permissions" in item_val: + permissions = item_val['permissions'] + if not isinstance(permissions, list): + raise ValueError( + 'For {} category, permissions entry value must be a list of string for item name {}; ' + 'got {}.'.format(category_name, item_name, type(permissions))) + if not permissions: + raise ValueError( + 'For {} category, permissions entry value must not be empty for item name {}.'.format( + category_name, item_name)) + else: + if not all(isinstance(ev, str) and ev != '' for ev in permissions): + raise ValueError('For {} category, permissions entry values must be a string and ' + 'non-empty for item name {}.'.format(category_name, item_name)) if entry_name == 'items': if entry_val not in ("string", "float", "integer", "object", "enumeration"): raise ValueError("For {} category, items value should either be in string, float, " @@ -487,6 +515,19 @@ def get_entry_val(k): if entry_name in ('properties', 'options'): d = {entry_name: entry_val} expected_item_entries.update(d) + elif entry_name == "permissions": + if not isinstance(entry_val, list): + raise ValueError( + 'For {} category, {} entry value must be a list of string for item name {}; got {}.' + ''.format(category_name, entry_name, item_name, type(entry_val))) + if not entry_val: + raise ValueError( + 'For {} category, {} entry value must not be empty for item name ' + '{}.'.format(category_name, entry_name, item_name)) + else: + if not all(isinstance(ev, str) and ev != '' for ev in entry_val): + raise ValueError('For {} category, {} entry values must be a string and non-empty ' + 'for item name {}.'.format(category_name, entry_name, item_name)) else: if type(entry_val) is not str: raise TypeError('For {} category, entry value must be a string for item name {} and ' @@ -510,6 +551,19 @@ def get_entry_val(k): self._validate_type_value('float', entry_val)) is False: raise ValueError('For {} category, entry value must be an integer or float for item name ' '{}; got {}'.format(category_name, entry_name, type(entry_val))) + elif entry_name == "permissions": + if not isinstance(entry_val, list): + raise ValueError( + 'For {} category, {} entry value must be a list of string for item name {}; got {}.' + ''.format(category_name, entry_name, item_name, type(entry_val))) + if not entry_val: + raise ValueError( + 'For {} category, {} entry value must not be empty for item name ' + '{}.'.format(category_name, entry_name, item_name)) + else: + if not all(isinstance(ev, str) and ev != '' for ev in entry_val): + raise ValueError('For {} category, {} entry values must be a string and non-empty ' + 'for item name {}.'.format(category_name, entry_name, item_name)) elif entry_name in ('displayName', 'group', 'rule', 'validity', 'listName'): if not isinstance(entry_val, str): raise ValueError('For {} category, entry value must be string for item name {}; got {}' @@ -751,26 +805,30 @@ async def _update_value_val(self, category_name, item_name, new_value_val): err_response = ex.error raise ValueError(err_response) - async def update_configuration_item_bulk(self, category_name, config_item_list): + async def update_configuration_item_bulk(self, category_name, config_item_list, request=None): """ Bulk update config items Args: category_name: category name config_item_list: dict containing config item values + request: request details to identify user info Returns: None """ - try: payload = {"updates": []} audit_details = {'category': category_name, 'items': {}} cat_info = await self.get_category_all_items(category_name) if cat_info is None: raise NameError("No such Category found for {}".format(category_name)) + """ Note: Update reject to the properties with permissions property when the logged in user type is not + given in the list of permissions. """ + user_role_name = await self._check_updates_by_role(request) for item_name, new_val in config_item_list.items(): if item_name not in cat_info: raise KeyError('{} config item not found'.format(item_name)) + self._check_permissions(request, cat_info[item_name], user_role_name) # Evaluate new_val as per rule if defined if 'rule' in cat_info[item_name]: rule = cat_info[item_name]['rule'].replace("value", new_val) @@ -874,7 +932,8 @@ async def update_configuration_item_bulk(self, category_name, config_item_list): await audit.information('CONCH', audit_details) except Exception as ex: - _logger.exception(ex, 'Unable to bulk update config items') + if 'Forbidden' not in str(ex): + _logger.exception(ex, 'Unable to bulk update config items') raise try: @@ -1035,7 +1094,8 @@ async def get_category_item_value_entry(self, category_name, item_name): item_name) raise - async def set_category_item_value_entry(self, category_name, item_name, new_value_entry, script_file_path=""): + async def set_category_item_value_entry(self, category_name, item_name, new_value_entry, script_file_path="", + request=None): """Set the "value" entry of a given item within a given category. Keyword Arguments: @@ -1043,6 +1103,7 @@ async def set_category_item_value_entry(self, category_name, item_name, new_valu item_name -- name of item within the category whose "value" entry needs to be changed (required) new_value_entry -- new value entry to replace old value entry script_file_path -- Script file path for the config item whose type is script + request -- request details to identify user info Side Effects: An update to storage will not be issued if a new_value_entry is the same as the new_value_entry from storage. @@ -1075,7 +1136,11 @@ async def set_category_item_value_entry(self, category_name, item_name, new_valu .format(category_name, item_name)) if storage_value_entry == new_value_entry: return - + """ Note: Update reject to the properties with permissions property when the logged in user type is not + given in the list of permissions. """ + user_role_name = await self._check_updates_by_role(request) + if user_role_name: + self._check_permissions(request, storage_value_entry, user_role_name) # Special case for enumeration field type handling if storage_value_entry['type'] == 'enumeration': if new_value_entry == '': @@ -1122,10 +1187,11 @@ async def set_category_item_value_entry(self, category_name, item_name, new_valu self._cacheManager.cache[category_name]['value'][item_name]["file"] = script_file_path else: self._cacheManager.cache[category_name]['value'].update({item_name: cat_item['value']}) - except: - _logger.exception( - 'Unable to set item value entry based on category_name %s and item_name %s and value_item_entry %s', - category_name, item_name, new_value_entry) + except Exception as ex: + if 'Forbidden' not in str(ex): + _logger.exception( + 'Unable to set item value entry based on category_name %s and item_name %s and value_item_entry %s', + category_name, item_name, new_value_entry) raise try: await self._run_callbacks(category_name) @@ -2001,3 +2067,26 @@ def _handle_config_items(self, cat_name: str, cat_value: dict) -> None: if 'cacheSize' in cat_value: self._cacheManager.max_cache_size = int(cat_value['cacheSize']['value']) + async def _check_updates_by_role(self, request: aiohttp.web_request.Request) -> str: + async def get_role_name(): + from fledge.services.core.user_model import User + name = await User.Objects.get_role_name_by_id(request.user['role_id']) + if name is None: + raise ValueError("Requesting user's role is not matched with any existing roles.") + return name + + role_name = "" + if request is not None: + if hasattr(request, "user_is_admin"): + if not request.user_is_admin: + role_name = await get_role_name() + return role_name + + def _check_permissions(self, request: aiohttp.web_request.Request, cat_info: str, role_name: str) -> None: + if request is not None: + if hasattr(request, "user_is_admin"): + if not request.user_is_admin: + if 'permissions' in cat_info: + if not (role_name in cat_info['permissions']): + raise Exception('Forbidden') + diff --git a/python/fledge/services/core/api/configuration.py b/python/fledge/services/core/api/configuration.py index e75ee681bd..56beb77e55 100644 --- a/python/fledge/services/core/api/configuration.py +++ b/python/fledge/services/core/api/configuration.py @@ -245,23 +245,8 @@ async def set_configuration_item(request): """ category_name = request.match_info.get('category_name', None) config_item = request.match_info.get('config_item', None) - category_name = urllib.parse.unquote(category_name) if category_name is not None else None config_item = urllib.parse.unquote(config_item) if config_item is not None else None - """ - FIXME: FOGL-6774 We should handle in better way, there may be more cases. - GIVEN: config item 'authentication' And category 'rest_api' - WHEN: if non-admin user is trying to update - THEN: 403 Forbidden case - """ - if hasattr(request, "user"): - if request.user and (category_name == 'rest_api' and config_item == 'authentication'): - if not request.user_is_admin: - msg = "Admin role permissions required to change the {} value for category {}.".format( - config_item, category_name) - _logger.warning(msg) - raise web.HTTPForbidden(reason=msg, body=json.dumps({"message": msg})) - data = await request.json() cf_mgr = ConfigurationManager(connect.get_storage_async()) found_optional = {} @@ -293,14 +278,21 @@ async def set_configuration_item(request): if 'readonly' in storage_value_entry: if storage_value_entry['readonly'] == 'true': raise TypeError("Update not allowed for {} item_name as it has readonly attribute set".format(config_item)) - await cf_mgr.set_category_item_value_entry(category_name, config_item, value) + request_details = request if hasattr(request, "user") else None + await cf_mgr.set_category_item_value_entry(category_name, config_item, value, request=request_details) else: await cf_mgr.set_optional_value_entry(category_name, config_item, list(found_optional.keys())[0], list(found_optional.values())[0]) except ValueError as ex: raise web.HTTPNotFound(reason=ex) if not found_optional else web.HTTPBadRequest(reason=ex) except (TypeError, KeyError) as ex: raise web.HTTPBadRequest(reason=ex) - + except Exception as ex: + msg = str(ex) + if 'Forbidden' in msg: + msg = "Insufficient access privileges to change the value for '{}' category and '{}' config item.".format( + category_name, config_item) + _logger.warning(msg) + raise web.HTTPForbidden(reason=msg, body=json.dumps({"message": msg})) category_item = await cf_mgr.get_category_item(category_name, config_item) if category_item is None: raise web.HTTPNotFound(reason="No detail found for the category_name: {} and config_item: {}".format(category_name, config_item)) @@ -325,20 +317,7 @@ async def update_configuration_item_bulk(request): data = await request.json() if not data: return web.HTTPBadRequest(reason='Nothing to update') - """ - FIXME: FOGL-6774 We should handle in better way, there may be more cases. - GIVEN: config item 'authentication' And category 'rest_api' - WHEN: if non-admin user is trying to update - THEN: 403 Forbidden case - """ - if hasattr(request, "user"): - config_items = [k for k, v in data.items() if k == 'authentication'] - if request.user and (category_name == 'rest_api' and config_items): - if not request.user_is_admin: - msg = "Admin role permissions required to change the authentication value for category {}.".format( - category_name) - _logger.warning(msg) - return web.HTTPForbidden(reason=msg, body=json.dumps({"message": msg})) + request_details = request if hasattr(request, "user") else None cf_mgr = ConfigurationManager(connect.get_storage_async()) try: is_core_mgt = request.is_core_mgt @@ -352,15 +331,22 @@ async def update_configuration_item_bulk(request): if storage_value_entry['readonly'] == 'true': raise TypeError( "Bulk update not allowed for {} item_name as it has readonly attribute set".format(item_name)) - await cf_mgr.update_configuration_item_bulk(category_name, data) + await cf_mgr.update_configuration_item_bulk(category_name, data, request_details) except (NameError, KeyError) as ex: raise web.HTTPNotFound(reason=ex) except (ValueError, TypeError) as ex: raise web.HTTPBadRequest(reason=ex) except Exception as ex: msg = str(ex) - _logger.error(ex, "Failed to bulk update {} category.".format(category_name)) - raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) + if 'Forbidden' in msg: + if 'Forbidden' in msg: + msg = "Insufficient access privileges to change the value for given data for '{}' category.".format( + category_name) + _logger.warning(msg) + raise web.HTTPForbidden(reason=msg, body=json.dumps({"message": msg})) + else: + _logger.error(ex, "Failed to bulk update {} category.".format(category_name)) + raise web.HTTPInternalServerError(reason=msg, body=json.dumps({"message": msg})) else: cat = await cf_mgr.get_category_all_items(category_name) try: diff --git a/python/fledge/services/core/server.py b/python/fledge/services/core/server.py index 9cbafa157c..5f7b5db3c6 100755 --- a/python/fledge/services/core/server.py +++ b/python/fledge/services/core/server.py @@ -241,7 +241,8 @@ class Server: 'options': ['mandatory', 'optional'], 'default': 'optional', 'displayName': 'Authentication', - 'order': '5' + 'order': '5', + 'permissions': ['admin'] }, 'authMethod': { 'description': 'Authentication method', @@ -280,7 +281,8 @@ class Server: 'displayName': 'Idle User Session Disconnection (In Minutes)', 'order': '10', 'minimum': '1', - 'maximum': '1440' + 'maximum': '1440', + 'permissions': ['admin'] } } @@ -522,7 +524,8 @@ async def password_config(cls): 'options': ['Any characters', 'Mixed case Alphabetic', 'Mixed case and numeric', 'Mixed case, numeric and special characters'], 'default': 'Any characters', 'displayName': 'Policy', - 'order': '1' + 'order': '1', + 'permissions': ['admin'] }, 'length': { 'description': 'Minimum password length', @@ -531,14 +534,16 @@ async def password_config(cls): 'displayName': 'Minimum Length', 'minimum': '6', 'maximum': '80', - 'order': '2' + 'order': '2', + 'permissions': ['admin'] }, 'expiration': { 'description': 'Number of days after which passwords must be changed', 'type': 'integer', 'default': '0', 'displayName': 'Expiry (in Days)', - 'order': '3' + 'order': '3', + 'permissions': ['admin'] } } category = 'password' diff --git a/python/fledge/services/core/user_model.py b/python/fledge/services/core/user_model.py index d13f0c9a0e..90636c8ef6 100644 --- a/python/fledge/services/core/user_model.py +++ b/python/fledge/services/core/user_model.py @@ -91,6 +91,13 @@ async def get_role_id_by_name(cls, name): result = await storage_client.query_tbl_with_payload('roles', payload) return result["rows"] + @classmethod + async def get_role_name_by_id(cls, rid): + storage_client = connect.get_storage_async() + payload = PayloadBuilder().SELECT("name").WHERE(['id', '=', rid]).LIMIT(1).payload() + result = await storage_client.query_tbl_with_payload('roles', payload) + return result['rows'][0] if result["rows"] else None + @classmethod async def create(cls, username, password, role_id, access_method='any', real_name='', description=''): """ diff --git a/tests/system/python/api/test_configuration.py b/tests/system/python/api/test_configuration.py index 3303ea8b57..a7836a2dae 100644 --- a/tests/system/python/api/test_configuration.py +++ b/tests/system/python/api/test_configuration.py @@ -167,12 +167,12 @@ def test_get_category(self, fledge_url): 'authCertificateName': {'displayName': 'Auth Certificate', 'description': 'Auth Certificate name', 'type': 'string', 'order': '7', 'value': 'ca', 'default': 'ca'}, 'certificateName': {'displayName': 'Certificate Name', 'description': 'Certificate file name', 'type': 'string', 'order': '4', 'value': 'fledge', 'default': 'fledge', 'validity': 'enableHttp=="false"'}, 'authProviders': {'displayName': 'Auth Providers', 'description': 'Authentication providers to use for the interface (JSON array object)', 'type': 'JSON', 'order': '9', 'value': '{"providers": ["username", "ldap"] }', 'default': '{"providers": ["username", "ldap"] }'}, - 'authentication': {'displayName': 'Authentication', 'description': 'API Call Authentication', 'type': 'enumeration', 'options': ['mandatory', 'optional'], 'order': '5', 'value': 'optional', 'default': 'optional'}, + 'authentication': {'displayName': 'Authentication', 'description': 'API Call Authentication', 'type': 'enumeration', 'options': ['mandatory', 'optional'], 'order': '5', 'value': 'optional', 'default': 'optional', 'permissions': ['admin']}, 'authMethod': {'displayName': 'Authentication method', 'description': 'Authentication method', 'type': 'enumeration', 'options': ['any', 'password', 'certificate'], 'order': '6', 'value': 'any', 'default': 'any'}, 'httpPort': {'displayName': 'HTTP Port', 'description': 'Port to accept HTTP connections on', 'type': 'integer', 'order': '2', 'value': '8081', 'default': '8081'}, 'allowPing': {'displayName': 'Allow Ping', 'description': 'Allow access to ping, regardless of the authentication required and authentication header', 'type': 'boolean', 'order': '8', 'value': 'true', 'default': 'true'}, 'enableHttp': {'displayName': 'Enable HTTP', 'description': 'Enable HTTP (disable to use HTTPS)', 'type': 'boolean', 'order': '1', 'value': 'true', 'default': 'true'}, - 'disconnectIdleUserSession': {'description': 'Disconnect idle user session after certain period of inactivity', 'type': 'integer', 'default': '15', 'displayName': 'Idle User Session Disconnection (In Minutes)', 'order': '10', 'minimum': '1', 'maximum': '1440', 'value': '15'}} + 'disconnectIdleUserSession': {'description': 'Disconnect idle user session after certain period of inactivity', 'type': 'integer', 'default': '15', 'displayName': 'Idle User Session Disconnection (In Minutes)', 'order': '10', 'minimum': '1', 'maximum': '1440', 'value': '15', 'permissions': ['admin']}} conn = http.client.HTTPConnection(fledge_url) conn.request("GET", '/fledge/category/rest_api') r = conn.getresponse() diff --git a/tests/unit/python/fledge/common/test_configuration_manager.py b/tests/unit/python/fledge/common/test_configuration_manager.py index 79e7bc869b..154f612a43 100644 --- a/tests/unit/python/fledge/common/test_configuration_manager.py +++ b/tests/unit/python/fledge/common/test_configuration_manager.py @@ -42,7 +42,7 @@ def test_supported_validate_type_strings(self): def test_supported_optional_items(self): expected_types = ['deprecated', 'displayName', 'group', 'length', 'mandatory', 'maximum', 'minimum', 'order', - 'readonly', 'rule', 'validity', 'listSize', 'listName'] + 'readonly', 'rule', 'validity', 'listSize', 'listName', 'permissions'] assert len(expected_types) == len(_optional_items) assert sorted(expected_types) == _optional_items @@ -523,7 +523,18 @@ async def test__validate_category_val_config_entry_val_not_string(self): ({"description": "test description", "type": "enumeration", "default": "C", "options": ["A", "B"]}, ValueError, "For test category, entry value does not exist in options list for item name test_item_name and entry_name options; got C"), ({"description": 1, "type": "enumeration", "default": "A", "options": ["A", "B"]}, - TypeError, "For test category, entry value must be a string for item name test_item_name and entry name description; got ") + TypeError, "For test category, entry value must be a string for item name test_item_name and entry name description; got "), + ({"description": "Test", "type": "enumeration", "default": "A", "options": ["A", "B"], 'permissions': ""}, + ValueError, "For test category, permissions entry value must be a list of string for item name test_item_name;" + " got ."), + ({"description": "Test", "type": "enumeration", "default": "A", "options": ["A", "B"], 'permissions': []}, + ValueError, "For test category, permissions entry value must not be empty for item name test_item_name."), + ({"description": "Test", "type": "enumeration", "default": "A", "options": ["A", "B"], 'permissions': [""]}, + ValueError, + "For test category, permissions entry values must be a string and non-empty for item name test_item_name."), + ({"description": "Test", "type": "enumeration", "default": "A", "options": ["A", "B"], + 'permissions': ["editor", 2]}, ValueError, + "For test category, permissions entry values must be a string and non-empty for item name test_item_name.") ]) async def test__validate_category_val_enum_type_bad(self, config, exception_name, exception_msg): storage_client_mock = MagicMock(spec=StorageClientAsync) @@ -535,12 +546,42 @@ async def test__validate_category_val_enum_type_bad(self, config, exception_name assert excinfo.type is exception_name assert exception_msg == str(excinfo.value) - @pytest.mark.skip(reason="FOGL-8281") + #@pytest.mark.skip(reason="FOGL-8281") @pytest.mark.parametrize("config", [ - ({ITEM_NAME: {"description": "test description", "type": "bucket", "default": "A"}}), - ({ITEM_NAME: {"description": "test description", "type": "bucket", "default": "A", "properties": "{}"}}), + ({ITEM_NAME: {"description": "test description", "type": "bucket", + "default": "{'type': 'model', 'name': 'Person', 'version': '1.0', 'hardware': 'tpu'}", "properties": + {"constant": {"type": "model"}, "key": { + "name": {"description": "TFlite model name to use for inference", "type": "string", "default": "People", + "order": "1", "displayName": "TFlite model name"}}, "properties": { + "version": {"description": "Model version as stored in bucket", "type": "string", "default": "1.2", + "order": "2", "displayName": "Model version"}, "hardware": { + "description": "Inference hardware (\'tpu\' may be chosen only if available and configured properly)", + "type": "enumeration", "default": "cpu", "options": ["cpu", "tpu"], "order": "3", + "displayName": "Inference hardware"}}}}}), + ({ITEM_NAME: {"description": "test description", "type": "bucket", + "default": "{'type': 'model', 'name': 'Person', 'version': '1.0', 'hardware': 'tpu'}", + "properties": + {"constant": {"type": "model"}, "key": { + "name": {"description": "TFlite model name to use for inference", "type": "string", + "default": "People", + "order": "1", "displayName": "TFlite model name"}}, "properties": { + "version": {"description": "Model version as stored in bucket", "type": "string", + "default": "1.2", + "order": "2", "displayName": "Model version"}, "hardware": { + "description": "Inference hardware (\'tpu\' may be chosen only if available and configured properly)", + "type": "enumeration", "default": "cpu", "options": ["cpu", "tpu"], "order": "3", + "displayName": "Inference hardware"}}}}}), ({"item": {"description": "test description", "type": "string", "default": "A"}, - ITEM_NAME: {"description": "test description", "type": "bucket", "default": "A"}}), + ITEM_NAME: {"description": "test description", "type": "bucket", "default": + "{'type': 'model', 'name': 'People', 'version': '1.2', 'hardware': 'cpu'}", "properties": + {"constant": {"type": "model"}, "key": { + "name": {"description": "TFlite model name to use for inference", "type": "string", "default": "People", + "order": "1", "displayName": "TFlite model name"}}, "properties": { + "version": {"description": "Model version as stored in bucket", "type": "string", "default": "1.2", + "order": "2", "displayName": "Model version"}, "hardware": { + "description": "Inference hardware (\'tpu\' may be chosen only if available and configured properly)", + "type": "enumeration", "default": "cpu", "options": ["cpu", "tpu"], "order": "3", + "displayName": "Inference hardware"}}}}}) ]) async def test__validate_category_val_bucket_type_good(self, config): storage_client_mock = MagicMock(spec=StorageClientAsync) @@ -680,6 +721,26 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re ({ITEM_NAME: {"description": "test", "type": "list", "default": "{\"key\": \"1.0\"}", "items": "object", "properties": {"width": {"description": "", "default": "", "type": ""}}, "listName": ""}}, ValueError,"For {} category, listName cannot be empty for item name {}".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "{\"key\": \"1.0\"}", "items": "object", + "properties": {"width": {"description": "", "default": "", "type": ""}}, "permissions": ""}}, + ValueError, "For {} category, permissions entry value must be a list of string for item name {}; " + "got .".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "{\"key\": \"1.0\"}", "items": "object", + "properties": {"width": {"description": "", "default": "", "type": ""}}, "permissions": []}}, + ValueError, "For {} category, permissions entry value must not be empty for item name {}.".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "{\"key\": \"1.0\"}", "items": "object", + "properties": {"width": {"description": "", "default": "", "type": ""}}, "permissions": [1]}}, + ValueError, "For {} category, permissions entry values must be a string and non-empty for item name {}.".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "{\"key\": \"1.0\"}", "items": "object", + "properties": {"width": {"description": "", "default": "", "type": ""}}, "permissions": ["a", 2]}}, + ValueError, "For {} category, permissions entry values must be a string and non-empty for item name {}." + "".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "test", "type": "list", "default": "{\"key\": \"1.0\"}", "items": "object", + "properties": {"width": {"description": "", "default": "", "type": ""}}, "permissions": ["", "A"]}}, + ValueError, "For {} category, permissions entry values must be a string and non-empty for item name {}." + "".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A"}}, KeyError, "'For {} category, items KV pair must be required for item name {}.'".format(CAT_NAME, ITEM_NAME)), ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "A", "items": []}}, TypeError, @@ -834,7 +895,23 @@ async def test__validate_category_val_bucket_type_bad(self, config, exc_name, re ({ITEM_NAME: {"description": "expression", "type": "kvlist", "default": "{\"key\": \"1.0\", \"key\": \"val2\"}", "items": "float", "listName": 2}}, TypeError, "For {} category, listName type must be a string for item name {}; got ".format( - CAT_NAME, ITEM_NAME)) + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", + "default": "{\"key\": \"1.0\", \"key\": \"val2\"}", "items": "float", "permissions": ""}}, + ValueError, "For {} category, permissions entry value must be a list of string for item name {}; " + "got .".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", + "default": "{\"key\": \"1.0\", \"key\": \"val2\"}", "items": "float", "permissions": []}}, + ValueError, "For {} category, permissions entry value must not be empty for item name {}.".format( + CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", + "default": "{\"key\": \"1.0\", \"key\": \"val2\"}", "items": "float", "permissions": [""]}}, + ValueError, "For {} category, permissions entry values must be a string and non-empty for item name {}." + "".format(CAT_NAME, ITEM_NAME)), + ({ITEM_NAME: {"description": "expression", "type": "kvlist", + "default": "{\"key\": \"1.0\", \"key\": \"val2\"}", "items": "float", "permissions": [2]}}, + ValueError, "For {} category, permissions entry values must be a string and non-empty for item name {}." + "".format(CAT_NAME, ITEM_NAME)), ]) async def test__validate_category_val_list_type_bad(self, config, exc_name, reason): storage_client_mock = MagicMock(spec=StorageClientAsync) @@ -860,6 +937,8 @@ async def test__validate_category_val_list_type_bad(self, config, exc_name, reas "default": "[\"var1\", \"var2\"]", "listSize": "2"}}, {"include": {"description": "A list of variables to include", "type": "list", "items": "string", "default": "[]", "listSize": "1"}}, + {"include": {"description": "A list of variables to include", "type": "list", "items": "string", + "default": "[]", "permissions": ["user", "control"]}}, {"include": {"description": "A list of variables to include", "type": "list", "items": "integer", "default": "[\"10\", \"100\", \"200\", \"300\"]", "listSize": "4"}}, {"include": {"description": "A list of variables to include", "type": "list", "items": "object", @@ -889,7 +968,10 @@ async def test__validate_category_val_list_type_bad(self, config, exc_name, reas "properties": {"width": {"description": "Number of registers to read", "displayName": "Width", "type": "integer", "maximum": "4", "default": "1"}}}}, {"include": {"description": "A list of expressions and values ", "type": "kvlist", "default": - "{\"key1\": \"integer\", \"key2\": \"float\"}", "items": "enumeration", "options": ["integer", "float"]}} + "{\"key1\": \"integer\", \"key2\": \"float\"}", "items": "enumeration", "options": ["integer", "float"]}}, + {"include": {"description": "A list of expressions and values ", "type": "kvlist", "default": + "{\"key1\": \"integer\", \"key2\": \"float\"}", "items": "enumeration", "options": ["integer", "float"], + "permissions": ["admin"]}} ]) async def test__validate_category_val_list_type_good(self, config): storage_client_mock = MagicMock(spec=StorageClientAsync) @@ -3626,17 +3708,20 @@ async def async_mock(return_value): # Changed in version 3.8: patch() now returns an AsyncMock if the target is an async function. if sys.version_info.major == 3 and sys.version_info.minor >= 8: - _rv = await async_mock(cat_info) + rv1 = await async_mock(cat_info) + rv2 = await async_mock("") else: - _rv = asyncio.ensure_future(async_mock(cat_info)) - - with patch.object(c_mgr, 'get_category_all_items', return_value=_rv) as patch_get_all_items: - with patch.object(_logger, 'exception') as patch_log_exc: - with pytest.raises(Exception) as exc_info: - await c_mgr.update_configuration_item_bulk(category_name, config_item_list) - assert exc_type == exc_info.type - assert exc_msg == str(exc_info.value) - assert 1 == patch_log_exc.call_count + rv1 = asyncio.ensure_future(async_mock(cat_info)) + rv2 = asyncio.ensure_future(async_mock("")) + + with patch.object(c_mgr, 'get_category_all_items', return_value=rv1) as patch_get_all_items: + with patch.object(c_mgr, '_check_updates_by_role', return_value=rv2): + with patch.object(_logger, 'exception') as patch_log_exc: + with pytest.raises(Exception) as exc_info: + await c_mgr.update_configuration_item_bulk(category_name, config_item_list) + assert exc_type == exc_info.type + assert exc_msg == str(exc_info.value) + assert 1 == patch_log_exc.call_count patch_get_all_items.assert_called_once_with(category_name) async def test_update_configuration_item_bulk(self, category_name='rest_api'): diff --git a/tests/unit/python/fledge/services/core/api/test_configuration.py b/tests/unit/python/fledge/services/core/api/test_configuration.py index be069135e7..4f69024703 100644 --- a/tests/unit/python/fledge/services/core/api/test_configuration.py +++ b/tests/unit/python/fledge/services/core/api/test_configuration.py @@ -320,7 +320,13 @@ async def async_mock(return_value): args, kwargs = calls[1] assert category_name == args[0] assert item_name == args[1] - patch_set_entry.assert_called_once_with(category_name, item_name, payload['value']) + assert 1 == patch_set_entry.call_count + calls = patch_set_entry.call_args_list + args, _ = calls[0] + assert 3 == len(args) + assert category_name == args[0] + assert item_name == args[1] + assert payload['value'] == args[2] @pytest.mark.parametrize("payload, message", [ ({"valu": '8082'}, "Missing required value for http_port"), @@ -361,7 +367,8 @@ async def async_mock(return_value): resp = await client.put('/fledge/category/{}/{}'.format(category_name, item_name), data=json.dumps(payload)) assert 404 == resp.status - assert "No detail found for the category_name: {} and config_item: {}".format(category_name, item_name) == resp.reason + assert "No detail found for the category_name: {} and config_item: {}".format( + category_name, item_name) == resp.reason assert 2 == patch_get_cat_item.call_count calls = patch_get_cat_item.call_args_list args, kwargs = calls[0] @@ -370,7 +377,13 @@ async def async_mock(return_value): args, kwargs = calls[1] assert category_name == args[0] assert item_name == args[1] - patch_set_entry.assert_called_once_with(category_name, item_name, payload['value']) + assert 1 == patch_set_entry.call_count + calls = patch_set_entry.call_args_list + args, _ = calls[0] + assert 3 == len(args) + assert category_name == args[0] + assert item_name == args[1] + assert payload['value'] == args[2] @pytest.mark.parametrize("payload, optional_item, message", [ ({"value": '8082'}, "readonly", "Update not allowed for {} item_name as it has readonly attribute set") @@ -1074,7 +1087,13 @@ async def async_mock(return_value): json_response = json.loads(r) assert result == json_response patch_get_all_items.assert_called_once_with(category_name) - patch_update_bulk.assert_called_once_with(category_name, payload) + assert 1 == patch_update_bulk.call_count + calls = patch_update_bulk.call_args_list + args, _ = calls[0] + assert 3 == len(args) + assert category_name == args[0] + assert payload == args[1] + assert args[2] is not None assert 2 == patch_get_cat_item.call_count async def test_delete_configuration(self, client, category_name='rest_api'):