# SPDX-License-Identifier: AGPL-3.0-or-later """ Test module for actions utilities that modify configuration. Verify that privileged actions perform as expected. See actions.py for a full description of the expectations. """ import os import typing from unittest.mock import Mock, call, patch import pytest from plinth import actions from plinth.actions import privileged, secret_str actions_name = 'actions' @pytest.fixture(name='popen') def fixture_popen(): """A fixture to patch subprocess.Popen called by privileged action.""" with patch('subprocess.Popen') as popen: def call_popen(command, **kwargs): write_fd = int(command[8]) if not isinstance(popen.called_with_write_fd, list): popen.called_with_write_fd = [] popen.called_with_write_fd.append(write_fd) os.write(write_fd, bytes(popen.return_value, encoding='utf-8')) proc = Mock() proc.communicate.return_value = (b'', b'') proc.returncode = 0 return proc popen.side_effect = call_popen yield popen def test_privileged_properties(): """Test that privileged decorator sets proper properties on the method.""" def func(): return wrapped_func = privileged(func) assert wrapped_func._privileged assert wrapped_func.__wrapped__ == func def test_privileged_argument_vararg_check(): """Test that privileged decorator checks for simple arguments.""" def func_with_varargs(*_args): return def func_with_kwargs(**_kwargs): return def func_with_kwonlyargs(*_args, _kwarg): return def func_with_kwonlydefaults(*_args, _kwargs='foo'): return for func in (func_with_varargs, func_with_kwargs, func_with_kwonlyargs, func_with_kwonlydefaults): with pytest.raises(SyntaxError): privileged(func) def test_privileged_argument_annotation_check(): """Test that privileged decorator checks for annotations to arguments.""" def func1(_a): return def func2(_a: int, _b): return # Parameter with 'password' in the name should be typed 'secret_str' def func3(_password: str): return def func1_valid(_a: int, _b: dict[int, str]): return def func2_valid(_password: secret_str): return for func in (func1, func2, func3): with pytest.raises(SyntaxError): privileged(func) privileged(func1_valid) privileged(func2_valid) @patch('plinth.actions._read_from_server') @patch('plinth.actions._request_to_server') @patch('plinth.actions._get_privileged_action_module_name') def test_privileged_method_call(get_module_name, request_to_server, read_from_server): """Test that privileged method calls the superuser action properly.""" def func_with_args(_a: int, _b: str, _c: int = 1, _d: str = 'dval', _e: str = 'eval'): return get_module_name.return_value = 'tests' read_from_server.return_value = {"result": "success", "return": "bar"} wrapped_func = privileged(func_with_args) return_value = wrapped_func(1, 'bval', None, _d='dnewval') assert return_value == 'bar' assert request_to_server.mock_calls == [ call({ 'module': 'tests', 'action': 'func_with_args', 'args': (1, 'bval', None), 'kwargs': { '_d': 'dnewval' } }), call().close() ] @patch('plinth.actions._read_from_server') @patch('plinth.actions._request_to_server') @patch('plinth.actions._get_privileged_action_module_name') def test_privileged_method_exceptions(get_module_name, request_to_server, read_from_server): """Test that exceptions on privileged methods are return properly.""" def func_with_exception(): raise TypeError('type error') get_module_name.return_value = 'tests' read_from_server.return_value = { 'result': 'exception', 'exception': { 'module': 'builtins', 'name': 'TypeError', 'args': ['type error'], 'traceback': [''], 'stdout': '', 'stderr': '' } } wrapped_func = privileged(func_with_exception) with pytest.raises(TypeError, match='type error'): wrapped_func() @patch('importlib.import_module') @patch('plinth.module_loader.get_module_import_path') @patch('os.getuid') def test_call_syntax_checks(getuid, get_module_import_path, import_module): """Test that calling a method results in proper syntax checks.""" privileged_call = actions._privileged_call # Module name validation getuid.return_value = 0 with pytest.raises(SyntaxError, match='Invalid module name'): privileged_call('foo.bar', 'x-action', {}) # Module import test get_module_import_path.return_value = 'plinth.modules.test_module' import_module.side_effect = ModuleNotFoundError with pytest.raises(SyntaxError, match='Specified module not found'): privileged_call('test_module', 'x-action', {}) import_module.assert_has_calls( [call('plinth.modules.test_module.privileged')]) # Finding action in a module module = type('', (), {}) import_module.side_effect = None import_module.return_value = module with pytest.raises(SyntaxError, match='Specified action not found'): privileged_call('test_module', 'x-action', {}) # Checking if action is privileged def unprivileged_func(): pass setattr(module, 'func', unprivileged_func) with pytest.raises(SyntaxError, match='Specified action is not privileged action'): privileged_call('test-module', 'func', {}) # Argument validation @actions.privileged def func(): return 'foo' setattr(module, 'func', func) with pytest.raises(SyntaxError, match='Invalid arguments format'): privileged_call('test-module', 'func', {}) # Successful call return_value = privileged_call('test-module', 'func', { 'args': [], 'kwargs': {} }) assert return_value == {'result': 'success', 'return': 'foo'} # Exception call @actions.privileged def exception_func(): raise RuntimeError('foo exception') setattr(module, 'func', exception_func) return_value = privileged_call('test-module', 'func', { 'args': [], 'kwargs': {} }) assert return_value['result'] == 'exception' assert return_value['exception']['module'] == 'builtins' assert return_value['exception']['name'] == 'RuntimeError' assert return_value['exception']['args'] == ('foo exception', ) assert isinstance(return_value['exception']['traceback'], list) for line in return_value['exception']['traceback']: assert isinstance(line, str) def test_assert_valid_arguments(): """Test that checking valid arguments works.""" assert_valid = actions._privileged_assert_valid_arguments values = [ None, [], 10, {}, { 'args': [] }, { 'kwargs': {} }, { 'args': {}, 'kwargs': {} }, { 'args': [], 'kwargs': [] } ] for value in values: with pytest.raises(SyntaxError, match='Invalid arguments format'): assert_valid(lambda: None, value) def func(a: int, b: str, c: int = 3, d: str = 'foo'): pass with pytest.raises(SyntaxError, match='Too many arguments'): assert_valid(func, {'args': [1, 2, 3], 'kwargs': {'c': 3, 'd': 4}}) with pytest.raises(SyntaxError, match='Too many arguments'): assert_valid(func, {'args': [1, 2, 3, 4, 5], 'kwargs': {}}) with pytest.raises(SyntaxError, match='Too many arguments'): assert_valid(func, { 'args': [], 'kwargs': { 'a': 1, 'b': '2', 'c': 3, 'd': '4', 'e': 5 } }) with pytest.raises(SyntaxError, match='Argument not provided: b'): assert_valid(func, {'args': [1], 'kwargs': {}}) with pytest.raises(SyntaxError, match='Unknown argument: e'): assert_valid(func, {'args': [1, '2'], 'kwargs': {'e': 5}}) with pytest.raises(SyntaxError, match='Duplicate argument: c'): assert_valid(func, {'args': [1, '2', 3], 'kwargs': {'c': 4}}) with pytest.raises(TypeError, match='Expected type str for arg #1'): assert_valid(func, {'args': [1, 2], 'kwargs': {}}) with pytest.raises(TypeError, match='Expected type int for arg c'): assert_valid(func, {'args': [1, '2'], 'kwargs': {'c': '3'}}) def test_assert_valid_type(): """Test that type validation works as expected.""" assert_valid = actions._privileged_assert_valid_type assert_valid(None, None, typing.Any) # Invalid values for int, str, float and Optional values = [[1, bool], ['foo', int], [1, str], [1, secret_str], [1, float], [1, typing.Optional[str]], [1, str | None], [1.1, typing.Union[int, str]], [1.1, int | str], [1, list], [1, dict], [[1], list[str]], [{ 'a': 'b' }, dict[int, str]], [{ 1: 2 }, dict[int, str]]] for value in values: with pytest.raises(TypeError): assert_valid('arg', *value) # Valid values assert_valid('arg', True, bool) assert_valid('arg', 1, int) assert_valid('arg', '1', str) assert_valid('arg', '1', secret_str) assert_valid('arg', 1.1, float) assert_valid('arg', None, typing.Optional[int]) assert_valid('arg', 1, typing.Optional[int]) assert_valid('arg', None, int | None) assert_valid('arg', 1, int | None) assert_valid('arg', 1, typing.Union[int, str]) assert_valid('arg', '1', typing.Union[int, str]) assert_valid('arg', 1, int | str) assert_valid('arg', '1', int | str) assert_valid('arg', [], list[int]) assert_valid('arg', ['foo'], list[str]) assert_valid('arg', {}, dict[int, str]) assert_valid('arg', {1: 'foo'}, dict[int, str])