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>
This commit is contained in:
Sunil Mohan Adapa 2025-08-14 08:01:16 -07:00 committed by Joseph Nuthalapati
parent 944c427f44
commit 0c6f04b55f
No known key found for this signature in database
GPG Key ID: 5398F00A2FA43C35
5 changed files with 50 additions and 53 deletions

View File

@ -67,6 +67,9 @@ def privileged(func):
@functools.wraps(func) @functools.wraps(func)
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
if getattr(func, '_skip_privileged_call', False):
return func(*args, **kwargs)
module_name = _get_privileged_action_module_name(func) module_name = _get_privileged_action_module_name(func)
action_name = func.__name__ action_name = func.__name__
return run_privileged_method(func, module_name, action_name, args, return run_privileged_method(func, module_name, action_name, args,

View File

@ -111,17 +111,6 @@ def fixture_needs_sudo():
pytest.skip('Needs sudo command installed.') pytest.skip('Needs sudo command installed.')
@pytest.fixture(name='no_privileged_server', scope='module')
def fixture_no_privileged__server():
"""Don't setup for and run privileged methods on server.
Tests on using privileged daemon are not yet implemented.
"""
with patch('plinth.actions.run_privileged_method_on_server') as mock:
mock.side_effect = NotImplementedError
yield
@pytest.fixture(scope='session') @pytest.fixture(scope='session')
def splinter_selenium_implicit_wait(): def splinter_selenium_implicit_wait():
"""Disable implicit waiting.""" """Disable implicit waiting."""
@ -164,9 +153,10 @@ def fixture_mock_privileged(request):
privileged_modules_to_mock = request.module.privileged_modules_to_mock privileged_modules_to_mock = request.module.privileged_modules_to_mock
except AttributeError: except AttributeError:
raise AttributeError( raise AttributeError(
'mock_privileged fixture requires "privileged_module_to_mock" ' 'mock_privileged fixture requires "privileged_modules_to_mock" '
'attribute at module level') 'attribute at module level')
mocked_methods = []
for module_name in privileged_modules_to_mock: for module_name in privileged_modules_to_mock:
module = importlib.import_module(module_name) module = importlib.import_module(module_name)
for name, member in module.__dict__.items(): for name, member in module.__dict__.items():
@ -177,19 +167,20 @@ def fixture_mock_privileged(request):
if not getattr(member, '_privileged', False): if not getattr(member, '_privileged', False):
continue continue
setattr(wrapped, '_original_wrapper', member) while getattr(wrapped, '__wrapped__', None) and getattr(
module.__dict__[name] = wrapped wrapped, '_privileged', True):
wrapped = getattr(wrapped, '__wrapped__', None)
setattr(wrapped, '_skip_privileged_call', True)
mocked_methods.append(wrapped)
yield yield
for module_name in privileged_modules_to_mock: for mocked_method in mocked_methods:
module = importlib.import_module(module_name) try:
for name, member in module.__dict__.items(): delattr(mocked_method, '_skip_privileged_call')
wrapper = getattr(member, '_original_wrapper', None) except AttributeError:
if not callable(member) or not wrapper: pass
continue
module.__dict__[name] = wrapper
@pytest.fixture(name='mock_run_as_user') @pytest.fixture(name='mock_run_as_user')

View File

@ -118,8 +118,8 @@ def reraise_known_errors(privileged_func):
def _reraise_known_errors(err): def _reraise_known_errors(err):
"""Look whether the caught error is known and reraise it accordingly""" """Look whether the caught error is known and reraise it accordingly"""
stdout = getattr(err, 'stdout', b'').decode() stdout = (getattr(err, 'stdout') or b'').decode()
stderr = getattr(err, 'stderr', b'').decode() stderr = (getattr(err, 'stderr') or b'').decode()
caught_error = str((err, err.args, stdout, stderr)) caught_error = str((err, err.args, stdout, stderr))
for known_error in KNOWN_ERRORS: for known_error in KNOWN_ERRORS:
for error in known_error['errors']: for error in known_error['errors']:
@ -166,14 +166,14 @@ def mount(mountpoint: str, remote_path: str, ssh_keyfile: str | None = None,
cmd += ['-o', 'password_stdin'] cmd += ['-o', 'password_stdin']
input_ = password.encode() input_ = password.encode()
subprocess.run(cmd, check=True, timeout=TIMEOUT, input=input_) action_utils.run(cmd, check=True, timeout=TIMEOUT, input=input_)
@reraise_known_errors @reraise_known_errors
@privileged @privileged
def umount(mountpoint: str): def umount(mountpoint: str):
"""Unmount a mountpoint.""" """Unmount a mountpoint."""
subprocess.run(['umount', mountpoint], check=True) action_utils.run(['umount', mountpoint], check=True)
def _validate_mountpoint(mountpoint): def _validate_mountpoint(mountpoint):
@ -515,4 +515,4 @@ def _get_env(encryption_passphrase: str | None = None):
def _run(cmd, encryption_passphrase=None, check=True, **kwargs): def _run(cmd, encryption_passphrase=None, check=True, **kwargs):
"""Wrap the command with extra encryption passphrase handling.""" """Wrap the command with extra encryption passphrase handling."""
env = _get_env(encryption_passphrase) env = _get_env(encryption_passphrase)
return subprocess.run(cmd, check=check, env=env, **kwargs) return action_utils.run(cmd, check=check, env=env, **kwargs)

View File

@ -16,7 +16,9 @@ from plinth.modules.backups.repository import BorgRepository, SshBorgRepository
from plinth.tests import config as test_config from plinth.tests import config as test_config
pytestmark = pytest.mark.usefixtures('needs_root', 'needs_borg', 'load_cfg', pytestmark = pytest.mark.usefixtures('needs_root', 'needs_borg', 'load_cfg',
'no_privileged_server') 'mock_privileged')
privileged_modules_to_mock = ['plinth.modules.backups.privileged']
# try to access a non-existing url and a URL that exists but does not # try to access a non-existing url and a URL that exists but does not
# grant access # grant access

View File

@ -7,21 +7,17 @@ description of the expectations.
""" """
import json
import os import os
import subprocess
import typing import typing
from unittest.mock import Mock, call, patch from unittest.mock import Mock, call, patch
import pytest import pytest
from plinth import actions, cfg from plinth import actions
from plinth.actions import privileged, secret_str from plinth.actions import privileged, secret_str
actions_name = 'actions' actions_name = 'actions'
pytestmark = pytest.mark.usefixtures('no_privileged_server')
@pytest.fixture(name='popen') @pytest.fixture(name='popen')
def fixture_popen(): def fixture_popen():
@ -104,8 +100,11 @@ def test_privileged_argument_annotation_check():
privileged(func2_valid) privileged(func2_valid)
@patch('plinth.actions._read_from_server')
@patch('plinth.actions._request_to_server')
@patch('plinth.actions._get_privileged_action_module_name') @patch('plinth.actions._get_privileged_action_module_name')
def test_privileged_method_call(get_module_name, popen): def test_privileged_method_call(get_module_name, request_to_server,
read_from_server):
"""Test that privileged method calls the superuser action properly.""" """Test that privileged method calls the superuser action properly."""
def func_with_args(_a: int, _b: str, _c: int = 1, _d: str = 'dval', def func_with_args(_a: int, _b: str, _c: int = 1, _d: str = 'dval',
@ -113,43 +112,45 @@ def test_privileged_method_call(get_module_name, popen):
return return
get_module_name.return_value = 'tests' get_module_name.return_value = 'tests'
popen.return_value = json.dumps({'result': 'success', 'return': 'bar'}) read_from_server.return_value = {"result": "success", "return": "bar"}
wrapped_func = privileged(func_with_args) wrapped_func = privileged(func_with_args)
return_value = wrapped_func(1, 'bval', None, _d='dnewval') return_value = wrapped_func(1, 'bval', None, _d='dnewval')
assert return_value == 'bar' assert return_value == 'bar'
assert request_to_server.mock_calls == [
input_ = {'args': [1, 'bval', None], 'kwargs': {'_d': 'dnewval'}} call({
input_ = json.dumps(input_) 'module': 'tests',
write_fd = popen.called_with_write_fd[0] 'action': 'func_with_args',
close_from_fd = str(write_fd + 1) 'args': (1, 'bval', None),
popen.assert_has_calls([ 'kwargs': {
call([ '_d': 'dnewval'
'sudo', '--non-interactive', '--close-from', close_from_fd, }
cfg.actions_dir + '/actions', 'tests', 'func_with_args', }),
'--write-fd', call().close()
str(write_fd) ]
], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, shell=False, pass_fds=[write_fd])
])
@patch('plinth.actions._read_from_server')
@patch('plinth.actions._request_to_server')
@patch('plinth.actions._get_privileged_action_module_name') @patch('plinth.actions._get_privileged_action_module_name')
def test_privileged_method_exceptions(get_module_name, popen): def test_privileged_method_exceptions(get_module_name, request_to_server,
read_from_server):
"""Test that exceptions on privileged methods are return properly.""" """Test that exceptions on privileged methods are return properly."""
def func_with_exception(): def func_with_exception():
raise TypeError('type error') raise TypeError('type error')
get_module_name.return_value = 'tests' get_module_name.return_value = 'tests'
popen.return_value = json.dumps({ read_from_server.return_value = {
'result': 'exception', 'result': 'exception',
'exception': { 'exception': {
'module': 'builtins', 'module': 'builtins',
'name': 'TypeError', 'name': 'TypeError',
'args': ['type error'], 'args': ['type error'],
'traceback': [''] 'traceback': [''],
'stdout': '',
'stderr': ''
} }
}) }
wrapped_func = privileged(func_with_exception) wrapped_func = privileged(func_with_exception)
with pytest.raises(TypeError, match='type error'): with pytest.raises(TypeError, match='type error'):
wrapped_func() wrapped_func()