diff --git a/.github/workflows/build-ci.yml b/.github/workflows/build-ci.yml index 6734c0e..5e2fa88 100644 --- a/.github/workflows/build-ci.yml +++ b/.github/workflows/build-ci.yml @@ -78,3 +78,16 @@ jobs: --platform ${{ matrix.platform }} \ --image ${{ env.image_tag }} \ --version ${{ matrix.version }} + + - name: Run unit tests of entrypoint + run: | + #Run tests from tests/unit in a container + echo "apt update && apt install -y python3-pip" >> pytest_setup + echo "pip3 install pytest" >> pytest_setup + echo "pytest --verbose" >> pytest_setup + + docker run -i --platform ${{ matrix.platform }} \ + --volume $(pwd)/tests/unit:/tests \ + --workdir /tests \ + --entrypoint /bin/bash \ + ${{ env.image_tag }} < pytest_setup diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..3afed57 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,56 @@ +""" + Pytest configuration file for tests fixtures, global variables + and error messages for tests errors. + + Pytest fixtures are used to arrange and clean up tests environment. + See: https://docs.pytest.org/en/6.2.x/fixture.html +""" + +import os +import tempfile +import pytest +from hooks.entrypoint_hook import EntrypointHook +from hooks.command import Command + +def abs_path(executable): + """Format expected location of dogecoin executables in containers""" + return os.path.join(pytest.executables_folder, executable) + +def pytest_configure(): + """Declare global variables to use across tests""" + # User used for tests + pytest.user = os.environ["USER"] + + # Perform tests in a temporary directory, used as datadir + pytest.directory = tempfile.TemporaryDirectory() + pytest.datadir = pytest.directory.name + + # Location where dogecoin executables should be located + pytest.executables_folder = "/usr/local/bin" + pytest.abs_path = abs_path + +@pytest.fixture +def hook(): + """ + Prepare & cleanup EntrypointHook for tests, by disabling and restoring + entrypoint functions & system calls. + + EntrypointHook.test is then used inside a test, available as hook.test. + """ + test_hook = EntrypointHook() + yield test_hook + test_hook.reset_hooks() + +def pytest_assertrepr_compare(left, right): + """Override error messages of AssertionError on test failure.""" + # Display comparison of result command and an expected execve command + if isinstance(left, Command) and isinstance(right, Command): + assert_msg = ["fail"] + assert_msg.append("======= Result =======") + assert_msg.extend(str(left).splitlines()) + assert_msg.append("======= Expected =======") + assert_msg.extend(str(right).splitlines()) + assert_msg.append("======= Diff =======") + assert_msg.extend(left.diff(right)) + return assert_msg + return None diff --git a/tests/unit/hooks/command.py b/tests/unit/hooks/command.py new file mode 100644 index 0000000..3ec257b --- /dev/null +++ b/tests/unit/hooks/command.py @@ -0,0 +1,45 @@ +""" + Command interface for os.execve hook from EntrypointHook. + + Store arguments and environ for hooked commands, used to create + both the command of entrypoint.py called during tests, + and the expected command for test comparison. +""" + +import difflib +import json + +class CommandNotFound(Exception): + """Raised when entrypoint command is not found or test fail.""" + +class Command: + """ + Represent a single execve command, with + its arguments and environment. + + Can represent an entrypoint hooked command, the expected + result or the input of a test. + """ + def __init__(self, argv, environ): + self.argv = argv + self.environ = environ + + def __eq__(self, other): + """Compare 2 Command, result of a test and expected command.""" + return self.argv == other.argv and self.environ == other.environ + + def __str__(self): + """Render single command into string for error outputs.""" + argv_str = json.dumps(self.argv, indent=4) + command_str = f"argv: {argv_str}\n" + environ_str = json.dumps(self.environ, indent=4) + command_str += f"environ: {environ_str}" + return command_str + + def diff(self, other): + """Perform diff between result command and expected command.""" + command = str(self).splitlines() + other_command = str(other).splitlines() + + return difflib.unified_diff(command, other_command, + fromfile="result", tofile="expected", lineterm="") diff --git a/tests/unit/hooks/entrypoint_hook.py b/tests/unit/hooks/entrypoint_hook.py new file mode 100644 index 0000000..8fc1636 --- /dev/null +++ b/tests/unit/hooks/entrypoint_hook.py @@ -0,0 +1,155 @@ +""" + Hook for tests of entrypoint.py behavior. Abstract functions and system + call to catch arguments used by entrypoint, or disable unwanted functions. + + EntrypointHook.test is used to perform a single test by + calling entrypoint.main. The hook is available as a test fixture, inside + a test argument. + + Test example: + + def test_for_entrypoint(hook): + # Values to test + test_argv = [value1, ...] + test_env = {key:value, ...} + + # Expected result + result_argv = [value1, ...] + result_env = {key:value, ...} + + # Perform the test using the hook + hook.test(test_argv, test_env, result_argv, result_env) + assert hook.result == hook.reference + + Visit also pytest doc for test formatting: https://docs.pytest.org/ +""" + +import sys +import os +import shutil +import entrypoint +import hooks.help_menus +from hooks.command import Command, CommandNotFound + +class EntrypointHook: + """ + Hook to perform tests of the Dockerfile entrypoint.py. Manage all + informations about test result and expected output for test comparison. + + Hook some system calls & functions used by `entrypoint.main` to handle + commands which should have been run by the script. + Disable some function related so file permissions & creation. + + See `self._setup_hooks` for all defined hooks. + """ + # Environment to use for every tests Command & comparison Command + DEFAULT_ENV = { + "USER" : os.environ["USER"], + "PATH" : os.environ["PATH"], + } + + def __init__(self): + self.result = None + self.reference = None + + self._setup_hooks() + + def test(self, test_argv, test_environ, \ + result_argv, result_environ): + """ + Run a test of entrypoint.main and store expected result in the hook + for further comparaison. + + - self.result store entrypoint.py command launched by main + - self.reference store the expected Command for comparison. + + Stored Command objects are comparable, used for asserts. + Example: + >>> assert hook.result == hook.reference + """ + # Clean hook from previous test, store the command to test + self._reset_attributes() + + # Default environment variables needed by all tests + test_environ.update(self.DEFAULT_ENV) + result_environ.update(self.DEFAULT_ENV) + + # Manage system arguments & environment used by the script + sys.argv[1:] = test_argv.copy() + os.environ = test_environ.copy() + + # Run the test, launching entrypoint script from the main + entrypoint.main() + + # Store expected Command used for comparison + self.reference = Command(result_argv, result_environ) + + if self.result is None: + raise CommandNotFound("Test fail, do not return a command") + + def _execve_hook(self, executable, argv, environ): + """ + Hook for os.execve function, to catch arguments/environment + instead of launching processes. + """ + assert executable == argv[0] + self.result = Command(argv, environ) + + @staticmethod + def _get_help_hook(command_arguments): + """ + Hook call of executable help menu to retrieve options. + Fake a list of raw options generated by entrypoint.get_help. + """ + executable = command_arguments[0] + + #Test use of -help-debug to expand help options + if executable == "dogecoind": + assert "-help-debug" in command_arguments + else: + assert "-help-debug" not in command_arguments + return getattr(hooks.help_menus, executable.replace("-", "_")) + + def _reset_attributes(self): + """Clean state between each test""" + self.result = None + self.reference = None + + def _setup_hooks(self): + """ + Enable hooks of entrypoint.py system & functions calls, disable + some functions. + + Replace entrypoint function by EntrypointHook methods + to handle arguments used by entrypoint calls. + + Save references to previous functions to restore them test + clean up. + """ + # Save hooked functions for restoration + self._execve_backup = os.execve + self._setgid_backup = os.setgid + self._setuid_backup = os.setuid + self._which_backup = shutil.which + self._get_help_backup = entrypoint.get_help + + # Setup hooks + # Add execve hook globally to catch entrypoint arguments + os.execve = self._execve_hook + # Hook executables call to `-help` menus to fake options + entrypoint.get_help = self._get_help_hook + + # which not working from host without dogecoin executables in PATH + shutil.which = lambda executable : f"/usr/local/bin/{executable}" + + # Disable setgid & setuid behavior + os.setgid = lambda _ : None + os.setuid = lambda _ : None + + def reset_hooks(self): + """Restore hooks of `self._setup_hooks` to initial functions""" + os.execve = self._execve_backup + os.setgid = self._setgid_backup + os.setuid = self._setuid_backup + shutil.which = self._which_backup + entrypoint.get_help = self._get_help_backup diff --git a/tests/unit/hooks/help_menus.py b/tests/unit/hooks/help_menus.py new file mode 100644 index 0000000..9120c0f --- /dev/null +++ b/tests/unit/hooks/help_menus.py @@ -0,0 +1,171 @@ +""" + Store outputs of `-help` menus of all Dogecoin Core executable. + Lists of raw options used to hook `entrypoint.get_help` function. +""" + +dogecoin_cli = [ + ' -conf=', + ' -datadir=', + ' -testnet', + ' -regtest', + ' -named', + ' -rpcconnect=', + ' -rpcport=', + ' -rpcwait', + ' -rpcuser=', + ' -rpcpassword=', + ' -rpcclienttimeout=', + ' -stdin' + ] + +dogecoin_tx = [ + ' -create', + ' -json', + ' -txid', + ' -testnet', + ' -regtest' + ] + +dogecoind = [ + ' -version', + ' -alerts', + ' -alertnotify=', + ' -blocknotify=', + ' -blocksonly', + ' -assumevalid=', + ' -conf=', + ' -daemon', + ' -datadir=', + ' -dbcache=', + ' -feefilter', + ' -loadblock=', + ' -maxorphantx=', + ' -maxmempool=', + ' -mempoolexpiry=', + ' -blockreconstructionextratxn=', + ' -par=', + ' -pid=', + ' -prune=', + ' -reindex-chainstate', + ' -reindex', + ' -sysperms', + ' -txindex', + ' -addnode=', + ' -banscore=', + ' -bantime=', + ' -bind=', + ' -connect=', + ' -discover', + ' -dns', + ' -dnsseed', + ' -externalip=', + ' -forcednsseed', + ' -listen', + ' -listenonion', + ' -maxconnections=', + ' -maxreceivebuffer=', + ' -maxsendbuffer=', + ' -maxtimeadjustment', + ' -onion=', + ' -onlynet=', + ' -permitbaremultisig', + ' -peerbloomfilters', + ' -port=', + ' -proxy=', + ' -proxyrandomize', + ' -rpcserialversion', + ' -seednode=', + ' -timeout=', + ' -torcontrol=:', + ' -torpassword=', + ' -upnp', + ' -whitebind=', + ' -whitelist=', + ' -whitelistrelay', + ' -whitelistforcerelay', + ' -maxuploadtarget=', + ' -disablewallet', + ' -keypool=', + ' -fallbackfee=', + ' -mintxfee=', + ' -paytxfee=', + ' -rescan', + ' -salvagewallet', + ' -sendfreetransactions', + ' -spendzeroconfchange', + ' -txconfirmtarget=', + ' -usehd', + ' -walletrbf', + ' -upgradewallet', + ' -wallet=', + ' -walletbroadcast', + ' -walletnotify=', + ' -zapwallettxes=', + ' -dblogsize=', + ' -flushwallet', + ' -privdb', + ' -walletrejectlongchains', + ' -zmqpubhashblock=
', + ' -zmqpubhashtx=
', + ' -zmqpubrawblock=
', + ' -zmqpubrawtx=
', + ' -uacomment=', + ' -checkblocks=', + ' -checklevel=', + ' -checkblockindex', + ' -checkmempool=', + ' -checkpoints', + ' -disablesafemode', + ' -testsafemode', + ' -dropmessagestest=', + ' -fuzzmessagestest=', + ' -stopafterblockimport', + ' -limitancestorcount=', + ' -limitancestorsize=', + ' -limitdescendantcount=', + ' -limitdescendantsize=', + ' -bip9params=deployment:start:end', + ' -debug=', + ' -nodebug', + ' -help-debug', + ' -logips', + ' -logtimestamps', + ' -logtimemicros', + ' -mocktime=', + ' -limitfreerelay=', + ' -relaypriority', + ' -maxsigcachesize=', + ' -maxtipage=', + ' -minrelaytxfee=', + ' -maxtxfee=', + ' -printtoconsole', + ' -printpriority', + ' -shrinkdebugfile', + ' -testnet', + ' -regtest', + ' -acceptnonstdtxn', + ' -incrementalrelayfee=', + ' -dustrelayfee=', + ' -dustlimit=', + ' -bytespersigop', + ' -datacarrier', + ' -datacarriersize', + ' -mempoolreplacement', + ' -blockmaxweight=', + ' -blockmaxsize=', + ' -blockprioritysize=', + ' -blockmintxfee=', + ' -blockversion=', + ' -server', + ' -rest', + ' -rpcbind=', + ' -rpccookiefile=', + ' -rpcuser=', + ' -rpcpassword=', + ' -rpcauth=', + ' -rpcport=', + ' -rpcallowip=', + ' -rpcthreads=', + ' -rpcworkqueue=', + ' -rpcservertimeout=' + ] diff --git a/tests/unit/test_arguments.py b/tests/unit/test_arguments.py new file mode 100644 index 0000000..22f0dd6 --- /dev/null +++ b/tests/unit/test_arguments.py @@ -0,0 +1,76 @@ +""" + Tests if argv arguments are passed properly. +""" + +import pytest + +def test_arguments(hook): + """Verifying arguments are being kept appropriatly""" + # Verify arguments with values + test_args = ["dogecoind", "-maxconnections=150", "-paytxfee=0.01"] + test_env = { + "DATADIR" : pytest.datadir, + } + + result_args = [ + pytest.abs_path("dogecoind"), + f"-datadir={pytest.datadir}", + "-maxconnections=150", + "-paytxfee=0.01", + "-printtoconsole", + ] + result_env = {} + hook.test(test_args, test_env, result_args, result_env) + assert hook.result == hook.reference + + # Verify arguments without values + test_args = ["dogecoind", "-daemon", "-testnet"] + test_env = { + "DATADIR" : pytest.datadir, + } + + result_args = [ + pytest.abs_path("dogecoind"), + f"-datadir={pytest.datadir}", + "-daemon", + "-testnet", + "-printtoconsole", + ] + result_env = {} + hook.test(test_args, test_env, result_args, result_env) + assert hook.result == hook.reference + + # Mixing arguments with and without values + test_args = ["dogecoind", "-daemon", "-maxconnections=150"] + test_env = { + "DATADIR" : pytest.datadir, + } + + result_args = [ + pytest.abs_path("dogecoind"), + f"-datadir={pytest.datadir}", + "-daemon", + "-maxconnections=150", + "-printtoconsole", + ] + result_env = {} + hook.test(test_args, test_env, result_args, result_env) + assert hook.result == hook.reference + +def test_arguments_double_dash(hook): + """Check arguments formates with double-dash like `--testnet`""" + test_args = ["dogecoind", "--maxconnections=150", "--paytxfee=0.01"] + test_env = { + "DATADIR" : pytest.datadir, + } + + result_args = [ + pytest.abs_path("dogecoind"), + f"-datadir={pytest.datadir}", + "--maxconnections=150", + "--paytxfee=0.01", + "-printtoconsole", + ] + result_env = {} + hook.test(test_args, test_env, result_args, result_env) + assert hook.result == hook.reference diff --git a/tests/unit/test_environment.py b/tests/unit/test_environment.py new file mode 100644 index 0000000..4dd0a30 --- /dev/null +++ b/tests/unit/test_environment.py @@ -0,0 +1,48 @@ +""" + Tests if environment variable alone are converted into + arguments by entrypoint. +""" + +import pytest + +def test_environment(hook): + """ + Verify if environment is converted to arguments, + control that arguments are removed from the environment. + """ + # Control environment variables with values + test_args = ["dogecoind"] + test_env = { + "DATADIR" : pytest.datadir, + "MAXCONNECTIONS" : "150", + "PAYTXFEE" : "0.01" + } + + result_args = [ + pytest.abs_path("dogecoind"), + f"-datadir={pytest.datadir}", + "-maxconnections=150", + "-paytxfee=0.01", + "-printtoconsole", + ] + result_env = {} + hook.test(test_args, test_env, result_args, result_env) + assert hook.result == hook.reference + + # Control environment variables with empty values + test_env = { + "DATADIR" : pytest.datadir, + "TESTNET" : "", + "DAEMON" : "", + } + + result_args = [ + pytest.abs_path("dogecoind"), + "-daemon", + f"-datadir={pytest.datadir}", + "-testnet", + "-printtoconsole", + ] + result_env = {} + hook.test(test_args, test_env, result_args, result_env) + assert hook.result == hook.reference diff --git a/tests/unit/test_environment_hyphen.py b/tests/unit/test_environment_hyphen.py new file mode 100644 index 0000000..a2bd6a5 --- /dev/null +++ b/tests/unit/test_environment_hyphen.py @@ -0,0 +1,28 @@ +""" + Tests environment variables containing an hyphen (-). + + Special case for environment conversion. +""" + +import pytest + +def test_environment_hyphen(hook): + """ + Test option with dash like `-help-debug` if working + properly in environment. + """ + test_args = ["dogecoind"] + test_env = { + "DATADIR" : pytest.datadir, + "HELP_DEBUG" : "", + } + + result_args = [ + pytest.abs_path("dogecoind"), + f"-datadir={pytest.datadir}", + "-help-debug", + "-printtoconsole", + ] + result_env = {} + hook.test(test_args, test_env, result_args, result_env) + assert hook.result == hook.reference diff --git a/tests/unit/test_executables.py b/tests/unit/test_executables.py new file mode 100644 index 0000000..a342134 --- /dev/null +++ b/tests/unit/test_executables.py @@ -0,0 +1,52 @@ +""" + Test if executables are found properly by entrypoint. +""" + +import pytest + +def test_entrypoint_executables(hook): + """ + Basic test without configuration to check + if entrypoint run each dogecoin executables. + """ + # Constant variable for test + test_environ = { + "DATADIR" : pytest.datadir, + } + + result_environ = {} + + # Test basic command with `dogecoind` + test_args = ["dogecoind"] + + result_args = [ + pytest.abs_path("dogecoind"), + f"-datadir={pytest.datadir}", + "-printtoconsole", + ] + hook.test(test_args, test_environ, result_args, result_environ) + assert hook.result == hook.reference + + # Test empty command with `dogecoin-cli` + test_args = ["dogecoin-cli"] + + result_args = [ + pytest.abs_path("dogecoin-cli"), + f"-datadir={pytest.datadir}", + ] + + hook.test(test_args, test_environ, result_args, result_environ) + assert hook.result == hook.reference + + # Test basic command with `dogecoin-tx` + tx_result_env = { + "DATADIR" : pytest.datadir, + } + + test_args = ["dogecoin-tx"] + + result_args = [ + pytest.abs_path("dogecoin-tx"), + ] + hook.test(test_args, test_environ, result_args, tx_result_env) + assert hook.result == hook.reference diff --git a/tests/unit/test_extended_options.py b/tests/unit/test_extended_options.py new file mode 100644 index 0000000..6c6c335 --- /dev/null +++ b/tests/unit/test_extended_options.py @@ -0,0 +1,49 @@ +""" + Tests if extended help menus are available as environment variables. + + Control if options from `-help-debug` are available. +""" + +import pytest + +def test_extended_options(hook): + """Verify dogecoind environment variables from `-help-debug` options""" + test_args = ["dogecoind"] + test_env = { + "DATADIR" : pytest.datadir, + "REGTEST" : "1", + "SENDFREETRANSACTIONS" : "", + "CHECKBLOCKS":"420", + } + + result_args = [ + pytest.abs_path("dogecoind"), + f"-datadir={pytest.datadir}", + "-sendfreetransactions", + "-checkblocks=420", + "-regtest=1", + "-printtoconsole", + ] + result_env = {} + hook.test(test_args, test_env, result_args, result_env) + assert hook.result == hook.reference + +def test_invalid_extended(hook): + """Control `-help-debug` options not extended with other executables""" + test_args = ["dogecoin-cli"] + test_env = { + "DATADIR" : pytest.datadir, + "SENDFREETRANSACTIONS" : "", + "CHECKBLOCKS":"420", + } + + result_args = [ + pytest.abs_path("dogecoin-cli"), + f"-datadir={pytest.datadir}", + ] + result_env = { + "SENDFREETRANSACTIONS" : "", + "CHECKBLOCKS":"420", + } + hook.test(test_args, test_env, result_args, result_env) + assert hook.result == hook.reference diff --git a/tests/unit/test_mix_argument_environment.py b/tests/unit/test_mix_argument_environment.py new file mode 100644 index 0000000..cd33118 --- /dev/null +++ b/tests/unit/test_mix_argument_environment.py @@ -0,0 +1,68 @@ +""" + Tests formatting of commands using environment variables + arguments. +""" + +import pytest + +def test_mixing_argument_and_env(hook): + """Configure container with arguments and environment variables""" + test_args = ["dogecoind", "-maxconnections=150", "-daemon"] + test_env = { + "DATADIR" : pytest.datadir, + "TESTNET" : "", + } + + result_args = [ + pytest.abs_path("dogecoind"), + f"-datadir={pytest.datadir}", + "-testnet", + "-maxconnections=150", + "-daemon", + "-printtoconsole", + ] + result_env = {} + hook.test(test_args, test_env, result_args, result_env) + assert hook.result == hook.reference + +def test_equal_argv_and_env(hook): + """Check arguments and environment with identical variables""" + test_args = ["dogecoind", "-maxconnections=150", "-daemon"] + test_env = { + "DATADIR" : pytest.datadir, + "MAXCONNECTIONS" : "150", + "DAEMON" : "", + } + + result_args = [ + pytest.abs_path("dogecoind"), + "-daemon", + f"-datadir={pytest.datadir}", + "-maxconnections=150", + "-maxconnections=150", + "-daemon", + "-printtoconsole", + ] + result_env = {} + hook.test(test_args, test_env, result_args, result_env) + assert hook.result == hook.reference + + #Same variable with different value for env & arguments. + test_args = ["dogecoind", "-maxconnections=130", "-daemon"] + test_env = { + "DATADIR" : pytest.datadir, + "MAXCONNECTIONS" : "150", + "DAEMON" : "1", + } + + result_args = [ + pytest.abs_path("dogecoind"), + "-daemon=1", + f"-datadir={pytest.datadir}", + "-maxconnections=150", + "-maxconnections=130", + "-daemon", + "-printtoconsole", + ] + result_env = {} + hook.test(test_args, test_env, result_args, result_env) + assert hook.result == hook.reference