FreedomBox/plinth/tests/test_actions.py
Sunil Mohan Adapa 1d58dcaae1
actions: Define and allow a new alias for str; secret_str
- Method parameters marked with secret_str will not be logged.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: James Valleroy <jvalleroy@mailbox.org>
2024-08-11 12:57:17 -04:00

323 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 json
import os
import subprocess
import typing
from unittest.mock import Mock, call, patch
import pytest
from plinth import actions, cfg
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._get_privileged_action_module_name')
def test_privileged_method_call(get_module_name, popen):
"""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'
popen.return_value = json.dumps({'result': 'success', 'return': 'bar'})
wrapped_func = privileged(func_with_args)
return_value = wrapped_func(1, 'bval', None, _d='dnewval')
assert return_value == 'bar'
input_ = {'args': [1, 'bval', None], 'kwargs': {'_d': 'dnewval'}}
input_ = json.dumps(input_)
write_fd = popen.called_with_write_fd[0]
close_from_fd = str(write_fd + 1)
popen.assert_has_calls([
call([
'sudo', '--non-interactive', '--close-from', close_from_fd,
cfg.actions_dir + '/actions', 'tests', 'func_with_args',
'--write-fd',
str(write_fd)
], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, shell=False, pass_fds=[write_fd])
])
@patch('plinth.actions._get_privileged_action_module_name')
def test_privileged_method_exceptions(get_module_name, popen):
"""Test that exceptions on privileged methods are return properly."""
def func_with_exception():
raise TypeError('type error')
get_module_name.return_value = 'tests'
popen.return_value = json.dumps({
'result': 'exception',
'exception': {
'module': 'builtins',
'name': 'TypeError',
'args': ['type error'],
'traceback': ['']
}
})
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])