From 5becd2c31b814d50f93bc0ac63e29f3e1aa78fea Mon Sep 17 00:00:00 2001 From: ArcticDev78 <131583057+ArcticDev78@users.noreply.github.com> Date: Sat, 2 May 2026 23:41:29 +0530 Subject: [PATCH] Add pytests and GitHub Actions testing workflow --- .github/workflows/tests.yml | 28 ++++ pytest.ini | 4 + requirements-dev.txt | 2 + tests/__init__.py | 0 tests/test_module_registry.py | 182 +++++++++++++++++++++++ tests/test_secure_utils.py | 271 ++++++++++++++++++++++++++++++++++ tests/test_target.py | 97 ++++++++++++ 7 files changed, 584 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_module_registry.py create mode 100644 tests/test_secure_utils.py create mode 100644 tests/test_target.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..7bc4b43 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,28 @@ +name: Tests + +on: + push: + branches: ["master", "main"] + pull_request: + branches: ["master", "main"] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Run tests + run: pytest diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..943595d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +pythonpath = . +testpaths = tests +addopts = -v --tb=short diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..661d893 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest>=8.0 +pytest-cov>=5.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_module_registry.py b/tests/test_module_registry.py new file mode 100644 index 0000000..b5a1f10 --- /dev/null +++ b/tests/test_module_registry.py @@ -0,0 +1,182 @@ +""" +Tests for utils/module_registry.py + +Covers: + - list_modules() returns all 10 expected names in consistent order + - load_module_attr() raises ModuleRegistryError for unknown names + - run_module() returns (False, non-empty str) for unknown names + - get_module_metadata() returns graceful fallback for unknown names + - clear_caches() runs without error + - get_module_metadata() returns correct full_name for known modules + +NOTE: Real modules (network-scanner, port-scanner, etc.) are NOT invoked +because they call nmap / network resources. Only metadata and error-paths +are exercised. +""" + +import pytest + +from utils.module_registry import ( + MODULE_MAP, + ModuleRegistryError, + clear_caches, + get_module_metadata, + list_modules, + load_module_attr, + run_module, +) + +# --------------------------------------------------------------------------- +# Expected module names (order matters — same as MODULE_MAP insertion order) +# --------------------------------------------------------------------------- + +EXPECTED_MODULES = [ + "network-scanner", + "device-info", + "os-guesser", + "oui-lookup", + "port-scanner", + "dos", + "ping", + "vuln-scanner", + "custom", + "auto", +] + + +# --------------------------------------------------------------------------- +# Fixture: clear all caches before every test to prevent cross-test pollution +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def fresh_caches(): + """Clear registry caches before each test.""" + clear_caches() + yield + clear_caches() + + +# --------------------------------------------------------------------------- +# list_modules +# --------------------------------------------------------------------------- + + +def test_list_modules_contains_all_expected_names(): + modules = list_modules() + for name in EXPECTED_MODULES: + assert name in modules, f"Expected module '{name}' missing from list_modules()" + + +def test_list_modules_has_correct_count(): + assert len(list_modules()) == 10 + + +def test_list_modules_order_is_consistent(): + """Calling list_modules() twice must return the same ordered list.""" + first = list_modules() + second = list_modules() + assert first == second + + +def test_list_modules_matches_expected_order(): + """The returned list must exactly match the insertion order of MODULE_MAP.""" + assert list_modules() == EXPECTED_MODULES + + +# --------------------------------------------------------------------------- +# load_module_attr — unknown name +# --------------------------------------------------------------------------- + + +def test_load_module_attr_unknown_raises_registry_error(): + with pytest.raises(ModuleRegistryError): + load_module_attr("unknown-module") + + +def test_load_module_attr_error_message_contains_name(): + with pytest.raises(ModuleRegistryError, match="unknown-module"): + load_module_attr("unknown-module") + + +# --------------------------------------------------------------------------- +# run_module — unknown name +# --------------------------------------------------------------------------- + + +def test_run_module_unknown_returns_false(): + success, error = run_module("unknown-module") + assert success is False + + +def test_run_module_unknown_returns_nonempty_error_string(): + _, error = run_module("unknown-module") + assert isinstance(error, str) + assert len(error) > 0 + + +def test_run_module_unknown_error_mentions_name(): + _, error = run_module("unknown-module") + assert "unknown-module" in error + + +# --------------------------------------------------------------------------- +# get_module_metadata — unknown name (graceful fallback) +# --------------------------------------------------------------------------- + + +def test_get_module_metadata_unknown_returns_dict(): + meta = get_module_metadata("unknown-module") + assert isinstance(meta, dict) + + +def test_get_module_metadata_unknown_has_required_keys(): + meta = get_module_metadata("unknown-module") + assert "full_name" in meta + assert "description" in meta + assert "options" in meta + + +def test_get_module_metadata_unknown_full_name_is_string(): + meta = get_module_metadata("unknown-module") + assert isinstance(meta["full_name"], str) + + +# --------------------------------------------------------------------------- +# clear_caches +# --------------------------------------------------------------------------- + + +def test_clear_caches_runs_without_error(): + """clear_caches() must complete without raising any exception.""" + clear_caches() # already called by fixture; call again explicitly + + +def test_clear_caches_can_be_called_multiple_times(): + clear_caches() + clear_caches() + clear_caches() + + +# --------------------------------------------------------------------------- +# get_module_metadata — known modules (metadata correctness) +# --------------------------------------------------------------------------- + + +def test_get_module_metadata_port_scanner_full_name(): + meta = get_module_metadata("port-scanner") + assert meta["full_name"] == "Port Scanner" + + +def test_get_module_metadata_os_guesser_full_name(): + meta = get_module_metadata("os-guesser") + assert meta["full_name"] == "OS Guesser" + + +def test_get_module_metadata_known_module_has_all_keys(): + """Every known module's metadata dict must have the three standard keys.""" + for name in EXPECTED_MODULES: + meta = get_module_metadata(name) + assert "full_name" in meta, f"'full_name' missing for module '{name}'" + assert "description" in meta, f"'description' missing for module '{name}'" + assert "options" in meta, f"'options' missing for module '{name}'" diff --git a/tests/test_secure_utils.py b/tests/test_secure_utils.py new file mode 100644 index 0000000..cf443ff --- /dev/null +++ b/tests/test_secure_utils.py @@ -0,0 +1,271 @@ +""" +Tests for utils/secure_utils.py + +Covers: + - validate_ip_address + - validate_ip_range + - validate_port + - validate_hostname + - get_privilege_prefix + - run_user_command +""" + +import shlex +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from utils.secure_utils import ( + get_privilege_prefix, + run_user_command, + validate_hostname, + validate_ip_address, + validate_ip_range, + validate_port, +) + +# --------------------------------------------------------------------------- +# validate_ip_address +# --------------------------------------------------------------------------- + + +class TestValidateIpAddress: + @pytest.mark.parametrize( + "ip", + [ + "192.168.1.1", + "10.0.0.1", + "255.255.255.255", + "0.0.0.0", + ], + ) + def test_valid_ipv4(self, ip): + assert validate_ip_address(ip) is True + + @pytest.mark.parametrize( + "ip", + [ + "::1", + "2001:db8::1", + ], + ) + def test_valid_ipv6(self, ip): + assert validate_ip_address(ip) is True + + @pytest.mark.parametrize( + "ip", + [ + "999.999.999.999", + "not_an_ip", + "", + "192.168.1", + "192.168.1.1.1", + ], + ) + def test_invalid_ip(self, ip): + assert validate_ip_address(ip) is False + + +# --------------------------------------------------------------------------- +# validate_ip_range +# --------------------------------------------------------------------------- + + +class TestValidateIpRange: + # --- CIDR --- + + @pytest.mark.parametrize( + "cidr", + [ + "192.168.0.0/24", + "10.0.0.0/8", + ], + ) + def test_valid_private_cidr(self, cidr): + assert validate_ip_range(cidr, restrict_to_private=True) is True + + def test_public_cidr_rejected_when_restricted(self): + assert validate_ip_range("8.8.8.0/24", restrict_to_private=True) is False + + def test_public_cidr_accepted_when_unrestricted(self): + assert validate_ip_range("8.8.8.0/24", restrict_to_private=False) is True + + # --- Dash range --- + + def test_valid_private_dash_range(self): + assert ( + validate_ip_range("192.168.1.1-192.168.1.10", restrict_to_private=True) + is True + ) + + @pytest.mark.parametrize( + "ip_range", + [ + "192.168.1.10-192.168.1.1", # start > end + "192.168.1.5-192.168.1.5", # start == end + ], + ) + def test_invalid_dash_range_start_gte_end(self, ip_range): + assert validate_ip_range(ip_range) is False + + @pytest.mark.parametrize( + "ip_range", + [ + "not_a_range", + "192.168.1.1", + "", + ], + ) + def test_invalid_range_format(self, ip_range): + assert validate_ip_range(ip_range) is False + + def test_mixed_ip_version_dash_range(self): + """An IPv4 start and IPv6 end must be rejected regardless of restrict flag.""" + assert validate_ip_range("192.168.1.1-::1", restrict_to_private=False) is False + assert validate_ip_range("192.168.1.1-::1", restrict_to_private=True) is False + + +# --------------------------------------------------------------------------- +# validate_port +# --------------------------------------------------------------------------- + + +class TestValidatePort: + @pytest.mark.parametrize("port", [1024, 8080, 65535]) + def test_valid_unprivileged(self, port): + assert validate_port(port, allow_privileged=False) is True + + @pytest.mark.parametrize("port", [1, 80, 443, 1023]) + def test_invalid_unprivileged_too_low(self, port): + assert validate_port(port, allow_privileged=False) is False + + @pytest.mark.parametrize("port", [1, 80, 443]) + def test_valid_privileged(self, port): + assert validate_port(port, allow_privileged=True) is True + + @pytest.mark.parametrize("port", [0, 65536, -1]) + def test_out_of_range(self, port): + assert validate_port(port, allow_privileged=True) is False + + def test_string_valid(self): + assert validate_port("8080") is True + + def test_string_invalid(self): + assert validate_port("abc") is False + + def test_none_invalid(self): + assert validate_port(None) is False # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# validate_hostname +# --------------------------------------------------------------------------- + + +class TestValidateHostname: + @pytest.mark.parametrize( + "hostname", + [ + "example.com", + "sub.example.com", + "my-host.local", + ], + ) + def test_valid_hostnames(self, hostname): + assert validate_hostname(hostname) is True + + def test_valid_with_trailing_dot(self): + assert validate_hostname("example.com.", allow_trailing_dot=True) is True + + def test_empty_string_returns_false(self): + assert validate_hostname("") is False + + def test_label_too_long(self): + long_label = "a" * 64 # 64 chars → exceeds 63-char max + hostname = f"{long_label}.com" + assert validate_hostname(hostname) is False + + def test_hostname_too_long(self): + # Build a hostname that is 256 characters total + label = "a" * 63 # 63 chars + # "aaaa...aaa.aaaa...aaa.aaaa...aaa.aaaa...aaa" — 4×63 + 3 dots = 255, add one more char + filler = ".".join(["a" * 63] * 3) # 63+1+63+1+63 = 191 chars + hostname = filler + "." + "b" * (256 - len(filler) - 1) + assert len(hostname) > 255 + assert validate_hostname(hostname) is False + + def test_leading_hyphen_in_label(self): + assert validate_hostname("-invalid.com") is False + + def test_trailing_hyphen_in_label(self): + assert validate_hostname("invalid-.com") is False + + def test_underscore_rejected_by_default(self): + assert validate_hostname("my_host.com", allow_underscores=False) is False + + def test_underscore_allowed_when_permitted(self): + assert validate_hostname("my_host.com", allow_underscores=True) is True + + +# --------------------------------------------------------------------------- +# get_privilege_prefix +# --------------------------------------------------------------------------- + + +class TestGetPrivilegePrefix: + def test_windows_returns_empty_list(self): + with patch("utils.secure_utils.os.name", "nt"): + result = get_privilege_prefix() + assert result == [] + + def test_posix_returns_sudo(self): + with patch("utils.secure_utils.os.name", "posix"): + result = get_privilege_prefix() + assert result == ["sudo"] + + +# --------------------------------------------------------------------------- +# run_user_command +# --------------------------------------------------------------------------- + + +class TestRunUserCommand: + def test_list_cmd_calls_subprocess_with_list_and_shell_false(self): + """When cmd is a list, subprocess.run should be called with shell=False and the list.""" + cmd = ["echo", "hello"] + mock_result = MagicMock(spec=subprocess.CompletedProcess) + + with patch( + "utils.secure_utils.subprocess.run", return_value=mock_result + ) as mock_run: + result = run_user_command(cmd) + + mock_run.assert_called_once() + call_args, call_kwargs = mock_run.call_args + # First positional arg is the command list + assert call_args[0] == ["echo", "hello"] + assert call_kwargs.get("shell") is False + assert result is mock_result + + def test_string_cmd_is_shlex_split_and_shell_false(self): + """When cmd is a string and use_shell=False, it should be split via shlex.""" + cmd_str = "ping -c 1 192.168.1.1" + expected_args = shlex.split(cmd_str) + mock_result = MagicMock(spec=subprocess.CompletedProcess) + + with patch( + "utils.secure_utils.subprocess.run", return_value=mock_result + ) as mock_run: + result = run_user_command(cmd_str, use_shell=False) + + mock_run.assert_called_once() + call_args, call_kwargs = mock_run.call_args + assert call_args[0] == expected_args + assert call_kwargs.get("shell") is False + assert result is mock_result + + def test_use_shell_true_with_list_raises_value_error(self): + """Passing use_shell=True together with a list cmd must raise ValueError.""" + with pytest.raises(ValueError): + run_user_command(["echo", "hello"], use_shell=True) diff --git a/tests/test_target.py b/tests/test_target.py new file mode 100644 index 0000000..f16bfc5 --- /dev/null +++ b/tests/test_target.py @@ -0,0 +1,97 @@ +""" +Tests for utils/target.py + +Covers: + - Default value of _current_target is False + - get_target() / set_target() round-trip + - Resetting the target to False + - Various valid string targets +""" + +import pytest + +import utils.target as target_module +from utils.target import get_target, set_target + + +@pytest.fixture(autouse=True) +def reset_target(): + """Ensure _current_target is reset to False before *and* after every test.""" + set_target(False) + yield + set_target(False) + + +# --------------------------------------------------------------------------- +# Initial state +# --------------------------------------------------------------------------- + + +def test_initial_value_is_false(): + """After a reset the target must be exactly False (not just falsy).""" + assert get_target() is False + + +# --------------------------------------------------------------------------- +# set_target / get_target round-trip +# --------------------------------------------------------------------------- + + +def test_set_and_get_ip_address(): + set_target("192.168.1.1") + assert get_target() == "192.168.1.1" + + +def test_set_false_resets_target(): + set_target("10.0.0.1") + assert get_target() == "10.0.0.1" + set_target(False) + assert get_target() is False + + +@pytest.mark.parametrize( + "target_value", + [ + "10.0.0.1", + "hostname.local", + "example.com", + ], +) +def test_set_various_valid_strings(target_value): + set_target(target_value) + assert get_target() == target_value + + +# --------------------------------------------------------------------------- +# Module-level variable is updated in-place +# --------------------------------------------------------------------------- + + +def test_module_level_variable_reflects_set(): + """Accessing _current_target on the module directly should match get_target().""" + set_target("scanner.local") + assert target_module._current_target == "scanner.local" + assert target_module._current_target == get_target() + + +def test_module_level_variable_reset_to_false(): + set_target("scanner.local") + set_target(False) + assert target_module._current_target is False + + +# --------------------------------------------------------------------------- +# Successive calls overwrite previous value +# --------------------------------------------------------------------------- + + +def test_successive_set_overwrites(): + set_target("first.host") + set_target("second.host") + assert get_target() == "second.host" + + +def test_set_empty_string(): + """An empty string is a falsy but valid string value — must be stored as-is.""" + set_target("") + assert get_target() == ""