diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bea33b..a3d0d4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,3 +38,6 @@ jobs: - name: Lint with black run: | ./ci/run-black.sh check || ( ./ci/run-black.sh diff; exit 1 ) + - name: Test with pytest + run: | + ./ci/run-pytest.sh diff --git a/action_plugins/apache_ports_generator.py b/action_plugins/apache_ports_generator.py index 24c3e1c..bdc62dd 100644 --- a/action_plugins/apache_ports_generator.py +++ b/action_plugins/apache_ports_generator.py @@ -77,13 +77,22 @@ def run(self, tmp=None, task_vars=None): vhost.get("servername", "unknown") ) raise AnsibleError(msg) from exception - except ValueError as exception: + except (TypeError, ValueError) as exception: msg = "failed to convert port '{}' of vhost '{}' to int".format( - vhost.get("servername", "unknown"), vhost.get("port", None), + vhost.get("servername", "unknown"), ) raise AnsibleError(msg) from exception + if port < 0 or port > 65535: + raise AnsibleError( + "port number '{}' of vhost '{}' is out of 0-65535 " + "range".format( + port, + vhost.get("servername", "unknown"), + ) + ) + if port not in bindings: bindings[port] = {} @@ -91,16 +100,16 @@ def run(self, tmp=None, task_vars=None): # According to documentation, https is default proto for port 443. # Therefore there is no need to specify it. if ssl and port != 443: - ssl = "https" + proto = "https" else: - ssl = "" + proto = "" listen_ip = vhost.get("listen_ip", "") if bindings[port]: # We need to check for possible numerous and various conflicts. if ( listen_ip in bindings[port] - and bindings[port][listen_ip] != ssl + and bindings[port][listen_ip] != proto ): # Reasoning: 'IP:Port' is the same and protocol is # different -> error @@ -129,7 +138,7 @@ def run(self, tmp=None, task_vars=None): ) raise AnsibleError(msg) - bindings[port][listen_ip] = ssl + bindings[port][listen_ip] = proto result["data"] = { "{}:{}:{}".format(listen_ip, port, binding[listen_ip]): { diff --git a/action_plugins/tests/test_apache_ports_generator.py b/action_plugins/tests/test_apache_ports_generator.py new file mode 100644 index 0000000..17fe453 --- /dev/null +++ b/action_plugins/tests/test_apache_ports_generator.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Unit tests for apache_ports_generator.""" +from unittest.mock import Mock + +import pytest +from ansible.errors import AnsibleError + +from action_plugins.apache_ports_generator import ActionModule # noqa + + +@pytest.mark.parametrize( + "listen_ip,port,proto,expected", + [ + ("1.2.3.4", 80, None, "1.2.3.4:80"), + ("1.2.3.4", 80, "https", "1.2.3.4:80 https"), + (None, 80, None, "80"), + (None, 80, "https", "80 https"), + ], +) +def test_apg_format_binding(listen_ip, port, proto, expected): + """Check that ActionModule._format_binding() works as expected.""" + action = ActionModule( + "task", + "connection", + "play_context", + "loader", + "templar", + "shared_loader_obj", + ) + result = action._format_binding(listen_ip, port, proto) + assert result == expected + + +@pytest.mark.parametrize( + "vhosts,expected", + [ + # No vhosts defined. + ( + [], + { + "data": { + ":443:": { + "formatted": "443", + "listen_ip": "", + "port": 443, + "proto": "", + }, + ":80:": { + "formatted": "80", + "listen_ip": "", + "port": 80, + "proto": "", + }, + } + }, + ), + # Just HTTP/80 + ( + [ + { + "port": 80, + } + ], + { + "data": { + ":80:": { + "formatted": "80", + "listen_ip": "", + "port": 80, + "proto": "", + } + } + }, + ), + # Just HTTPS/443 + ( + [ + { + "port": 443, + } + ], + { + "data": { + ":443:": { + "formatted": "443", + "listen_ip": "", + "port": 443, + "proto": "", + } + } + }, + ), + # Mix + ( + [ + { + "port": 80, + }, + { + "port": 80, + }, + { + "port": 443, + }, + { + "port": 443, + }, + { + "port": 8080, + }, + { + "port": 8081, + "ssl": {"attr": "is_irrelevant"}, + }, + { + "listen_ip": "1.2.3.4", + "port": 8082, + }, + { + "listen_ip": "1.2.3.4", + "port": 8083, + "ssl": {"attr": "is_irrelevant"}, + }, + ], + { + "data": { + ":80:": { + "formatted": "80", + "listen_ip": "", + "port": 80, + "proto": "", + }, + ":443:": { + "formatted": "443", + "listen_ip": "", + "port": 443, + "proto": "", + }, + ":8080:": { + "formatted": "8080", + "listen_ip": "", + "port": 8080, + "proto": "", + }, + ":8081:https": { + "formatted": "8081 https", + "listen_ip": "", + "port": 8081, + "proto": "https", + }, + "1.2.3.4:8082:": { + "formatted": "1.2.3.4:8082", + "listen_ip": "1.2.3.4", + "port": 8082, + "proto": "", + }, + "1.2.3.4:8083:https": { + "formatted": "1.2.3.4:8083 https", + "listen_ip": "1.2.3.4", + "port": 8083, + "proto": "https", + }, + }, + }, + ), + ], +) +def test_apg_run_happy_path(vhosts, expected): + """Test happy path in ActionModule.run().""" + # NOTE(zstyblik): mocked just enough to make it work. + mock_task = Mock() + mock_task.async_val = False + mock_task.args = {"vhosts": vhosts} + mock_conn = Mock() + mock_conn._shell.tmpdir = "/path/does/not/exist" + action = ActionModule( + mock_task, + mock_conn, + "play_context", + "loader", + "templar", + "shared_loader_obj", + ) + result = action.run(None, None) + assert result == expected + + +@pytest.mark.parametrize( + "vhosts,expected_exc,expected_exc_msg", + [ + # Port undefined + ( + [ + { + "servername": "pytest", + }, + ], + AnsibleError, + "vhost 'pytest' is missing port attribute", + ), + # Port out-of-range + ( + [ + { + "port": -1, + }, + ], + AnsibleError, + "port number '-1' of vhost 'unknown' is out of 0-65535 range", + ), + ( + [ + { + "port": 72329, + }, + ], + AnsibleError, + "port number '72329' of vhost 'unknown' is out of 0-65535 range", + ), + # Invalid port + ( + [ + { + "port": "abcefg", + }, + ], + AnsibleError, + "failed to convert port 'abcefg' of vhost 'unknown' to int", + ), + ( + [ + { + "port": None, + }, + ], + AnsibleError, + "failed to convert port 'None' of vhost 'unknown' to int", + ), + # IP/port/protocol collisions + ( + [ + { + "port": 8080, + }, + { + "port": 8080, + "ssl": {"attr": "is_irrelevant"}, + }, + ], + AnsibleError, + "HTTP/HTTPS collision for IP '' and port '8080' in vhost 'unknown'", + ), + ( + [ + { + "listen_ip": "1.2.3.4", + "port": 8080, + }, + { + "listen_ip": "1.2.3.4", + "port": 8080, + "ssl": {"attr": "is_irrelevant"}, + }, + ], + AnsibleError, + ( + "HTTP/HTTPS collision for IP '1.2.3.4' and port '8080' " + "in vhost 'unknown'" + ), + ), + ( + [ + { + "port": 8080, + }, + { + "listen_ip": "1.2.3.4", + "port": 8080, + }, + ], + AnsibleError, + ( + "bind collision any Vs. IP for IP '1.2.3.4' and port '8080' " + "in vhost 'unknown'" + ), + ), + ], +) +def test_apg_run_unhappy_path(vhosts, expected_exc, expected_exc_msg): + """Test unhappy path resp. exceptions in ActionModule.run().""" + # NOTE(zstyblik): mocked just enough to make it work. + mock_task = Mock() + mock_task.async_val = False + mock_task.args = {"vhosts": vhosts} + mock_conn = Mock() + mock_conn._shell.tmpdir = "/path/does/not/exist" + action = ActionModule( + mock_task, + mock_conn, + "play_context", + "loader", + "templar", + "shared_loader_obj", + ) + with pytest.raises(expected_exc) as exc: + _ = action.run(None, None) + + assert str(exc.value) == expected_exc_msg diff --git a/ci/run-ansible-lint.sh b/ci/run-ansible-lint.sh index d2d7561..0f92315 100755 --- a/ci/run-ansible-lint.sh +++ b/ci/run-ansible-lint.sh @@ -2,4 +2,6 @@ set -e set -u +cd "$(dirname "${0}")/.." + ansible-lint . diff --git a/ci/run-black.sh b/ci/run-black.sh index a9c0f0b..f078572 100755 --- a/ci/run-black.sh +++ b/ci/run-black.sh @@ -16,6 +16,8 @@ else exit 1 fi +cd "$(dirname "${0}")/.." + # shellcheck disable=SC2086 find . ! -path '*/\.*' -name '*.py' -print0 | \ xargs -0 -- python3 -m black ${black_arg} -l 80 diff --git a/ci/run-flake8.sh b/ci/run-flake8.sh index 708c225..00b7ee3 100755 --- a/ci/run-flake8.sh +++ b/ci/run-flake8.sh @@ -2,6 +2,8 @@ set -e set -u +cd "$(dirname "${0}")/.." + python3 -m flake8 \ . \ --ignore=W503 \ diff --git a/ci/run-pytest.sh b/ci/run-pytest.sh new file mode 100755 index 0000000..46dd56c --- /dev/null +++ b/ci/run-pytest.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -e +set -u + +cd "$(dirname "${0}")/.." + +python3 -m pytest -vv . diff --git a/ci/run-reorder-python-imports.sh b/ci/run-reorder-python-imports.sh index 040f998..010eb8f 100755 --- a/ci/run-reorder-python-imports.sh +++ b/ci/run-reorder-python-imports.sh @@ -2,5 +2,7 @@ set -e set -u +cd "$(dirname "${0}")/.." + find . ! -path '*/\.*' -name '*.py' -print0 | \ xargs -0 -- reorder-python-imports diff --git a/ci/run-yamllint.sh b/ci/run-yamllint.sh index 33eeae2..7a84140 100755 --- a/ci/run-yamllint.sh +++ b/ci/run-yamllint.sh @@ -2,4 +2,6 @@ set -e set -u -yamllint . +cd "$(dirname "${0}")/.." + +yamllint -s . diff --git a/filter_plugins/tests/test_core.py b/filter_plugins/tests/test_core.py new file mode 100644 index 0000000..eeab2d2 --- /dev/null +++ b/filter_plugins/tests/test_core.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Unit tests for filter_plugins.core.""" +import pytest +from ansible.errors import AnsibleFilterError + +import filter_plugins.core as fpc # noqa + + +def test_fpc_available_filters(): + """Check retval of FilterModule.filters().""" + expected_filters = [ + "file_exists", + "parse_a2query", + "to_vhost_filename", + ] + filter_module = fpc.FilterModule() + available_filters = filter_module.filters() + assert list(available_filters.keys()) == expected_filters + + +@pytest.mark.parametrize( + "input_data,expected", + [ + ({}, False), + ({"stat": {}}, False), + ({"stat": {"foo": "bar"}}, False), + ({"stat": {"isreg": False, "islnk": False}}, False), + ({"stat": {"isreg": True, "islnk": False}}, True), + ({"stat": {"isreg": False, "islnk": True}}, True), + ({"stat": {"isreg": True, "islnk": True}}, True), + ], +) +def test_fpc_file_exists(input_data, expected): + """Check that file_exists() works as expected.""" + result = fpc.file_exists(input_data) + assert result is expected + + +@pytest.mark.parametrize( + "input_data,expected", + [ + ( + [ + "php8.2 (enabled by maintainer script)", + "alias (enabled by maintainer script)", + "env (enabled by maintainer script)", + ], + ["php8.2", "alias", "env"], + ), + ( + "", + "", + ), + ], +) +def test_fpc_parse_a2query(input_data, expected): + """Check that parse_a2query works as expected.""" + result = fpc.parse_a2query(input_data) + assert sorted(result) == sorted(expected) + + +@pytest.mark.parametrize( + "vhost,expected_fname", + [ + ( + {"servername": "pytest"}, + "200-http_pytest", + ), + ( + {"servername": "pytest", "ssl": {"attr": "is_irrelevant"}}, + "200-https_pytest", + ), + ( + { + "servername": "pytest", + "ssl": {"attr": "is_irrelevant"}, + "priority": 400, + }, + "400-https_pytest", + ), + ], +) +def test_fpc_to_vhost_filename_happy_path(vhost, expected_fname): + """Check happy path of to_vhost_filename().""" + result = fpc.to_vhost_filename(vhost) + assert result == expected_fname + + +@pytest.mark.parametrize( + "vhost,expected_exc_msg", + [ + ( + {}, + "to_vhost_filename - 'servername'. 'servername'", + ), + ( + {"priority": "abc", "servername": "test"}, + ( + "to_vhost_filename - invalid literal for int() with base 10: " + "'abc'. invalid literal for int() with base 10: 'abc'" + ), + ), + ( + {"priority": 200}, + "to_vhost_filename - 'servername'. 'servername'", + ), + ], +) +def test_fpc_to_vhost_filename_unhappy_path(vhost, expected_exc_msg): + """Check unhappy path of to_vhost_filename().""" + with pytest.raises(AnsibleFilterError) as exc: + fpc.to_vhost_filename(vhost) + + assert str(exc.value) == expected_exc_msg diff --git a/requirements-ci.txt b/requirements-ci.txt index bf55501..0f41785 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -3,4 +3,5 @@ black==24.8.0 flake8 flake8-docstrings flake8-import-order +pytest reorder-python-imports