Don't overwrite invalid json in config (#2881)

* Separate tests of LocalConf into new test class

* Do not overwrite configs with malformed json

If a config file is loaded and is invalid saves to that file will not be
possible until it's manually restored. The store accepts a force flag to
override this protection.

The method returns True if the store succeeded so call sites can
verbally report the issue as well.
pull/3088/head
Åke 2022-03-07 01:33:10 +01:00 committed by GitHub
parent 660b7b9bd3
commit 5f4c68e583
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 92 additions and 37 deletions

View File

@ -97,6 +97,7 @@ class LocalConf(dict):
def __init__(self, path): def __init__(self, path):
super(LocalConf, self).__init__() super(LocalConf, self).__init__()
self.is_valid = True # is loaded json valid, updated when load occurs
if path: if path:
self.path = path self.path = path
self.load_local(path) self.load_local(path)
@ -117,23 +118,41 @@ class LocalConf(dict):
except Exception as e: except Exception as e:
LOG.error("Error loading configuration '{}'".format(path)) LOG.error("Error loading configuration '{}'".format(path))
LOG.error(repr(e)) LOG.error(repr(e))
self.is_valid = False
else: else:
LOG.debug("Configuration '{}' not defined, skipping".format(path)) LOG.debug("Configuration '{}' not defined, skipping".format(path))
def store(self, path=None): def store(self, path=None, force=False):
"""Cache the received settings locally. """Save config to disk.
The cache will be used if the remote is unreachable to load settings The cache will be used if the remote is unreachable to load settings
that are as close to the user's as possible. that are as close to the user's as possible.
path (str): path to store file to, if missing will use the path from
where the config was loaded.
force (bool): Set to True if writing should occur despite the original
was malformed.
Returns:
(bool) True if save was successful, else False.
""" """
result = False
with self._lock: with self._lock:
path = path or self.path path = path or self.path
config_dir = dirname(path) config_dir = dirname(path)
if not exists(config_dir): if not exists(config_dir):
os.makedirs(config_dir) os.makedirs(config_dir)
with open(path, 'w') as f: if self.is_valid or force:
json.dump(self, f, indent=2) with open(path, 'w') as f:
json.dump(self, f, indent=2)
result = True
else:
LOG.warning((f'"{path}" was not a valid config file when '
'loaded, will not save config. Please correct '
'the json or remove it to allow updates.'))
result = False
return result
def merge(self, conf): def merge(self, conf):
merge_dict(self, conf) merge_dict(self, conf)
@ -175,7 +194,7 @@ class RemoteConf(LocalConf):
translate_remote(config, setting) translate_remote(config, setting)
for key in config: for key in config:
self.__setitem__(key, config[key]) self.__setitem__(key, config[key])
self.store(cache) self.store(cache, force=True)
except RequestException as e: except RequestException as e:
LOG.error("RequestException fetching remote configuration: {}" LOG.error("RequestException fetching remote configuration: {}"

View File

@ -32,38 +32,6 @@ class TestConfiguration(TestCase):
self.assertTrue(rc['test_config']) self.assertTrue(rc['test_config'])
self.assertEqual(rc['location']['city']['name'], 'Stockholm') self.assertEqual(rc['location']['city']['name'], 'Stockholm')
@patch('json.dump')
@patch('mycroft.configuration.config.exists')
@patch('mycroft.configuration.config.isfile')
@patch('mycroft.configuration.config.load_commented_json')
def test_local(self, mock_json_loader, mock_isfile, mock_exists,
mock_json_dump):
local_conf = {'answer': 42, 'falling_objects': ['flower pot', 'whale']}
mock_exists.return_value = True
mock_isfile.return_value = True
mock_json_loader.return_value = local_conf
lc = mycroft.configuration.LocalConf('test')
self.assertEqual(lc, local_conf)
# Test merge method
merge_conf = {'falling_objects': None, 'has_towel': True}
lc.merge(merge_conf)
self.assertEqual(lc['falling_objects'], None)
self.assertEqual(lc['has_towel'], True)
# test store
lc.store('test_conf.json')
self.assertEqual(mock_json_dump.call_args[0][0], lc)
# exists but is not file
mock_isfile.return_value = False
lc = mycroft.configuration.LocalConf('test')
self.assertEqual(lc, {})
# does not exist
mock_exists.return_value = False
lc = mycroft.configuration.LocalConf('test')
self.assertEqual(lc, {})
@patch('mycroft.configuration.config.RemoteConf') @patch('mycroft.configuration.config.RemoteConf')
@patch('mycroft.configuration.config.LocalConf') @patch('mycroft.configuration.config.LocalConf')
def test_update(self, mock_remote, mock_local): def test_update(self, mock_remote, mock_local):
@ -78,3 +46,71 @@ class TestConfiguration(TestCase):
def tearDown(self): def tearDown(self):
mycroft.configuration.Configuration.load_config_stack([{}], True) mycroft.configuration.Configuration.load_config_stack([{}], True)
@patch('mycroft.configuration.config.exists')
@patch('mycroft.configuration.config.isfile')
@patch('mycroft.configuration.config.load_commented_json')
class TestLocalConf(TestCase):
"""Test cases for LocalConf class."""
def test_create(self, mock_json_loader, mock_isfile, mock_exists):
"""Test that initialization and creation works as expected."""
local_conf = {'answer': 42, 'falling_objects': ['flower pot', 'whale']}
mock_exists.return_value = True
mock_isfile.return_value = True
mock_json_loader.return_value = local_conf
lc = mycroft.configuration.LocalConf('test')
self.assertEqual(lc, local_conf)
def test_merge(self, mock_json_loader, mock_isfile, mock_exists):
"""Check that configurations are merged correctly."""
local_conf = {'answer': 42, 'falling_objects': ['flower pot', 'whale']}
mock_exists.return_value = True
mock_isfile.return_value = True
mock_json_loader.return_value = local_conf
lc = mycroft.configuration.LocalConf('test')
merge_conf = {'falling_objects': None, 'has_towel': True}
lc.merge(merge_conf)
self.assertEqual(lc['falling_objects'], None)
self.assertEqual(lc['has_towel'], True)
@patch('json.dump')
def test_store(self, mock_json_dump, mock_json_loader, mock_isfile,
mock_exists):
"""Check that the config is stored correctly."""
local_conf = {'answer': 42, 'falling_objects': ['flower pot', 'whale']}
mock_exists.return_value = True
mock_isfile.return_value = True
mock_json_loader.return_value = local_conf
lc = mycroft.configuration.LocalConf('test')
lc.store('test_conf.json')
self.assertEqual(mock_json_dump.call_args[0][0], lc)
# exists but is not file
mock_isfile.return_value = False
lc = mycroft.configuration.LocalConf('test')
self.assertEqual(lc, {})
# does not exist
mock_exists.return_value = False
lc = mycroft.configuration.LocalConf('test')
self.assertEqual(lc, {})
@patch('json.dump')
def test_store_invalid(self, mock_json_dump, mock_json_loader, mock_isfile,
mock_exists):
"""Storing shouldn't happen when config content is invalid."""
mock_exists.return_value = True
mock_isfile.return_value = True
def raise_error(*arg, **kwarg):
raise Exception('Test exception')
mock_json_loader.side_effect = raise_error
lc = mycroft.configuration.LocalConf('invalid')
self.assertFalse(lc.is_valid)
self.assertFalse(lc.store())
mock_json_dump.assert_not_called()