Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add test coverage for entrypoint #25

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/build-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,16 @@ jobs:
--platform ${{ matrix.platform }} \
--image ${{ env.image_tag }} \
--version ${{ matrix.version }}

- name: Run unit tests of entrypoint
patricklodder marked this conversation as resolved.
Show resolved Hide resolved
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
56 changes: 56 additions & 0 deletions tests/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -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
45 changes: 45 additions & 0 deletions tests/unit/hooks/command.py
Original file line number Diff line number Diff line change
@@ -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="")
155 changes: 155 additions & 0 deletions tests/unit/hooks/entrypoint_hook.py
Original file line number Diff line number Diff line change
@@ -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
Loading