diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..19fe6ad --- /dev/null +++ b/tests/cli/__init__.py @@ -0,0 +1 @@ +"""Tests for quickxss.cli module.""" diff --git a/tests/cli/test_cli.py b/tests/cli/test_cli.py new file mode 100644 index 0000000..c84c625 --- /dev/null +++ b/tests/cli/test_cli.py @@ -0,0 +1,229 @@ +"""Unit tests for quickxss.cli module.""" + +from __future__ import annotations + +from importlib import import_module +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from quickxss.models.scan import ScanResult +from quickxss.models.setup import SetupReport +from quickxss.scan.errors import DependencyError, ToolError, ValidationError + +app_module = import_module("quickxss.cli.app") +scan_module = import_module("quickxss.cli.scan") +setup_cli_module = import_module("quickxss.cli.setup") +setup_runner = import_module("quickxss.setup.runner") + + +@pytest.fixture +def cli_runner(): + """Create a CLI runner.""" + return CliRunner() + + +def test_cli_requires_domain(cli_runner) -> None: + """Scan without domain should fail.""" + result = cli_runner.invoke(app_module.app, ["scan"]) + assert result.exit_code != 0 + + +def test_cli_success(monkeypatch, cli_runner) -> None: + """Successful scan should show summary.""" + + def fake_run_scan(config, logger): + return ScanResult( + total_urls=1, + candidate_urls=1, + findings=0, + results_file=Path("/tmp/results.txt"), + ) + + monkeypatch.setattr(scan_module, "run_scan", fake_run_scan) + with cli_runner.isolated_filesystem(): + result = cli_runner.invoke(app_module.app, ["scan", "-d", "example.com"]) + assert result.exit_code == 0 + assert "Summary" in result.stdout + + +def test_cli_displays_banner(monkeypatch, cli_runner) -> None: + """Scan should display banner unless quiet.""" + + def fake_run_scan(config, logger): + return ScanResult( + total_urls=1, + candidate_urls=1, + findings=0, + results_file=Path("/tmp/results.txt"), + ) + + monkeypatch.setattr(scan_module, "run_scan", fake_run_scan) + with cli_runner.isolated_filesystem(): + result = cli_runner.invoke(app_module.app, ["scan", "-d", "example.com"]) + assert result.exit_code == 0 + assert "XSS" in result.stdout or "___" in result.stdout + + +def test_cli_quiet_suppresses_output(monkeypatch, cli_runner) -> None: + """Scan with --quiet should suppress output.""" + + def fake_run_scan(config, logger): + return ScanResult( + total_urls=1, + candidate_urls=1, + findings=0, + results_file=Path("/tmp/results.txt"), + ) + + monkeypatch.setattr(scan_module, "run_scan", fake_run_scan) + with cli_runner.isolated_filesystem(): + result = cli_runner.invoke( + app_module.app, ["scan", "-d", "example.com", "--quiet"] + ) + assert result.exit_code == 0 + assert result.stdout == "" + + +def test_cli_validation_error_exit_code_2(monkeypatch, cli_runner) -> None: + """Validation error should exit with code 2.""" + + def fake_run_scan(config, logger): + raise ValidationError("Invalid domain") + + monkeypatch.setattr(scan_module, "run_scan", fake_run_scan) + with cli_runner.isolated_filesystem(): + result = cli_runner.invoke(app_module.app, ["scan", "-d", "example.com"]) + assert result.exit_code == 2 + + +def test_cli_dependency_error_exit_code_3(monkeypatch, cli_runner) -> None: + """Dependency error should exit with code 3.""" + + def fake_run_scan(config, logger): + raise DependencyError("Missing gf") + + monkeypatch.setattr(scan_module, "run_scan", fake_run_scan) + with cli_runner.isolated_filesystem(): + result = cli_runner.invoke(app_module.app, ["scan", "-d", "example.com"]) + assert result.exit_code == 3 + + +def test_cli_tool_error_exit_code_4(monkeypatch, cli_runner) -> None: + """Tool error should exit with code 4.""" + + def fake_run_scan(config, logger): + raise ToolError("dalfox failed") + + monkeypatch.setattr(scan_module, "run_scan", fake_run_scan) + with cli_runner.isolated_filesystem(): + result = cli_runner.invoke(app_module.app, ["scan", "-d", "example.com"]) + assert result.exit_code == 4 + + +def test_cli_unexpected_error_exit_code_1(monkeypatch, cli_runner) -> None: + """Unexpected error should exit with code 1.""" + + def fake_run_scan(config, logger): + raise RuntimeError("Unexpected error") + + monkeypatch.setattr(scan_module, "run_scan", fake_run_scan) + with cli_runner.isolated_filesystem(): + result = cli_runner.invoke(app_module.app, ["scan", "-d", "example.com"]) + assert result.exit_code == 1 + + +def test_cli_with_blind_payload(monkeypatch, cli_runner) -> None: + """Scan with blind payload should pass it to config.""" + captured_config = {} + + def fake_run_scan(config, logger): + captured_config["blind"] = config.blind_payload + return ScanResult( + total_urls=1, + candidate_urls=1, + findings=0, + results_file=Path("/tmp/results.txt"), + ) + + monkeypatch.setattr(scan_module, "run_scan", fake_run_scan) + with cli_runner.isolated_filesystem(): + result = cli_runner.invoke( + app_module.app, + ["scan", "-d", "example.com", "-b", "https://callback.com"], + ) + assert result.exit_code == 0 + assert captured_config["blind"] == "https://callback.com" + + +def test_cli_with_custom_output(monkeypatch, cli_runner) -> None: + """Scan with custom output name should use it.""" + captured_config = {} + + def fake_run_scan(config, logger): + captured_config["output"] = config.output_name + return ScanResult( + total_urls=1, + candidate_urls=1, + findings=0, + results_file=Path("/tmp/results.txt"), + ) + + monkeypatch.setattr(scan_module, "run_scan", fake_run_scan) + with cli_runner.isolated_filesystem(): + result = cli_runner.invoke( + app_module.app, + ["scan", "-d", "example.com", "-o", "custom_output.txt"], + ) + assert result.exit_code == 0 + assert captured_config["output"] == "custom_output.txt" + + +def test_setup_check_success(monkeypatch, cli_runner, all_tools_ok_report) -> None: + """Setup check should pass when all requirements met.""" + monkeypatch.setattr(setup_runner, "build_report", lambda: all_tools_ok_report) + + result = cli_runner.invoke(app_module.app, ["setup"]) + assert result.exit_code == 0 + assert "ok" in result.stdout.lower() + + +def test_setup_check_missing_tools(monkeypatch, cli_runner) -> None: + """Setup check should fail when tools missing.""" + report = SetupReport( + tools={"gf": False, "dalfox": True, "waybackurls": True, "gau": True}, + gf_pattern=True, + os_name="linux", + install_supported=True, + ) + monkeypatch.setattr(setup_runner, "build_report", lambda: report) + + result = cli_runner.invoke(app_module.app, ["setup"]) + assert result.exit_code == 3 + assert "missing" in result.stdout.lower() + + +def test_setup_displays_os(monkeypatch, cli_runner) -> None: + """Setup should display detected OS.""" + report = SetupReport( + tools={"gf": True, "dalfox": True, "waybackurls": True, "gau": True}, + gf_pattern=True, + os_name="darwin", + install_supported=True, + ) + monkeypatch.setattr(setup_runner, "build_report", lambda: report) + + result = cli_runner.invoke(app_module.app, ["setup"]) + assert "darwin" in result.stdout.lower() + + +def test_setup_quiet_suppresses_output( + monkeypatch, cli_runner, all_tools_ok_report +) -> None: + """Setup with --quiet should suppress output.""" + monkeypatch.setattr(setup_runner, "build_report", lambda: all_tools_ok_report) + + result = cli_runner.invoke(app_module.app, ["setup", "--quiet"]) + assert result.exit_code == 0 + assert result.stdout.strip() == "" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..7746bce --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,98 @@ +"""Pytest configuration and shared fixtures.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from quickxss.models.scan import ScanConfig, ScanResult +from quickxss.models.setup import SetupReport +from quickxss.utils.log import Logger + + +@pytest.fixture +def quiet_logger() -> Logger: + """Create a quiet logger for testing.""" + return Logger(verbose=False, quiet=True) + + +@pytest.fixture +def verbose_logger() -> Logger: + """Create a verbose logger for testing.""" + return Logger(verbose=True, quiet=False) + + +@pytest.fixture +def mock_logger() -> MagicMock: + """Create a mock logger.""" + return MagicMock(spec=Logger) + + +@pytest.fixture +def sample_scan_config(tmp_path: Path) -> ScanConfig: + """Create a sample scan configuration.""" + return ScanConfig( + domain="example.com", + results_dir=tmp_path, + output_name="results.txt", + overwrite=False, + use_wayback=True, + use_gau=True, + gf_pattern="xss", + blind_payload=None, + dalfox_args=[], + keep_temp=True, + verbose=False, + quiet=False, + ) + + +@pytest.fixture +def sample_scan_result(tmp_path: Path) -> ScanResult: + """Create a sample scan result.""" + return ScanResult( + total_urls=100, + candidate_urls=50, + findings=5, + results_file=tmp_path / "results.txt", + ) + + +@pytest.fixture +def all_tools_ok_report() -> SetupReport: + """Create a setup report with all tools installed.""" + return SetupReport( + tools={"gf": True, "dalfox": True, "waybackurls": True, "gau": True}, + gf_pattern=True, + os_name="linux", + install_supported=True, + ) + + +@pytest.fixture +def missing_tools_report() -> SetupReport: + """Create a setup report with missing tools.""" + return SetupReport( + tools={"gf": False, "dalfox": False, "waybackurls": False, "gau": False}, + gf_pattern=False, + os_name="linux", + install_supported=True, + ) + + +@pytest.fixture +def gf_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Create a temporary home directory with .gf folder.""" + monkeypatch.setenv("HOME", str(tmp_path)) + gf_dir = tmp_path / ".gf" + gf_dir.mkdir() + return gf_dir + + +@pytest.fixture +def fake_subprocess_success() -> SimpleNamespace: + """Create a fake successful subprocess result.""" + return SimpleNamespace(stdout="", stderr="", returncode=0) diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..d158ffd --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests for QuickXSS.""" diff --git a/tests/test_integration.py b/tests/integration/test_integration.py similarity index 98% rename from tests/test_integration.py rename to tests/integration/test_integration.py index c448a14..84c78c0 100644 --- a/tests/test_integration.py +++ b/tests/integration/test_integration.py @@ -1,3 +1,5 @@ +"""Integration tests for QuickXSS.""" + from __future__ import annotations import os diff --git a/tests/models/__init__.py b/tests/models/__init__.py new file mode 100644 index 0000000..1923ef7 --- /dev/null +++ b/tests/models/__init__.py @@ -0,0 +1 @@ +"""Tests for quickxss.models module.""" diff --git a/tests/models/test_models.py b/tests/models/test_models.py new file mode 100644 index 0000000..e7773b2 --- /dev/null +++ b/tests/models/test_models.py @@ -0,0 +1,196 @@ +"""Unit tests for quickxss.models modules.""" + +from __future__ import annotations + +from collections import OrderedDict +from pathlib import Path + +import pytest + +from quickxss.models.scan import ScanConfig, ScanPaths, ScanResult +from quickxss.models.setup import SetupReport + + +def test_scan_config_frozen(tmp_path: Path) -> None: + """ScanConfig should be immutable (frozen).""" + config = ScanConfig( + domain="example.com", + results_dir=tmp_path, + output_name="results.txt", + overwrite=False, + use_wayback=True, + use_gau=True, + gf_pattern="xss", + blind_payload=None, + dalfox_args=[], + keep_temp=True, + verbose=False, + quiet=False, + ) + with pytest.raises(AttributeError): + config.domain = "other.com" # type: ignore + + +def test_scan_config_with_all_options(tmp_path: Path) -> None: + """ScanConfig should accept all options.""" + config = ScanConfig( + domain="example.com", + results_dir=tmp_path, + output_name="out.txt", + overwrite=True, + use_wayback=False, + use_gau=False, + gf_pattern="sqli", + blind_payload="https://evil.com/callback", + dalfox_args=["--timeout", "10"], + keep_temp=False, + verbose=True, + quiet=False, + ) + assert config.domain == "example.com" + assert config.blind_payload == "https://evil.com/callback" + assert config.dalfox_args == ["--timeout", "10"] + assert config.use_wayback is False + assert config.use_gau is False + + +def test_scan_paths_frozen(tmp_path: Path) -> None: + """ScanPaths should be immutable (frozen).""" + paths = ScanPaths( + base_dir=tmp_path, + urls_file=tmp_path / "urls.txt", + temp_xss_file=tmp_path / "temp.txt", + xss_file=tmp_path / "xss.txt", + results_file=tmp_path / "results.txt", + ) + with pytest.raises(AttributeError): + paths.base_dir = Path("/other") # type: ignore + + +def test_scan_paths_stores_paths(tmp_path: Path) -> None: + """ScanPaths should store all path components.""" + paths = ScanPaths( + base_dir=tmp_path / "base", + urls_file=tmp_path / "urls.txt", + temp_xss_file=tmp_path / "temp.txt", + xss_file=tmp_path / "xss.txt", + results_file=tmp_path / "results.txt", + ) + assert paths.base_dir == tmp_path / "base" + assert paths.urls_file == tmp_path / "urls.txt" + assert paths.temp_xss_file == tmp_path / "temp.txt" + + +def test_scan_result_frozen(tmp_path: Path) -> None: + """ScanResult should be immutable (frozen).""" + result = ScanResult( + total_urls=100, + candidate_urls=50, + findings=5, + results_file=tmp_path / "results.txt", + ) + with pytest.raises(AttributeError): + result.total_urls = 200 # type: ignore + + +def test_scan_result_stores_metrics(tmp_path: Path) -> None: + """ScanResult should store all metrics.""" + result = ScanResult( + total_urls=1000, + candidate_urls=100, + findings=10, + results_file=tmp_path / "results.txt", + ) + assert result.total_urls == 1000 + assert result.candidate_urls == 100 + assert result.findings == 10 + assert result.results_file == tmp_path / "results.txt" + + +def test_setup_report_frozen() -> None: + """SetupReport should be immutable (frozen).""" + report = SetupReport( + tools={"gf": True, "dalfox": True}, + gf_pattern=True, + os_name="linux", + install_supported=True, + ) + with pytest.raises(AttributeError): + report.os_name = "darwin" # type: ignore + + +def test_missing_tools_returns_missing() -> None: + """missing_tools property should return list of missing tools.""" + report = SetupReport( + tools={"gf": True, "dalfox": False, "waybackurls": True, "gau": False}, + gf_pattern=True, + os_name="linux", + install_supported=True, + ) + missing = report.missing_tools + assert "dalfox" in missing + assert "gau" in missing + assert "gf" not in missing + assert "waybackurls" not in missing + + +def test_missing_tools_empty_when_all_present() -> None: + """missing_tools should return empty list when all tools present.""" + report = SetupReport( + tools={"gf": True, "dalfox": True, "waybackurls": True, "gau": True}, + gf_pattern=True, + os_name="linux", + install_supported=True, + ) + assert report.missing_tools == [] + + +def test_ok_true_when_all_requirements_met() -> None: + """ok property should return True when all requirements met.""" + report = SetupReport( + tools={"gf": True, "dalfox": True, "waybackurls": True, "gau": True}, + gf_pattern=True, + os_name="linux", + install_supported=True, + ) + assert report.ok is True + + +def test_ok_false_when_tools_missing() -> None: + """ok property should return False when tools are missing.""" + report = SetupReport( + tools={"gf": False, "dalfox": True}, + gf_pattern=True, + os_name="linux", + install_supported=True, + ) + assert report.ok is False + + +def test_ok_false_when_gf_pattern_missing() -> None: + """ok property should return False when gf_pattern is False.""" + report = SetupReport( + tools={"gf": True, "dalfox": True, "waybackurls": True, "gau": True}, + gf_pattern=False, + os_name="linux", + install_supported=True, + ) + assert report.ok is False + + +def test_missing_tools_preserves_order() -> None: + """missing_tools should return tools in insertion order.""" + tools = OrderedDict([ + ("gf", False), + ("dalfox", False), + ("waybackurls", True), + ("gau", False), + ]) + report = SetupReport( + tools=tools, + gf_pattern=True, + os_name="linux", + install_supported=True, + ) + missing = report.missing_tools + assert missing == ["gf", "dalfox", "gau"] diff --git a/tests/scan/__init__.py b/tests/scan/__init__.py new file mode 100644 index 0000000..e4360d7 --- /dev/null +++ b/tests/scan/__init__.py @@ -0,0 +1 @@ +"""Tests for quickxss.scan module.""" diff --git a/tests/scan/test_deps.py b/tests/scan/test_deps.py new file mode 100644 index 0000000..8b9da3f --- /dev/null +++ b/tests/scan/test_deps.py @@ -0,0 +1,113 @@ +"""Unit tests for quickxss.scan.deps module.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +import quickxss.scan.deps as deps +from quickxss.scan.errors import DependencyError + + +@patch.object(deps, "which", return_value=None) +def test_missing_binaries_returns_missing(mock_which: patch) -> None: + """Should return list of missing binaries.""" + result = deps.missing_binaries(["gf", "dalfox"]) + assert result == ["gf", "dalfox"] + + +@patch.object(deps, "which", side_effect=lambda name: f"/usr/bin/{name}") +def test_missing_binaries_returns_empty_when_all_present(mock_which: patch) -> None: + """Should return empty list when all binaries present.""" + result = deps.missing_binaries(["gf", "dalfox"]) + assert result == [] + + +def test_missing_binaries_partial(monkeypatch: pytest.MonkeyPatch) -> None: + """Should return only missing binaries.""" + + def fake_which(name): + return f"/usr/bin/{name}" if name == "gf" else None + + monkeypatch.setattr(deps, "which", fake_which) + result = deps.missing_binaries(["gf", "dalfox", "waybackurls"]) + assert result == ["dalfox", "waybackurls"] + + +@patch.object(deps, "which", return_value=None) +def test_check_binaries_missing(mock_which: patch) -> None: + """Should raise DependencyError when binaries missing.""" + with pytest.raises(DependencyError) as exc_info: + deps.check_binaries(["gf", "dalfox"]) + assert "gf" in str(exc_info.value) + assert "dalfox" in str(exc_info.value) + + +@patch.object(deps, "which", side_effect=lambda name: "/bin/" + name) +def test_check_binaries_ok(mock_which: patch) -> None: + """Should not raise when all binaries present.""" + deps.check_binaries(["gf", "dalfox"]) + + +@patch.object(deps, "which", return_value=None) +def test_check_binaries_empty_list(mock_which: patch) -> None: + """Should not raise for empty required list.""" + deps.check_binaries([]) + + +def test_check_gf_pattern_file_exists(gf_home: Path) -> None: + """Should pass when pattern file exists in ~/.gf.""" + (gf_home / "xss.json").write_text("{}") + deps.check_gf_pattern("xss") + + +def test_check_gf_pattern_from_list( + gf_home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Should pass when pattern available via gf -list.""" + + def fake_run(*_args, **_kwargs): + return SimpleNamespace(stdout="xss\nsqli\nssrf\n", returncode=0) + + monkeypatch.setattr(deps.subprocess, "run", fake_run) + deps.check_gf_pattern("xss") + + +def test_check_gf_pattern_not_found( + gf_home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Should raise when pattern not found anywhere.""" + + def fake_run(*_args, **_kwargs): + return SimpleNamespace(stdout="sqli\nssrf\n", returncode=0) + + monkeypatch.setattr(deps.subprocess, "run", fake_run) + with pytest.raises(DependencyError) as exc_info: + deps.check_gf_pattern("xss") + assert "xss" in str(exc_info.value) + + +def test_check_gf_pattern_gf_not_found( + gf_home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Should raise when gf command not found.""" + + def fake_run(*_args, **_kwargs): + raise FileNotFoundError() + + monkeypatch.setattr(deps.subprocess, "run", fake_run) + with pytest.raises(DependencyError) as exc_info: + deps.check_gf_pattern("xss") + assert "gf" in str(exc_info.value).lower() + + +def test_check_gf_pattern_custom_pattern(gf_home: Path) -> None: + """Should work with custom pattern names.""" + (gf_home / "custom-pattern.json").write_text("{}") + deps.check_gf_pattern("custom-pattern") diff --git a/tests/scan/test_errors.py b/tests/scan/test_errors.py new file mode 100644 index 0000000..d219bcd --- /dev/null +++ b/tests/scan/test_errors.py @@ -0,0 +1,122 @@ +"""Unit tests for quickxss.scan.errors module.""" + +from __future__ import annotations + +import pytest + +from quickxss.scan.errors import ( + DependencyError, + OperationError, + QuickXSSError, + ToolError, + ValidationError, +) + + +def test_quickxss_error_is_exception() -> None: + """QuickXSSError should be an Exception.""" + error = QuickXSSError("test error") + assert isinstance(error, Exception) + + +def test_quickxss_error_message() -> None: + """QuickXSSError should store message.""" + error = QuickXSSError("test message") + assert str(error) == "test message" + + +def test_validation_error_inherits_from_base() -> None: + """ValidationError should inherit from QuickXSSError.""" + error = ValidationError("invalid input") + assert isinstance(error, QuickXSSError) + assert isinstance(error, Exception) + + +def test_validation_error_message() -> None: + """ValidationError should store message.""" + error = ValidationError("domain is required") + assert str(error) == "domain is required" + + +def test_dependency_error_inherits_from_base() -> None: + """DependencyError should inherit from QuickXSSError.""" + error = DependencyError("missing tool") + assert isinstance(error, QuickXSSError) + + +def test_dependency_error_message() -> None: + """DependencyError should store message.""" + error = DependencyError("gf not found") + assert str(error) == "gf not found" + + +def test_tool_error_inherits_from_base() -> None: + """ToolError should inherit from QuickXSSError.""" + error = ToolError("command failed") + assert isinstance(error, QuickXSSError) + + +def test_tool_error_message() -> None: + """ToolError should store message.""" + error = ToolError("dalfox failed") + assert str(error) == "dalfox failed" + + +def test_tool_error_stores_command() -> None: + """ToolError should store command list.""" + error = ToolError("command failed", command=["gf", "xss"]) + assert error.command == ["gf", "xss"] + + +def test_tool_error_command_default_empty() -> None: + """ToolError command should default to empty list.""" + error = ToolError("command failed") + assert error.command == [] + + +def test_tool_error_with_none_command() -> None: + """ToolError with None command should use empty list.""" + error = ToolError("command failed", command=None) + assert error.command == [] + + +def test_operation_error_inherits_from_base() -> None: + """OperationError should inherit from QuickXSSError.""" + error = OperationError("write failed") + assert isinstance(error, QuickXSSError) + + +def test_operation_error_message() -> None: + """OperationError should store message.""" + error = OperationError("failed to write file") + assert str(error) == "failed to write file" + + +def test_all_errors_catchable_as_base() -> None: + """All errors should be catchable as QuickXSSError.""" + errors = [ + ValidationError("validation"), + DependencyError("dependency"), + ToolError("tool"), + OperationError("operation"), + ] + for error in errors: + try: + raise error + except QuickXSSError as e: + assert str(e) in ["validation", "dependency", "tool", "operation"] + + +def test_errors_are_distinct() -> None: + """Each error type should be distinguishable.""" + with pytest.raises(ValidationError): + raise ValidationError("test") + + with pytest.raises(DependencyError): + raise DependencyError("test") + + with pytest.raises(ToolError): + raise ToolError("test") + + with pytest.raises(OperationError): + raise OperationError("test") diff --git a/tests/scan/test_io.py b/tests/scan/test_io.py new file mode 100644 index 0000000..eec02a3 --- /dev/null +++ b/tests/scan/test_io.py @@ -0,0 +1,209 @@ +"""Unit tests for quickxss.scan.io module.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from quickxss.scan.errors import OperationError, ValidationError +from quickxss.scan.io import ( + dedupe_preserve_order, + ensure_scan_paths, + validate_domain, + validate_output_name, + write_lines, +) + + +def test_validate_domain_ok() -> None: + """Valid domain should pass.""" + assert validate_domain("example.com") == "example.com" + + +def test_validate_domain_strips_whitespace() -> None: + """Domain with whitespace should be stripped.""" + assert validate_domain(" example.com ") == "example.com" + + +def test_validate_domain_rejects_forward_slash() -> None: + """Domain with forward slash should be rejected.""" + with pytest.raises(ValidationError) as exc_info: + validate_domain("example.com/evil") + assert "path separators" in str(exc_info.value).lower() + + +def test_validate_domain_rejects_backslash() -> None: + """Domain with backslash should be rejected.""" + with pytest.raises(ValidationError): + validate_domain("example.com\\evil") + + +def test_validate_domain_rejects_double_dots() -> None: + """Domain with double dots for traversal should be rejected.""" + with pytest.raises(ValidationError): + validate_domain("..example.com") + + +def test_validate_domain_rejects_empty() -> None: + """Empty domain should be rejected.""" + with pytest.raises(ValidationError) as exc_info: + validate_domain("") + assert "required" in str(exc_info.value).lower() + + +def test_validate_domain_rejects_whitespace_only() -> None: + """Whitespace-only domain should be rejected.""" + with pytest.raises(ValidationError): + validate_domain(" ") + + +def test_validate_output_name_ok() -> None: + """Valid output name should pass.""" + assert validate_output_name("results.txt") == "results.txt" + + +def test_validate_output_name_strips_whitespace() -> None: + """Output name with whitespace should be stripped.""" + assert validate_output_name(" results.txt ") == "results.txt" + + +def test_validate_output_name_rejects_path() -> None: + """Output name with path traversal should be rejected.""" + with pytest.raises(ValidationError): + validate_output_name("../results.txt") + + +def test_validate_output_name_rejects_forward_slash() -> None: + """Output name with forward slash should be rejected.""" + with pytest.raises(ValidationError): + validate_output_name("dir/results.txt") + + +def test_validate_output_name_rejects_backslash() -> None: + """Output name with backslash should be rejected.""" + with pytest.raises(ValidationError): + validate_output_name("dir\\results.txt") + + +def test_validate_output_name_rejects_empty() -> None: + """Empty output name should be rejected.""" + with pytest.raises(ValidationError) as exc_info: + validate_output_name("") + assert "required" in str(exc_info.value).lower() + + +def test_ensure_scan_paths_creates_directory(tmp_path: Path) -> None: + """Should create base directory if it doesn't exist.""" + paths = ensure_scan_paths(tmp_path, "example.com", "results.txt", False) + assert paths.base_dir.exists() + assert paths.base_dir == tmp_path / "example.com" + + +def test_ensure_scan_paths_returns_correct_paths(tmp_path: Path) -> None: + """Should return correct path objects.""" + paths = ensure_scan_paths(tmp_path, "test.com", "output.txt", False) + assert paths.urls_file == tmp_path / "test.com" / "test.com.txt" + assert paths.temp_xss_file == tmp_path / "test.com" / "test.com_temp_xss.txt" + assert paths.xss_file == tmp_path / "test.com" / "test.com_xss.txt" + assert paths.results_file == tmp_path / "test.com" / "output.txt" + + +def test_ensure_scan_paths_raises_if_exists_without_overwrite(tmp_path: Path) -> None: + """Should raise ValidationError if directory exists and overwrite=False.""" + base = tmp_path / "existing.com" + base.mkdir() + with pytest.raises(ValidationError) as exc_info: + ensure_scan_paths(tmp_path, "existing.com", "results.txt", False) + assert "already exists" in str(exc_info.value).lower() + + +def test_ensure_scan_paths_allows_overwrite(tmp_path: Path) -> None: + """Should allow existing directory when overwrite=True.""" + base = tmp_path / "existing.com" + base.mkdir() + (base / "old_file.txt").write_text("old content") + + paths = ensure_scan_paths(tmp_path, "existing.com", "results.txt", True) + assert paths.base_dir.exists() + assert (base / "old_file.txt").exists() + + +def test_ensure_scan_paths_nested_directory(tmp_path: Path) -> None: + """Should create nested directories.""" + results_dir = tmp_path / "nested" / "results" + paths = ensure_scan_paths(results_dir, "example.com", "results.txt", False) + assert paths.base_dir.exists() + + +def test_write_lines_writes_content(tmp_path: Path) -> None: + """Should write lines to file.""" + path = tmp_path / "test.txt" + write_lines(path, ["line1", "line2", "line3"]) + content = path.read_text() + assert content == "line1\nline2\nline3\n" + + +def test_write_lines_empty_creates_empty_file(tmp_path: Path) -> None: + """Should create empty file for empty input.""" + path = tmp_path / "empty.txt" + write_lines(path, []) + assert path.exists() + assert path.read_text() == "" + + +def test_write_lines_single_line(tmp_path: Path) -> None: + """Should handle single line.""" + path = tmp_path / "single.txt" + write_lines(path, ["only one"]) + assert path.read_text() == "only one\n" + + +def test_write_lines_raises_on_write_error(tmp_path: Path) -> None: + """Should raise OperationError on write failure.""" + dir_path = tmp_path / "directory" + dir_path.mkdir() + with pytest.raises(OperationError): + write_lines(dir_path, ["content"]) + + +def test_dedupe_removes_duplicates() -> None: + """Should remove duplicate lines.""" + result = dedupe_preserve_order(["a", "b", "a", "c", "b"]) + assert result == ["a", "b", "c"] + + +def test_dedupe_preserves_order() -> None: + """Should preserve first occurrence order.""" + result = dedupe_preserve_order(["z", "y", "x", "y", "z"]) + assert result == ["z", "y", "x"] + + +def test_dedupe_empty_input() -> None: + """Should handle empty input.""" + result = dedupe_preserve_order([]) + assert result == [] + + +def test_dedupe_no_duplicates() -> None: + """Should work with no duplicates.""" + result = dedupe_preserve_order(["a", "b", "c"]) + assert result == ["a", "b", "c"] + + +def test_dedupe_all_same() -> None: + """Should handle all identical values.""" + result = dedupe_preserve_order(["x", "x", "x", "x"]) + assert result == ["x"] + + +def test_dedupe_generator_input() -> None: + """Should work with generator input.""" + + def gen(): + yield "a" + yield "b" + yield "a" + + result = dedupe_preserve_order(gen()) + assert result == ["a", "b"] diff --git a/tests/scan/test_pipeline.py b/tests/scan/test_pipeline.py new file mode 100644 index 0000000..be1a49a --- /dev/null +++ b/tests/scan/test_pipeline.py @@ -0,0 +1,329 @@ +"""Unit tests for quickxss.scan.pipeline module.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +import quickxss.scan.pipeline as pipeline +from quickxss.models.scan import ScanConfig +from quickxss.utils.log import Logger + + +@pytest.fixture +def scan_config(tmp_path: Path) -> ScanConfig: + """Create a basic scan config.""" + return ScanConfig( + domain="example.com", + results_dir=tmp_path, + output_name="results.txt", + overwrite=False, + use_wayback=True, + use_gau=True, + gf_pattern="xss", + blind_payload=None, + dalfox_args=[], + keep_temp=True, + verbose=False, + quiet=False, + ) + + +def test_run_scan_happy_path( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, quiet_logger: Logger +) -> None: + """Full scan should succeed and create expected files.""" + monkeypatch.setattr(pipeline, "check_binaries", lambda *_: None) + monkeypatch.setattr(pipeline, "check_gf_pattern", lambda *_: None) + + def fake_run_command(command, input_text, logger): + if command[0] == "waybackurls": + return "http://a.test/?q=1\n" + if command[0] == "gau": + return "http://b.test/?id=2\nhttp://a.test/?q=1\n" + if command[0] == "gf": + return "URL: http://a.test/?q=1\nhttp://b.test/?id=2\n" + if command[0] == "dalfox": + return "" + raise AssertionError("unexpected command") + + monkeypatch.setattr(pipeline, "run_command", fake_run_command) + + config = ScanConfig( + domain="example.com", + results_dir=tmp_path, + output_name="results.txt", + overwrite=False, + use_wayback=True, + use_gau=True, + gf_pattern="xss", + blind_payload=None, + dalfox_args=[], + keep_temp=True, + verbose=False, + quiet=False, + ) + + result = pipeline.run_scan(config, quiet_logger) + + base_dir = tmp_path / "example.com" + assert base_dir.exists() + assert (base_dir / "example.com.txt").exists() + assert (base_dir / "example.com_xss.txt").exists() + assert (base_dir / "results.txt").exists() + assert result.total_urls == 2 + assert result.candidate_urls == 2 + assert result.findings == 0 + + +def test_run_scan_no_candidates( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, quiet_logger: Logger +) -> None: + """Scan with no candidates should skip dalfox.""" + monkeypatch.setattr(pipeline, "check_binaries", lambda *_: None) + monkeypatch.setattr(pipeline, "check_gf_pattern", lambda *_: None) + + def fake_run_command(command, input_text, logger): + if command[0] in {"waybackurls", "gau"}: + return "" + if command[0] == "gf": + return "" + if command[0] == "dalfox": + raise AssertionError("dalfox should not run") + raise AssertionError("unexpected command") + + monkeypatch.setattr(pipeline, "run_command", fake_run_command) + + config = ScanConfig( + domain="example.com", + results_dir=tmp_path, + output_name="results.txt", + overwrite=False, + use_wayback=True, + use_gau=True, + gf_pattern="xss", + blind_payload=None, + dalfox_args=[], + keep_temp=False, + verbose=False, + quiet=False, + ) + + result = pipeline.run_scan(config, quiet_logger) + + base_dir = tmp_path / "example.com" + assert not (base_dir / "example.com_temp_xss.txt").exists() + assert (base_dir / "results.txt").exists() + assert result.candidate_urls == 0 + + +def test_run_scan_wayback_only( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, quiet_logger: Logger +) -> None: + """Scan with only waybackurls enabled.""" + monkeypatch.setattr(pipeline, "check_binaries", lambda *_: None) + monkeypatch.setattr(pipeline, "check_gf_pattern", lambda *_: None) + + gau_called = False + + def fake_run_command(command, input_text, logger): + nonlocal gau_called + if command[0] == "waybackurls": + return "http://test.com/?id=1\n" + if command[0] == "gau": + gau_called = True + return "" + if command[0] == "gf": + return "http://test.com/?id=1\n" + if command[0] == "dalfox": + return "" + raise AssertionError("unexpected command") + + monkeypatch.setattr(pipeline, "run_command", fake_run_command) + + config = ScanConfig( + domain="example.com", + results_dir=tmp_path, + output_name="results.txt", + overwrite=False, + use_wayback=True, + use_gau=False, + gf_pattern="xss", + blind_payload=None, + dalfox_args=[], + keep_temp=True, + verbose=False, + quiet=False, + ) + + result = pipeline.run_scan(config, quiet_logger) + + assert not gau_called + assert result.total_urls == 1 + + +def test_run_scan_with_blind_payload( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, quiet_logger: Logger +) -> None: + """Scan with blind XSS payload should pass it to dalfox.""" + monkeypatch.setattr(pipeline, "check_binaries", lambda *_: None) + monkeypatch.setattr(pipeline, "check_gf_pattern", lambda *_: None) + + captured_dalfox_cmd = [] + + def fake_run_command(command, input_text, logger): + if command[0] == "waybackurls": + return "http://test.com/?id=1\n" + if command[0] == "gau": + return "" + if command[0] == "gf": + return "http://test.com/?id=1\n" + if command[0] == "dalfox": + captured_dalfox_cmd.extend(command) + return "" + raise AssertionError("unexpected command") + + monkeypatch.setattr(pipeline, "run_command", fake_run_command) + + config = ScanConfig( + domain="example.com", + results_dir=tmp_path, + output_name="results.txt", + overwrite=False, + use_wayback=True, + use_gau=False, + gf_pattern="xss", + blind_payload="https://callback.evil.com", + dalfox_args=[], + keep_temp=True, + verbose=False, + quiet=False, + ) + + pipeline.run_scan(config, quiet_logger) + + assert "-b" in captured_dalfox_cmd + assert "https://callback.evil.com" in captured_dalfox_cmd + assert "-H" in captured_dalfox_cmd + + +def test_run_scan_with_custom_dalfox_args( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, quiet_logger: Logger +) -> None: + """Scan with custom dalfox args should include them.""" + monkeypatch.setattr(pipeline, "check_binaries", lambda *_: None) + monkeypatch.setattr(pipeline, "check_gf_pattern", lambda *_: None) + + captured_dalfox_cmd = [] + + def fake_run_command(command, input_text, logger): + if command[0] == "waybackurls": + return "http://test.com/?id=1\n" + if command[0] == "gau": + return "" + if command[0] == "gf": + return "http://test.com/?id=1\n" + if command[0] == "dalfox": + captured_dalfox_cmd.extend(command) + return "" + raise AssertionError("unexpected command") + + monkeypatch.setattr(pipeline, "run_command", fake_run_command) + + config = ScanConfig( + domain="example.com", + results_dir=tmp_path, + output_name="results.txt", + overwrite=False, + use_wayback=True, + use_gau=False, + gf_pattern="xss", + blind_payload=None, + dalfox_args=["--timeout", "10", "--skip-headless"], + keep_temp=True, + verbose=False, + quiet=False, + ) + + pipeline.run_scan(config, quiet_logger) + + assert "--timeout" in captured_dalfox_cmd + assert "10" in captured_dalfox_cmd + assert "--skip-headless" in captured_dalfox_cmd + + +def test_normalize_lines_strips_whitespace() -> None: + """Should strip whitespace from lines.""" + result = pipeline.normalize_lines([" hello ", " world "]) + assert result == ["hello", "world"] + + +def test_normalize_lines_removes_empty() -> None: + """Should remove empty lines.""" + result = pipeline.normalize_lines(["hello", "", " ", "world"]) + assert result == ["hello", "world"] + + +def test_normalize_lines_empty_input() -> None: + """Should handle empty input.""" + result = pipeline.normalize_lines([]) + assert result == [] + + +def test_normalize_candidate_strips_url_prefix() -> None: + """Should strip 'URL: ' prefix.""" + result = pipeline.normalize_candidate("URL: http://test.com/?id=1") + assert result == "http://test.com/?id=" + + +def test_normalize_candidate_truncates_at_equals() -> None: + """Should truncate value after equals sign.""" + result = pipeline.normalize_candidate("http://test.com/?id=12345") + assert result == "http://test.com/?id=" + + +def test_normalize_candidate_no_equals() -> None: + """Should return unchanged if no equals sign.""" + result = pipeline.normalize_candidate("http://test.com/path") + assert result == "http://test.com/path" + + +def test_normalize_candidate_strips_whitespace() -> None: + """Should strip whitespace.""" + result = pipeline.normalize_candidate(" http://test.com/?id=1 ") + assert result == "http://test.com/?id=" + + +def test_count_findings_counts_non_empty_lines(tmp_path: Path) -> None: + """Should count non-empty lines.""" + results = tmp_path / "results.txt" + results.write_text("finding1\nfinding2\nfinding3\n") + assert pipeline.count_findings(results) == 3 + + +def test_count_findings_ignores_empty_lines(tmp_path: Path) -> None: + """Should ignore empty lines.""" + results = tmp_path / "results.txt" + results.write_text("finding1\n\n\nfinding2\n") + assert pipeline.count_findings(results) == 2 + + +def test_count_findings_missing_file(tmp_path: Path) -> None: + """Should return 0 for missing file.""" + results = tmp_path / "nonexistent.txt" + assert pipeline.count_findings(results) == 0 + + +def test_count_findings_empty_file(tmp_path: Path) -> None: + """Should return 0 for empty file.""" + results = tmp_path / "empty.txt" + results.write_text("") + assert pipeline.count_findings(results) == 0 + + +def test_count_findings_whitespace_only_lines(tmp_path: Path) -> None: + """Should ignore whitespace-only lines.""" + results = tmp_path / "results.txt" + results.write_text("finding1\n \n\t\nfinding2\n") + assert pipeline.count_findings(results) == 2 diff --git a/tests/setup/__init__.py b/tests/setup/__init__.py new file mode 100644 index 0000000..9b657c5 --- /dev/null +++ b/tests/setup/__init__.py @@ -0,0 +1 @@ +"""Tests for quickxss.setup module.""" diff --git a/tests/setup/test_checks.py b/tests/setup/test_checks.py new file mode 100644 index 0000000..7aca0cf --- /dev/null +++ b/tests/setup/test_checks.py @@ -0,0 +1,168 @@ +"""Unit tests for quickxss.setup.checks module.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import pytest + +from quickxss.setup import checks + + +@patch.object(checks, "which", side_effect=lambda name: f"/usr/bin/{name}") +def test_detect_tools_all_present(mock_which: patch) -> None: + """Should return True for all present tools.""" + result = checks.detect_tools(["gf", "dalfox"]) + assert result == {"gf": True, "dalfox": True} + + +@patch.object(checks, "which", return_value=None) +def test_detect_tools_all_missing(mock_which: patch) -> None: + """Should return False for all missing tools.""" + result = checks.detect_tools(["gf", "dalfox"]) + assert result == {"gf": False, "dalfox": False} + + +def test_detect_tools_mixed(monkeypatch: pytest.MonkeyPatch) -> None: + """Should return mixed results.""" + + def fake_which(name): + return f"/usr/bin/{name}" if name in ("gf", "dalfox") else None + + monkeypatch.setattr(checks, "which", fake_which) + result = checks.detect_tools(["gf", "dalfox", "waybackurls", "gau"]) + assert result == {"gf": True, "dalfox": True, "waybackurls": False, "gau": False} + + +def test_detect_tools_empty() -> None: + """Should handle empty input.""" + result = checks.detect_tools([]) + assert result == {} + + +@patch.object(checks.platform, "system", return_value="Darwin") +def test_detect_os_returns_lowercase(mock_system: patch) -> None: + """Should return lowercase OS name.""" + assert checks.detect_os() == "darwin" + + +@patch.object(checks.platform, "system", return_value="Linux") +def test_detect_os_linux(mock_system: patch) -> None: + """Should detect Linux.""" + assert checks.detect_os() == "linux" + + +@patch.object(checks.platform, "system", return_value="Windows") +def test_detect_os_windows(mock_system: patch) -> None: + """Should detect Windows.""" + assert checks.detect_os() == "windows" + + +@patch.object(checks, "which", return_value="/usr/local/bin/brew") +def test_has_brew_true(mock_which: patch) -> None: + """Should return True when brew is available.""" + assert checks.has_brew() is True + + +@patch.object(checks, "which", return_value=None) +def test_has_brew_false(mock_which: patch) -> None: + """Should return False when brew is not available.""" + assert checks.has_brew() is False + + +@patch.object(checks, "which", return_value="/usr/bin/apt-get") +def test_has_apt_true(mock_which: patch) -> None: + """Should return True when apt-get is available.""" + assert checks.has_apt() is True + + +@patch.object(checks, "which", return_value=None) +def test_has_apt_false(mock_which: patch) -> None: + """Should return False when apt-get is not available.""" + assert checks.has_apt() is False + + +@patch.object(checks, "has_brew", return_value=True) +def test_install_supported_darwin_with_brew(mock_brew: patch) -> None: + """Should return True on Darwin with Homebrew.""" + assert checks.install_supported("darwin") is True + + +@patch.object(checks, "has_brew", return_value=False) +def test_install_supported_darwin_without_brew(mock_brew: patch) -> None: + """Should return False on Darwin without Homebrew.""" + assert checks.install_supported("darwin") is False + + +@patch.object(checks, "has_apt", return_value=True) +def test_install_supported_linux_with_apt(mock_apt: patch) -> None: + """Should return True on Linux with apt-get.""" + assert checks.install_supported("linux") is True + + +@patch.object(checks, "has_apt", return_value=False) +def test_install_supported_linux_without_apt(mock_apt: patch) -> None: + """Should return False on Linux without apt-get.""" + assert checks.install_supported("linux") is False + + +def test_install_supported_windows() -> None: + """Should return False on Windows.""" + assert checks.install_supported("windows") is False + + +def test_install_supported_unknown_os() -> None: + """Should return False on unknown OS.""" + assert checks.install_supported("freebsd") is False + + +def test_has_gf_pattern_gf_not_available() -> None: + """Should return False when gf is not available.""" + assert checks.has_gf_pattern("xss", gf_available=False) is False + + +def test_has_gf_pattern_file_exists(gf_home: Path) -> None: + """Should return True when pattern file exists.""" + (gf_home / "xss.json").write_text("{}") + assert checks.has_gf_pattern("xss", gf_available=True) is True + + +def test_has_gf_pattern_from_list( + gf_home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Should return True when pattern in gf -list.""" + + def fake_run(*_args, **_kwargs): + return SimpleNamespace(stdout="xss\nsqli\n", returncode=0) + + monkeypatch.setattr(checks.subprocess, "run", fake_run) + assert checks.has_gf_pattern("xss", gf_available=True) is True + + +def test_has_gf_pattern_not_found( + gf_home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Should return False when pattern not found.""" + + def fake_run(*_args, **_kwargs): + return SimpleNamespace(stdout="sqli\nssrf\n", returncode=0) + + monkeypatch.setattr(checks.subprocess, "run", fake_run) + assert checks.has_gf_pattern("xss", gf_available=True) is False + + +def test_has_gf_pattern_gf_command_not_found( + gf_home: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Should return False when gf command fails.""" + + def fake_run(*_args, **_kwargs): + raise FileNotFoundError() + + monkeypatch.setattr(checks.subprocess, "run", fake_run) + assert checks.has_gf_pattern("xss", gf_available=True) is False diff --git a/tests/setup/test_installer.py b/tests/setup/test_installer.py new file mode 100644 index 0000000..8680f4e --- /dev/null +++ b/tests/setup/test_installer.py @@ -0,0 +1,199 @@ +"""Unit tests for quickxss.setup.installer module.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from quickxss.constants.platform import APT_CMD, BREW_CMD, OS_DARWIN, OS_LINUX +from quickxss.scan.errors import ToolError +from quickxss.setup.installer import ( + install_gf_patterns, + install_go_tools, + install_system_packages, +) + + +@patch("quickxss.setup.installer.run_command") +def test_empty_packages_does_nothing( + mock_run: patch, mock_logger: MagicMock +) -> None: + """Empty packages list should not call any commands.""" + install_system_packages(OS_DARWIN, [], mock_logger) + mock_run.assert_not_called() + + +@patch("quickxss.setup.installer.run_command") +def test_darwin_uses_brew(mock_run: patch, mock_logger: MagicMock) -> None: + """Darwin should use brew install.""" + install_system_packages(OS_DARWIN, ["pkg1", "pkg2"], mock_logger) + mock_run.assert_called_once_with( + [BREW_CMD, "install", "pkg1", "pkg2"], mock_logger + ) + + +@patch("quickxss.setup.installer.run_command") +def test_linux_uses_apt(mock_run: patch, mock_logger: MagicMock) -> None: + """Linux should use apt-get update and install.""" + install_system_packages(OS_LINUX, ["pkg1", "pkg2"], mock_logger) + assert mock_run.call_count == 2 + calls = mock_run.call_args_list + assert calls[0][0][0] == [APT_CMD, "update"] + assert calls[1][0][0] == [APT_CMD, "install", "-y", "pkg1", "pkg2"] + + +def test_unsupported_os_raises_tool_error(mock_logger: MagicMock) -> None: + """Unsupported OS should raise ToolError.""" + with pytest.raises(ToolError, match="not supported"): + install_system_packages("unsupported_os", ["pkg1"], mock_logger) + + +@patch("quickxss.setup.installer.run_command") +def test_single_package(mock_run: patch, mock_logger: MagicMock) -> None: + """Single package should work correctly.""" + install_system_packages(OS_DARWIN, ["single_pkg"], mock_logger) + mock_run.assert_called_once_with( + [BREW_CMD, "install", "single_pkg"], mock_logger + ) + + +@patch("quickxss.setup.installer.run_command") +def test_empty_tools_does_nothing(mock_run: patch, mock_logger: MagicMock) -> None: + """Empty tools list should not call any commands.""" + install_go_tools([], mock_logger) + mock_run.assert_not_called() + + +@patch("quickxss.setup.installer.run_command") +def test_installs_known_tools(mock_run: patch, mock_logger: MagicMock) -> None: + """Known tools should be installed with go install.""" + install_go_tools(["gf"], mock_logger) + assert mock_run.call_count == 1 + call_args = mock_run.call_args[0][0] + assert call_args[0] == "go" + assert call_args[1] == "install" + + +@patch("quickxss.setup.installer.run_command") +def test_installs_multiple_tools(mock_run: patch, mock_logger: MagicMock) -> None: + """Multiple tools should each be installed separately.""" + install_go_tools(["gf", "dalfox", "waybackurls", "gau"], mock_logger) + assert mock_run.call_count == 4 + + +@patch("quickxss.setup.installer.run_command") +def test_unknown_tool_skipped(mock_run: patch, mock_logger: MagicMock) -> None: + """Unknown tool should be skipped.""" + install_go_tools(["unknown_tool"], mock_logger) + mock_run.assert_not_called() + + +@patch("shutil.copy2") +@patch("quickxss.setup.installer.copy_tree") +@patch("quickxss.setup.installer.run_command") +def test_creates_gf_directory( + mock_run: patch, + mock_copy_tree: patch, + mock_copy2: patch, + mock_logger: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Should create ~/.gf directory if it doesn't exist.""" + mock_home = tmp_path / "home" + mock_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: mock_home) + + try: + install_gf_patterns(mock_logger) + except Exception: + pass + + gf_dir = mock_home / ".gf" + assert gf_dir.exists() + + +@patch("shutil.copy2") +@patch("quickxss.setup.installer.copy_tree") +@patch("quickxss.setup.installer.run_command") +def test_clones_gf_repo( + mock_run: patch, + mock_copy_tree: patch, + mock_copy2: patch, + mock_logger: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Should clone gf repository.""" + mock_home = tmp_path / "home" + mock_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: mock_home) + + cloned_repos = [] + + def track_run_command(cmd, logger): + if cmd[0] == "git" and cmd[1] == "clone": + cloned_repos.append(cmd[4]) + + mock_run.side_effect = track_run_command + install_gf_patterns(mock_logger) + + assert any("gf" in repo for repo in cloned_repos) + + +@patch("shutil.copy2") +@patch("quickxss.setup.installer.copy_tree") +@patch("quickxss.setup.installer.run_command") +def test_clones_patterns_repo( + mock_run: patch, + mock_copy_tree: patch, + mock_copy2: patch, + mock_logger: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Should clone Gf-Patterns repository.""" + mock_home = tmp_path / "home" + mock_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: mock_home) + + cloned_repos = [] + + def track_run_command(cmd, logger): + if cmd[0] == "git" and cmd[1] == "clone": + cloned_repos.append(cmd[4]) + + mock_run.side_effect = track_run_command + install_gf_patterns(mock_logger) + + assert any("patterns" in repo.lower() for repo in cloned_repos) + + +@patch("shutil.copy2") +@patch("quickxss.setup.installer.run_command") +def test_copies_examples( + mock_run: patch, + mock_copy2: patch, + mock_logger: MagicMock, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Should copy examples to ~/.gf.""" + mock_home = tmp_path / "home" + mock_home.mkdir() + monkeypatch.setattr(Path, "home", lambda: mock_home) + + copy_tree_calls = [] + + def track_copy_tree(src, dst): + copy_tree_calls.append((src, dst)) + + with patch("quickxss.setup.installer.copy_tree", side_effect=track_copy_tree): + install_gf_patterns(mock_logger) + + assert len(copy_tree_calls) == 1 + src, dst = copy_tree_calls[0] + assert "examples" in str(src) + assert ".gf" in str(dst) diff --git a/tests/setup/test_runner.py b/tests/setup/test_runner.py new file mode 100644 index 0000000..798eb78 --- /dev/null +++ b/tests/setup/test_runner.py @@ -0,0 +1,245 @@ +"""Unit tests for quickxss.setup.runner module.""" + +from __future__ import annotations + +from quickxss.models.setup import SetupReport +from quickxss.setup import runner +from quickxss.utils.log import Logger + + +def test_setup_ok(monkeypatch, quiet_logger: Logger, all_tools_ok_report) -> None: + """Setup with all tools installed should return 0.""" + monkeypatch.setattr(runner, "build_report", lambda: all_tools_ok_report) + assert runner.run_setup(False, quiet_logger) == 0 + + +def test_setup_missing_check_only(monkeypatch, quiet_logger: Logger) -> None: + """Setup with missing tools (check only) should return 3.""" + report = SetupReport( + tools={"gf": True, "dalfox": False, "waybackurls": True, "gau": False}, + gf_pattern=False, + os_name="linux", + install_supported=True, + ) + monkeypatch.setattr(runner, "build_report", lambda: report) + assert runner.run_setup(False, quiet_logger) == 3 + + +def test_setup_install_unsupported(monkeypatch, quiet_logger: Logger) -> None: + """Setup with unsupported OS should return 3.""" + report = SetupReport( + tools={"gf": False, "dalfox": False, "waybackurls": False, "gau": False}, + gf_pattern=False, + os_name="windows", + install_supported=False, + ) + monkeypatch.setattr(runner, "build_report", lambda: report) + assert runner.run_setup(True, quiet_logger) == 3 + + +def test_setup_install_success( + monkeypatch, quiet_logger: Logger, missing_tools_report, all_tools_ok_report +) -> None: + """Setup install should return 0 when all deps installed.""" + reports = iter([missing_tools_report, all_tools_ok_report]) + monkeypatch.setattr(runner, "build_report", lambda: next(reports)) + monkeypatch.setattr(runner, "install_missing", lambda *_: None) + assert runner.run_setup(True, quiet_logger) == 0 + + +def test_setup_install_partial_failure( + monkeypatch, quiet_logger: Logger, missing_tools_report +) -> None: + """Setup install should return 3 when some deps still missing.""" + report_still_missing = SetupReport( + tools={"gf": True, "dalfox": False, "waybackurls": True, "gau": False}, + gf_pattern=True, + os_name="linux", + install_supported=True, + ) + reports = iter([missing_tools_report, report_still_missing]) + monkeypatch.setattr(runner, "build_report", lambda: next(reports)) + monkeypatch.setattr(runner, "install_missing", lambda *_: None) + assert runner.run_setup(True, quiet_logger) == 3 + + +def test_setup_darwin(monkeypatch, quiet_logger: Logger) -> None: + """Setup on Darwin should work correctly.""" + report = SetupReport( + tools={"gf": True, "dalfox": True, "waybackurls": True, "gau": True}, + gf_pattern=True, + os_name="darwin", + install_supported=True, + ) + monkeypatch.setattr(runner, "build_report", lambda: report) + assert runner.run_setup(False, quiet_logger) == 0 + + +def test_build_report_returns_setup_report(monkeypatch) -> None: + """Should return a SetupReport instance.""" + monkeypatch.setattr(runner, "detect_os", lambda: "linux") + monkeypatch.setattr( + runner, "detect_tools", lambda _: {"gf": True, "dalfox": True} + ) + monkeypatch.setattr(runner, "has_gf_pattern", lambda name, has_gf: True) + monkeypatch.setattr(runner, "install_supported", lambda os: True) + + report = runner.build_report() + assert isinstance(report, SetupReport) + + +def test_build_report_passes_required_tools(monkeypatch) -> None: + """Should detect required tools.""" + detected = [] + + def mock_detect(tools): + detected.extend(tools) + return {"gf": True, "dalfox": True, "waybackurls": True, "gau": True} + + monkeypatch.setattr(runner, "detect_os", lambda: "linux") + monkeypatch.setattr(runner, "detect_tools", mock_detect) + monkeypatch.setattr(runner, "has_gf_pattern", lambda name, has_gf: True) + monkeypatch.setattr(runner, "install_supported", lambda os: True) + + runner.build_report() + assert "gf" in detected + assert "dalfox" in detected + + +def test_print_report_prints_os_name(capsys) -> None: + """Should print detected OS.""" + report = SetupReport( + tools={"gf": True}, + gf_pattern=True, + os_name="darwin", + install_supported=True, + ) + logger = Logger(verbose=False, quiet=False) + runner.print_report(report, logger) + captured = capsys.readouterr() + assert "darwin" in captured.out + + +def test_print_report_prints_tool_status(capsys) -> None: + """Should print status for each tool.""" + report = SetupReport( + tools={"gf": True, "dalfox": False}, + gf_pattern=True, + os_name="linux", + install_supported=True, + ) + logger = Logger(verbose=False, quiet=False) + runner.print_report(report, logger) + captured = capsys.readouterr() + assert "gf" in captured.out + assert "ok" in captured.out + assert "dalfox" in captured.out + assert "missing" in captured.out + + +def test_print_report_prints_gf_pattern_status(capsys) -> None: + """Should print gf pattern status.""" + report = SetupReport( + tools={"gf": True}, + gf_pattern=False, + os_name="linux", + install_supported=True, + ) + logger = Logger(verbose=False, quiet=False) + runner.print_report(report, logger) + captured = capsys.readouterr() + assert "gf patterns" in captured.out + assert "missing" in captured.out + + +def test_suggested_commands_empty_when_nothing_missing() -> None: + """No missing tools should return empty list.""" + commands = runner.suggested_commands([], True) + assert commands == [] + + +def test_suggested_commands_go_install_for_missing_tools() -> None: + """Missing tools should have go install commands.""" + commands = runner.suggested_commands(["gf", "dalfox"], True) + assert any("go install" in cmd for cmd in commands) + + +def test_suggested_commands_gf_pattern_commands() -> None: + """Missing gf pattern should have clone commands.""" + commands = runner.suggested_commands([], False) + assert any("git clone" in cmd for cmd in commands) + assert any("mkdir" in cmd for cmd in commands) + + +def test_suggested_commands_combined() -> None: + """Missing tools and patterns should have both commands.""" + commands = runner.suggested_commands(["gf"], False) + assert any("go install" in cmd for cmd in commands) + assert any("git clone" in cmd for cmd in commands) + + +def test_install_missing_installs_system_packages( + monkeypatch, quiet_logger: Logger +) -> None: + """Should install system packages when needed.""" + installed_packages = [] + + def mock_install_system(os_name, packages, logger): + installed_packages.extend(packages) + + monkeypatch.setattr(runner, "install_system_packages", mock_install_system) + monkeypatch.setattr(runner, "install_go_tools", lambda *_: None) + monkeypatch.setattr(runner, "install_gf_patterns", lambda *_: None) + + report = SetupReport( + tools={"gf": False}, + gf_pattern=False, + os_name="linux", + install_supported=True, + ) + runner.install_missing(report, quiet_logger) + assert len(installed_packages) > 0 + + +def test_install_missing_installs_go_tools(monkeypatch, quiet_logger: Logger) -> None: + """Should install missing Go tools.""" + installed_tools = [] + + def mock_install_go(tools, logger): + installed_tools.extend(tools) + + monkeypatch.setattr(runner, "install_system_packages", lambda *_: None) + monkeypatch.setattr(runner, "install_go_tools", mock_install_go) + monkeypatch.setattr(runner, "install_gf_patterns", lambda *_: None) + + report = SetupReport( + tools={"gf": False, "dalfox": False}, + gf_pattern=True, + os_name="linux", + install_supported=True, + ) + runner.install_missing(report, quiet_logger) + assert "gf" in installed_tools or "dalfox" in installed_tools + + +def test_install_missing_installs_gf_patterns( + monkeypatch, quiet_logger: Logger +) -> None: + """Should install gf patterns when missing.""" + patterns_installed = [] + + def mock_install_patterns(logger): + patterns_installed.append(True) + + monkeypatch.setattr(runner, "install_system_packages", lambda *_: None) + monkeypatch.setattr(runner, "install_go_tools", lambda *_: None) + monkeypatch.setattr(runner, "install_gf_patterns", mock_install_patterns) + + report = SetupReport( + tools={"gf": True, "dalfox": True, "waybackurls": True, "gau": True}, + gf_pattern=False, + os_name="linux", + install_supported=True, + ) + runner.install_missing(report, quiet_logger) + assert patterns_installed == [True] diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 2143e70..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from importlib import import_module -from pathlib import Path - -from typer.testing import CliRunner - -from quickxss.models.scan import ScanResult - -app_module = import_module("quickxss.cli.app") -scan_module = import_module("quickxss.cli.scan") - - -def test_cli_requires_domain() -> None: - runner = CliRunner() - result = runner.invoke(app_module.app, ["scan"]) - assert result.exit_code != 0 - - -def test_cli_success(monkeypatch) -> None: - def fake_run_scan(config, logger): - return ScanResult( - total_urls=1, - candidate_urls=1, - findings=0, - results_file=Path("/tmp/results.txt"), - ) - - monkeypatch.setattr(scan_module, "run_scan", fake_run_scan) - runner = CliRunner() - with runner.isolated_filesystem(): - result = runner.invoke(app_module.app, ["scan", "-d", "example.com"]) - assert result.exit_code == 0 - assert "Summary" in result.stdout diff --git a/tests/test_deps.py b/tests/test_deps.py deleted file mode 100644 index 571a4f9..0000000 --- a/tests/test_deps.py +++ /dev/null @@ -1,38 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from types import SimpleNamespace - -import pytest - -import quickxss.scan.deps as deps -from quickxss.scan.errors import DependencyError - - -def test_check_binaries_missing(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(deps, "which", lambda name: None) - with pytest.raises(DependencyError): - deps.check_binaries(["gf", "dalfox"]) - - -def test_check_binaries_ok(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(deps, "which", lambda name: "/bin/" + name) - deps.check_binaries(["gf", "dalfox"]) - - -def test_check_gf_pattern_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.setenv("HOME", str(tmp_path)) - gf_dir = tmp_path / ".gf" - gf_dir.mkdir() - (gf_dir / "xss.json").write_text("{}") - deps.check_gf_pattern("xss") - - -def test_check_gf_pattern_list(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - monkeypatch.setenv("HOME", str(tmp_path)) - - def fake_run(*_args, **_kwargs): - return SimpleNamespace(stdout="xss\n", returncode=0) - - monkeypatch.setattr(deps.subprocess, "run", fake_run) - deps.check_gf_pattern("xss") diff --git a/tests/test_io.py b/tests/test_io.py deleted file mode 100644 index 5c6d0f3..0000000 --- a/tests/test_io.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -import pytest - -from quickxss.scan.errors import ValidationError -from quickxss.scan.io import validate_domain, validate_output_name - - -def test_validate_domain_ok() -> None: - assert validate_domain("example.com") == "example.com" - - -def test_validate_domain_rejects_path() -> None: - with pytest.raises(ValidationError): - validate_domain("example.com/evil") - - -def test_validate_output_name_ok() -> None: - assert validate_output_name("results.txt") == "results.txt" - - -def test_validate_output_name_rejects_path() -> None: - with pytest.raises(ValidationError): - validate_output_name("../results.txt") diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 974a0a8..be1a49a 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -1,3 +1,5 @@ +"""Unit tests for quickxss.scan.pipeline module.""" + from __future__ import annotations from pathlib import Path @@ -9,7 +11,29 @@ from quickxss.utils.log import Logger -def test_run_scan_happy_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +@pytest.fixture +def scan_config(tmp_path: Path) -> ScanConfig: + """Create a basic scan config.""" + return ScanConfig( + domain="example.com", + results_dir=tmp_path, + output_name="results.txt", + overwrite=False, + use_wayback=True, + use_gau=True, + gf_pattern="xss", + blind_payload=None, + dalfox_args=[], + keep_temp=True, + verbose=False, + quiet=False, + ) + + +def test_run_scan_happy_path( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, quiet_logger: Logger +) -> None: + """Full scan should succeed and create expected files.""" monkeypatch.setattr(pipeline, "check_binaries", lambda *_: None) monkeypatch.setattr(pipeline, "check_gf_pattern", lambda *_: None) @@ -41,8 +65,7 @@ def fake_run_command(command, input_text, logger): quiet=False, ) - logger = Logger(verbose=False, quiet=True) - result = pipeline.run_scan(config, logger) + result = pipeline.run_scan(config, quiet_logger) base_dir = tmp_path / "example.com" assert base_dir.exists() @@ -54,7 +77,10 @@ def fake_run_command(command, input_text, logger): assert result.findings == 0 -def test_run_scan_no_candidates(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_run_scan_no_candidates( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, quiet_logger: Logger +) -> None: + """Scan with no candidates should skip dalfox.""" monkeypatch.setattr(pipeline, "check_binaries", lambda *_: None) monkeypatch.setattr(pipeline, "check_gf_pattern", lambda *_: None) @@ -84,10 +110,220 @@ def fake_run_command(command, input_text, logger): quiet=False, ) - logger = Logger(verbose=False, quiet=True) - result = pipeline.run_scan(config, logger) + result = pipeline.run_scan(config, quiet_logger) base_dir = tmp_path / "example.com" assert not (base_dir / "example.com_temp_xss.txt").exists() assert (base_dir / "results.txt").exists() assert result.candidate_urls == 0 + + +def test_run_scan_wayback_only( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, quiet_logger: Logger +) -> None: + """Scan with only waybackurls enabled.""" + monkeypatch.setattr(pipeline, "check_binaries", lambda *_: None) + monkeypatch.setattr(pipeline, "check_gf_pattern", lambda *_: None) + + gau_called = False + + def fake_run_command(command, input_text, logger): + nonlocal gau_called + if command[0] == "waybackurls": + return "http://test.com/?id=1\n" + if command[0] == "gau": + gau_called = True + return "" + if command[0] == "gf": + return "http://test.com/?id=1\n" + if command[0] == "dalfox": + return "" + raise AssertionError("unexpected command") + + monkeypatch.setattr(pipeline, "run_command", fake_run_command) + + config = ScanConfig( + domain="example.com", + results_dir=tmp_path, + output_name="results.txt", + overwrite=False, + use_wayback=True, + use_gau=False, + gf_pattern="xss", + blind_payload=None, + dalfox_args=[], + keep_temp=True, + verbose=False, + quiet=False, + ) + + result = pipeline.run_scan(config, quiet_logger) + + assert not gau_called + assert result.total_urls == 1 + + +def test_run_scan_with_blind_payload( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, quiet_logger: Logger +) -> None: + """Scan with blind XSS payload should pass it to dalfox.""" + monkeypatch.setattr(pipeline, "check_binaries", lambda *_: None) + monkeypatch.setattr(pipeline, "check_gf_pattern", lambda *_: None) + + captured_dalfox_cmd = [] + + def fake_run_command(command, input_text, logger): + if command[0] == "waybackurls": + return "http://test.com/?id=1\n" + if command[0] == "gau": + return "" + if command[0] == "gf": + return "http://test.com/?id=1\n" + if command[0] == "dalfox": + captured_dalfox_cmd.extend(command) + return "" + raise AssertionError("unexpected command") + + monkeypatch.setattr(pipeline, "run_command", fake_run_command) + + config = ScanConfig( + domain="example.com", + results_dir=tmp_path, + output_name="results.txt", + overwrite=False, + use_wayback=True, + use_gau=False, + gf_pattern="xss", + blind_payload="https://callback.evil.com", + dalfox_args=[], + keep_temp=True, + verbose=False, + quiet=False, + ) + + pipeline.run_scan(config, quiet_logger) + + assert "-b" in captured_dalfox_cmd + assert "https://callback.evil.com" in captured_dalfox_cmd + assert "-H" in captured_dalfox_cmd + + +def test_run_scan_with_custom_dalfox_args( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, quiet_logger: Logger +) -> None: + """Scan with custom dalfox args should include them.""" + monkeypatch.setattr(pipeline, "check_binaries", lambda *_: None) + monkeypatch.setattr(pipeline, "check_gf_pattern", lambda *_: None) + + captured_dalfox_cmd = [] + + def fake_run_command(command, input_text, logger): + if command[0] == "waybackurls": + return "http://test.com/?id=1\n" + if command[0] == "gau": + return "" + if command[0] == "gf": + return "http://test.com/?id=1\n" + if command[0] == "dalfox": + captured_dalfox_cmd.extend(command) + return "" + raise AssertionError("unexpected command") + + monkeypatch.setattr(pipeline, "run_command", fake_run_command) + + config = ScanConfig( + domain="example.com", + results_dir=tmp_path, + output_name="results.txt", + overwrite=False, + use_wayback=True, + use_gau=False, + gf_pattern="xss", + blind_payload=None, + dalfox_args=["--timeout", "10", "--skip-headless"], + keep_temp=True, + verbose=False, + quiet=False, + ) + + pipeline.run_scan(config, quiet_logger) + + assert "--timeout" in captured_dalfox_cmd + assert "10" in captured_dalfox_cmd + assert "--skip-headless" in captured_dalfox_cmd + + +def test_normalize_lines_strips_whitespace() -> None: + """Should strip whitespace from lines.""" + result = pipeline.normalize_lines([" hello ", " world "]) + assert result == ["hello", "world"] + + +def test_normalize_lines_removes_empty() -> None: + """Should remove empty lines.""" + result = pipeline.normalize_lines(["hello", "", " ", "world"]) + assert result == ["hello", "world"] + + +def test_normalize_lines_empty_input() -> None: + """Should handle empty input.""" + result = pipeline.normalize_lines([]) + assert result == [] + + +def test_normalize_candidate_strips_url_prefix() -> None: + """Should strip 'URL: ' prefix.""" + result = pipeline.normalize_candidate("URL: http://test.com/?id=1") + assert result == "http://test.com/?id=" + + +def test_normalize_candidate_truncates_at_equals() -> None: + """Should truncate value after equals sign.""" + result = pipeline.normalize_candidate("http://test.com/?id=12345") + assert result == "http://test.com/?id=" + + +def test_normalize_candidate_no_equals() -> None: + """Should return unchanged if no equals sign.""" + result = pipeline.normalize_candidate("http://test.com/path") + assert result == "http://test.com/path" + + +def test_normalize_candidate_strips_whitespace() -> None: + """Should strip whitespace.""" + result = pipeline.normalize_candidate(" http://test.com/?id=1 ") + assert result == "http://test.com/?id=" + + +def test_count_findings_counts_non_empty_lines(tmp_path: Path) -> None: + """Should count non-empty lines.""" + results = tmp_path / "results.txt" + results.write_text("finding1\nfinding2\nfinding3\n") + assert pipeline.count_findings(results) == 3 + + +def test_count_findings_ignores_empty_lines(tmp_path: Path) -> None: + """Should ignore empty lines.""" + results = tmp_path / "results.txt" + results.write_text("finding1\n\n\nfinding2\n") + assert pipeline.count_findings(results) == 2 + + +def test_count_findings_missing_file(tmp_path: Path) -> None: + """Should return 0 for missing file.""" + results = tmp_path / "nonexistent.txt" + assert pipeline.count_findings(results) == 0 + + +def test_count_findings_empty_file(tmp_path: Path) -> None: + """Should return 0 for empty file.""" + results = tmp_path / "empty.txt" + results.write_text("") + assert pipeline.count_findings(results) == 0 + + +def test_count_findings_whitespace_only_lines(tmp_path: Path) -> None: + """Should ignore whitespace-only lines.""" + results = tmp_path / "results.txt" + results.write_text("finding1\n \n\t\nfinding2\n") + assert pipeline.count_findings(results) == 2 diff --git a/tests/test_setup.py b/tests/test_setup.py deleted file mode 100644 index d2b8aca..0000000 --- a/tests/test_setup.py +++ /dev/null @@ -1,62 +0,0 @@ -from __future__ import annotations - -from quickxss.models.setup import SetupReport -from quickxss.setup import runner -from quickxss.utils.log import Logger - - -def test_setup_ok(monkeypatch) -> None: - report = SetupReport( - tools={"gf": True, "dalfox": True, "waybackurls": True, "gau": True}, - gf_pattern=True, - os_name="linux", - install_supported=True, - ) - monkeypatch.setattr(runner, "build_report", lambda: report) - logger = Logger(verbose=False, quiet=True) - assert runner.run_setup(False, logger) == 0 - - -def test_setup_missing_check_only(monkeypatch) -> None: - report = SetupReport( - tools={"gf": True, "dalfox": False, "waybackurls": True, "gau": False}, - gf_pattern=False, - os_name="linux", - install_supported=True, - ) - monkeypatch.setattr(runner, "build_report", lambda: report) - logger = Logger(verbose=False, quiet=True) - assert runner.run_setup(False, logger) == 3 - - -def test_setup_install_unsupported(monkeypatch) -> None: - report = SetupReport( - tools={"gf": False, "dalfox": False, "waybackurls": False, "gau": False}, - gf_pattern=False, - os_name="windows", - install_supported=False, - ) - monkeypatch.setattr(runner, "build_report", lambda: report) - logger = Logger(verbose=False, quiet=True) - assert runner.run_setup(True, logger) == 3 - - -def test_setup_install_success(monkeypatch) -> None: - report_missing = SetupReport( - tools={"gf": False, "dalfox": False, "waybackurls": False, "gau": False}, - gf_pattern=False, - os_name="linux", - install_supported=True, - ) - report_ok = SetupReport( - tools={"gf": True, "dalfox": True, "waybackurls": True, "gau": True}, - gf_pattern=True, - os_name="linux", - install_supported=True, - ) - reports = iter([report_missing, report_ok]) - monkeypatch.setattr(runner, "build_report", lambda: next(reports)) - monkeypatch.setattr(runner, "install_missing", lambda *_: None) - - logger = Logger(verbose=False, quiet=True) - assert runner.run_setup(True, logger) == 0 diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 0000000..4bbbbdd --- /dev/null +++ b/tests/utils/__init__.py @@ -0,0 +1 @@ +"""Tests for quickxss.utils module.""" diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py new file mode 100644 index 0000000..569ad2e --- /dev/null +++ b/tests/utils/test_utils.py @@ -0,0 +1,227 @@ +"""Unit tests for quickxss.utils modules.""" + +from __future__ import annotations + +import io +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from quickxss.scan.errors import ToolError +from quickxss.utils.banner import render_banner +from quickxss.utils.exec import run_command +from quickxss.utils.fs import copy_tree +from quickxss.utils.log import Logger +from quickxss.utils.progress import Progress + + +def test_render_banner_returns_string() -> None: + """Banner should return a non-empty string.""" + result = render_banner() + assert isinstance(result, str) + assert len(result) > 0 + + +def test_render_banner_contains_quickxss() -> None: + """Banner should contain 'QuickXSS' in some form.""" + result = render_banner() + assert "Quick" in result or "_" in result or "/" in result + + +@patch("subprocess.run") +def test_run_command_success(mock_run: patch, quiet_logger: Logger) -> None: + """Successful command should not raise.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stderr = "" + mock_run.return_value = mock_result + + run_command(["echo", "hello"], quiet_logger) + mock_run.assert_called_once() + + +@patch("subprocess.run") +def test_run_command_failure_raises_tool_error( + mock_run: patch, quiet_logger: Logger +) -> None: + """Failed command should raise ToolError with stderr.""" + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stderr = "Command failed: error details" + mock_run.return_value = mock_result + + with pytest.raises(ToolError) as exc_info: + run_command(["failing_cmd"], quiet_logger) + assert "Command failed: error details" in str(exc_info.value) + + +@patch("subprocess.run") +def test_run_command_failure_default_message( + mock_run: patch, quiet_logger: Logger +) -> None: + """Failed command with empty stderr should use default message.""" + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stderr = "" + mock_run.return_value = mock_result + + with pytest.raises(ToolError) as exc_info: + run_command(["failing_cmd"], quiet_logger) + assert "Command failed" in str(exc_info.value) + + +@patch("subprocess.run") +def test_run_command_logs_debug(mock_run: patch, verbose_logger: Logger) -> None: + """Command should log debug when verbose.""" + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stderr = "" + mock_run.return_value = mock_result + + captured = io.StringIO() + with patch.object(sys, "stderr", captured): + run_command(["echo", "test"], verbose_logger) + assert "Running:" in captured.getvalue() + + +def test_copy_tree_copies_files(tmp_path: Path) -> None: + """Should copy files from source to destination.""" + source = tmp_path / "source" + source.mkdir() + (source / "file1.txt").write_text("content1") + (source / "file2.txt").write_text("content2") + + dest = tmp_path / "dest" + dest.mkdir() + + copy_tree(source, dest) + + assert (dest / "file1.txt").exists() + assert (dest / "file2.txt").exists() + assert (dest / "file1.txt").read_text() == "content1" + assert (dest / "file2.txt").read_text() == "content2" + + +def test_copy_tree_copies_directories(tmp_path: Path) -> None: + """Should copy directories recursively.""" + source = tmp_path / "source" + source.mkdir() + subdir = source / "subdir" + subdir.mkdir() + (subdir / "nested.txt").write_text("nested content") + + dest = tmp_path / "dest" + dest.mkdir() + + copy_tree(source, dest) + + assert (dest / "subdir").is_dir() + assert (dest / "subdir" / "nested.txt").exists() + assert (dest / "subdir" / "nested.txt").read_text() == "nested content" + + +def test_copy_tree_overwrites_existing(tmp_path: Path) -> None: + """Should overwrite existing files.""" + source = tmp_path / "source" + source.mkdir() + (source / "file.txt").write_text("new content") + + dest = tmp_path / "dest" + dest.mkdir() + (dest / "file.txt").write_text("old content") + + copy_tree(source, dest) + + assert (dest / "file.txt").read_text() == "new content" + + +def test_logger_info_prints_when_not_quiet(capsys) -> None: + """Info should print when quiet is False.""" + logger = Logger(verbose=False, quiet=False) + logger.info("test message") + captured = capsys.readouterr() + assert "test message" in captured.out + + +def test_logger_info_silent_when_quiet(capsys, quiet_logger: Logger) -> None: + """Info should not print when quiet is True.""" + quiet_logger.info("test message") + captured = capsys.readouterr() + assert captured.out == "" + + +def test_logger_warn_prints_to_stderr(capsys) -> None: + """Warn should print to stderr when not quiet.""" + logger = Logger(verbose=False, quiet=False) + logger.warn("warning message") + captured = capsys.readouterr() + assert "warning message" in captured.err + + +def test_logger_warn_silent_when_quiet(capsys, quiet_logger: Logger) -> None: + """Warn should not print when quiet is True.""" + quiet_logger.warn("warning message") + captured = capsys.readouterr() + assert captured.err == "" + + +def test_logger_error_always_prints(capsys, quiet_logger: Logger) -> None: + """Error should always print to stderr even when quiet.""" + quiet_logger.error("error message") + captured = capsys.readouterr() + assert "error message" in captured.err + + +def test_logger_debug_prints_when_verbose(capsys, verbose_logger: Logger) -> None: + """Debug should print when verbose is True and quiet is False.""" + verbose_logger.debug("debug message") + captured = capsys.readouterr() + assert "debug message" in captured.err + + +def test_logger_debug_silent_when_not_verbose(capsys) -> None: + """Debug should not print when verbose is False.""" + logger = Logger(verbose=False, quiet=False) + logger.debug("debug message") + captured = capsys.readouterr() + assert captured.err == "" + + +def test_logger_debug_silent_when_quiet(capsys) -> None: + """Debug should not print when quiet is True even if verbose.""" + logger = Logger(verbose=True, quiet=True) + logger.debug("debug message") + captured = capsys.readouterr() + assert captured.err == "" + + +def test_progress_enabled_creates_console() -> None: + """Progress with enabled=True should create a console.""" + progress = Progress(enabled=True) + assert progress._console is not None + + +def test_progress_disabled_no_console() -> None: + """Progress with enabled=False should not create a console.""" + progress = Progress(enabled=False) + assert progress._console is None + + +def test_progress_task_context_manager_works() -> None: + """Task context manager should execute wrapped code.""" + progress = Progress(enabled=False) + executed = False + with progress.task("Testing..."): + executed = True + assert executed + + +def test_progress_task_enabled_context_manager() -> None: + """Task context manager with enabled progress should work.""" + progress = Progress(enabled=True) + executed = False + with progress.task("Testing..."): + executed = True + assert executed