FreedomBox/plinth/tests/test_actions.py
Sunil Mohan Adapa 0c6f04b55f
actions, backups: Fix tests depending on sudo based actions
Tests:

- Mounting/unmounting of remote SSH repositories works.

- Creating repo, creating/deleting/list archives work.

- If a privileged method raises an exception after outputting to stdout (using
action_utils.run) then stdout is shown in the HTML UI message.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2025-09-05 20:24:10 +05:30

326 lines
10 KiB
Python

# 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])