From 1adcfb225ae8591f07afb1b15e01fc51823fb843 Mon Sep 17 00:00:00 2001 From: Tomas Gonzalez Date: Thu, 19 Jun 2025 09:28:51 -0400 Subject: [PATCH 1/2] improving CLI tests for robustness --- tests/unit/cli/conftest.py | 148 ++++++ tests/unit/cli/test_argument_parsing.py | 214 +++++++++ tests/unit/cli/test_argument_validation.py | 238 +++++++++ tests/unit/cli/test_cli.py | 531 --------------------- tests/unit/cli/test_main_function.py | 274 +++++++++++ 5 files changed, 874 insertions(+), 531 deletions(-) create mode 100644 tests/unit/cli/conftest.py create mode 100644 tests/unit/cli/test_argument_parsing.py create mode 100644 tests/unit/cli/test_argument_validation.py delete mode 100644 tests/unit/cli/test_cli.py create mode 100644 tests/unit/cli/test_main_function.py diff --git a/tests/unit/cli/conftest.py b/tests/unit/cli/conftest.py new file mode 100644 index 0000000..3ad21b0 --- /dev/null +++ b/tests/unit/cli/conftest.py @@ -0,0 +1,148 @@ +import pytest +from unittest.mock import MagicMock, patch +import argparse +import os +import sys + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src')) + +from workbench_cli.cli import parse_cmdline_args + + +@pytest.fixture +def mock_path_exists(): + """Mock os.path.exists to return True by default.""" + with patch('os.path.exists', return_value=True) as mock: + yield mock + + +@pytest.fixture +def base_args(): + """Base argument list with required credentials.""" + return [ + 'workbench-cli', + '--api-url', 'https://test.com', + '--api-user', 'testuser', + '--api-token', 'testtoken' + ] + + +@pytest.fixture +def arg_parser(): + """Create a fresh argument parser for each test.""" + def _create_parser_with_args(args_list): + """Parse arguments without affecting sys.argv.""" + # Import inside function to avoid import order issues + from workbench_cli.cli import parse_cmdline_args + with patch('sys.argv', args_list): + return parse_cmdline_args() + return _create_parser_with_args + + +@pytest.fixture +def mock_main_dependencies(): + """Mock all main() function dependencies.""" + mocks = {} + + # Mock WorkbenchAPI + with patch("workbench_cli.main.WorkbenchAPI") as mock_wb: + mocks['workbench_api'] = mock_wb + mocks['workbench_instance'] = MagicMock() + mock_wb.return_value = mocks['workbench_instance'] + + # Mock all handlers + with patch("workbench_cli.main.handle_scan") as mock_scan: + mocks['handle_scan'] = mock_scan + + with patch("workbench_cli.main.handle_scan_git") as mock_scan_git: + mocks['handle_scan_git'] = mock_scan_git + + with patch("workbench_cli.main.handle_import_da") as mock_import: + mocks['handle_import_da'] = mock_import + + with patch("workbench_cli.main.handle_show_results") as mock_show: + mocks['handle_show_results'] = mock_show + + with patch("workbench_cli.main.handle_download_reports") as mock_download: + mocks['handle_download_reports'] = mock_download + + with patch("workbench_cli.main.handle_evaluate_gates") as mock_gates: + mocks['handle_evaluate_gates'] = mock_gates + + yield mocks + + +class ArgBuilder: + """Builder pattern for constructing test arguments.""" + + def __init__(self): + self.args = [ + 'workbench-cli', + '--api-url', 'https://test.com', + '--api-user', 'testuser', + '--api-token', 'testtoken' + ] + + def scan(self, project='TestProject', scan='TestScan', path='.'): + self.args.extend(['scan', '--project-name', project, '--scan-name', scan, '--path', path]) + return self + + def scan_git(self, project='TestProject', scan='TestScan', git_url='https://git.com/repo.git'): + self.args.extend(['scan-git', '--project-name', project, '--scan-name', scan, '--git-url', git_url]) + return self + + def git_branch(self, branch='main'): + self.args.extend(['--git-branch', branch]) + return self + + def git_tag(self, tag='v1.0'): + self.args.extend(['--git-tag', tag]) + return self + + def git_commit(self, commit='abc123'): + self.args.extend(['--git-commit', commit]) + return self + + def import_da(self, project='TestProject', scan='TestScan', path='results.json'): + self.args.extend(['import-da', '--project-name', project, '--scan-name', scan, '--path', path]) + return self + + def download_reports(self, scope='scan'): + self.args.extend(['download-reports', '--report-scope', scope]) + return self + + def project_name(self, name): + self.args.extend(['--project-name', name]) + return self + + def scan_name(self, name): + self.args.extend(['--scan-name', name]) + return self + + def show_results(self, project='TestProject', scan='TestScan'): + self.args.extend(['show-results', '--project-name', project, '--scan-name', scan]) + return self + + def show_licenses(self): + self.args.append('--show-licenses') + return self + + def id_reuse(self, reuse_type='any', source=None): + self.args.extend(['--id-reuse', '--id-reuse-type', reuse_type]) + if source: + self.args.extend(['--id-reuse-source', source]) + return self + + def log_level(self, level='INFO'): + self.args.extend(['--log', level]) + return self + + def build(self): + return self.args.copy() + + +@pytest.fixture +def args(): + """Fixture providing the ArgBuilder for constructing test arguments.""" + return ArgBuilder \ No newline at end of file diff --git a/tests/unit/cli/test_argument_parsing.py b/tests/unit/cli/test_argument_parsing.py new file mode 100644 index 0000000..3a6f13e --- /dev/null +++ b/tests/unit/cli/test_argument_parsing.py @@ -0,0 +1,214 @@ +"""Test basic argument parsing functionality.""" + +import pytest +from unittest.mock import patch +import os +import sys + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src')) + +from workbench_cli.cli import parse_cmdline_args + + +class TestBasicCommandParsing: + """Test basic command parsing without validation logic.""" + + def test_parse_scan_command(self, args, arg_parser, mock_path_exists): + """Test basic scan command parsing.""" + cmd_args = args().scan(project='MyProject', scan='MyScan', path='.').build() + parsed = arg_parser(cmd_args) + + assert parsed.command == 'scan' + assert parsed.project_name == 'MyProject' + assert parsed.scan_name == 'MyScan' + assert parsed.path == '.' + assert parsed.api_url == 'https://test.com/api.php' # Check URL fix + assert parsed.limit == 10 # Check default + assert parsed.log == 'INFO' # Check default + + def test_parse_scan_git_with_branch(self, args, arg_parser): + """Test scan-git command with branch.""" + cmd_args = (args() + .scan_git(project='GitProject', scan='GitScan', git_url='https://github.com/owner/repo.git') + .git_branch('develop') + .build()) + parsed = arg_parser(cmd_args) + + assert parsed.command == 'scan-git' + assert parsed.project_name == 'GitProject' + assert parsed.scan_name == 'GitScan' + assert parsed.git_url == 'https://github.com/owner/repo.git' + assert parsed.git_branch == 'develop' + assert parsed.git_tag is None + assert parsed.git_commit is None + + def test_parse_scan_git_with_tag(self, args, arg_parser): + """Test scan-git command with tag.""" + cmd_args = (args() + .scan_git() + .git_tag('v2.0') + .build()) + parsed = arg_parser(cmd_args) + + assert parsed.command == 'scan-git' + assert parsed.git_tag == 'v2.0' + assert parsed.git_branch is None + assert parsed.git_commit is None + + def test_parse_scan_git_with_commit(self, args, arg_parser): + """Test scan-git command with commit.""" + cmd_args = (args() + .scan_git() + .git_commit('abc123def') + .build()) + parsed = arg_parser(cmd_args) + + assert parsed.command == 'scan-git' + assert parsed.git_commit == 'abc123def' + assert parsed.git_branch is None + assert parsed.git_tag is None + + def test_parse_import_da_command(self, args, arg_parser, mock_path_exists): + """Test import-da command parsing.""" + cmd_args = args().import_da(project='DAProject', scan='DAScan', path='results.json').build() + parsed = arg_parser(cmd_args) + + assert parsed.command == 'import-da' + assert parsed.project_name == 'DAProject' + assert parsed.scan_name == 'DAScan' + assert parsed.path == 'results.json' + + def test_parse_download_reports_scan_scope(self, args, arg_parser): + """Test download-reports with scan scope.""" + cmd_args = (args() + .download_reports(scope='scan') + .scan_name('TestScan') + .build()) + parsed = arg_parser(cmd_args) + + assert parsed.command == 'download-reports' + assert parsed.report_scope == 'scan' + assert parsed.scan_name == 'TestScan' + assert parsed.report_type == 'ALL' # Default + assert parsed.report_save_path == '.' # Default + + def test_parse_download_reports_project_scope(self, args, arg_parser): + """Test download-reports with project scope.""" + cmd_args = (args() + .download_reports(scope='project') + .project_name('TestProject') + .build()) + parsed = arg_parser(cmd_args) + + assert parsed.command == 'download-reports' + assert parsed.report_scope == 'project' + assert parsed.project_name == 'TestProject' + assert parsed.scan_name is None + + def test_parse_show_results_command(self, args, arg_parser): + """Test show-results command parsing.""" + cmd_args = (args() + .show_results(project='ShowProject', scan='ShowScan') + .show_licenses() + .build()) + parsed = arg_parser(cmd_args) + + assert parsed.command == 'show-results' + assert parsed.project_name == 'ShowProject' + assert parsed.scan_name == 'ShowScan' + assert parsed.show_licenses is True + assert parsed.show_components is False # Default + + +class TestFlagsAndDefaults: + """Test flag parsing and default values.""" + + def test_parse_flags_and_log_level(self, args, arg_parser, mock_path_exists): + """Test various flags and log level.""" + cmd_args = (args() + .log_level('DEBUG') + .scan() + .build()) + # Add flags manually for this test + cmd_args.extend(['--delta-scan', '--autoid-pending-ids']) + + parsed = arg_parser(cmd_args) + + assert parsed.log == 'DEBUG' + assert parsed.delta_scan is True + assert parsed.autoid_pending_ids is True + assert parsed.autoid_file_licenses is False # Default + assert parsed.run_dependency_analysis is False # Default + + def test_id_reuse_parameters(self, args, arg_parser, mock_path_exists): + """Test ID reuse parameter parsing.""" + # Test project reuse type + cmd_args = (args() + .scan() + .id_reuse(reuse_type='project', source='ReusePrj') + .build()) + parsed = arg_parser(cmd_args) + + assert parsed.id_reuse is True + assert parsed.id_reuse_type == 'project' + assert parsed.id_reuse_source == 'ReusePrj' + + # Test scan reuse type + cmd_args = (args() + .scan() + .id_reuse(reuse_type='scan', source='ReuseScan') + .build()) + parsed = arg_parser(cmd_args) + + assert parsed.id_reuse is True + assert parsed.id_reuse_type == 'scan' + assert parsed.id_reuse_source == 'ReuseScan' + + +class TestEnvironmentVariables: + """Test environment variable handling.""" + + def test_credentials_from_env_vars(self, arg_parser, mock_path_exists): + """Test parsing credentials from environment variables.""" + env_vars = { + "WORKBENCH_URL": "http://env.com", + "WORKBENCH_USER": "env_user", + "WORKBENCH_TOKEN": "env_token" + } + + with patch.dict(os.environ, env_vars, clear=True): + # No credential args in command line + cmd_args = ['workbench-cli', 'scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.'] + parsed = arg_parser(cmd_args) + + assert parsed.api_url == 'http://env.com/api.php' # Check URL fix + assert parsed.api_user == 'env_user' + assert parsed.api_token == 'env_token' + + +class TestUrlHandling: + """Test API URL handling and normalization.""" + + def test_api_url_normalization(self, args, arg_parser, mock_path_exists): + """Test that API URLs are properly normalized.""" + # Test URL without /api.php + base_cmd = ['workbench-cli', '--api-url', 'https://example.com', '--api-user', 'user', '--api-token', 'token'] + scan_cmd = base_cmd + ['scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.'] + + parsed = arg_parser(scan_cmd) + assert parsed.api_url == 'https://example.com/api.php' + + # Test URL with trailing slash + base_cmd = ['workbench-cli', '--api-url', 'https://example.com/', '--api-user', 'user', '--api-token', 'token'] + scan_cmd = base_cmd + ['scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.'] + + parsed = arg_parser(scan_cmd) + assert parsed.api_url == 'https://example.com/api.php' + + # Test URL already with /api.php + base_cmd = ['workbench-cli', '--api-url', 'https://example.com/api.php', '--api-user', 'user', '--api-token', 'token'] + scan_cmd = base_cmd + ['scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.'] + + parsed = arg_parser(scan_cmd) + assert parsed.api_url == 'https://example.com/api.php' \ No newline at end of file diff --git a/tests/unit/cli/test_argument_validation.py b/tests/unit/cli/test_argument_validation.py new file mode 100644 index 0000000..5e6e2b5 --- /dev/null +++ b/tests/unit/cli/test_argument_validation.py @@ -0,0 +1,238 @@ +"""Test argument validation logic.""" + +import pytest +from unittest.mock import patch +import os +import sys +import re + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src')) + +from workbench_cli.cli import parse_cmdline_args +from workbench_cli.exceptions import ValidationError + + +class TestValidationRules: + """Test argument validation rules.""" + + def test_id_reuse_missing_source_project(self, args, arg_parser, mock_path_exists): + """Test validation when ID reuse source is missing for project type.""" + cmd_args = (args() + .scan() + .id_reuse(reuse_type='project', source=None) # Missing source + .build()) + + with pytest.raises(ValidationError, match="ID reuse source project/scan name is required"): + arg_parser(cmd_args) + + def test_id_reuse_missing_source_scan(self, args, arg_parser, mock_path_exists): + """Test validation when ID reuse source is missing for scan type.""" + cmd_args = (args() + .scan() + .id_reuse(reuse_type='scan', source=None) # Missing source + .build()) + + with pytest.raises(ValidationError, match="ID reuse source project/scan name is required"): + arg_parser(cmd_args) + + def test_download_reports_missing_project_for_project_scope(self, args, arg_parser): + """Test validation when project name is missing for project scope reports.""" + cmd_args = (args() + .download_reports(scope='project') + # Missing project_name + .build()) + + with pytest.raises(ValidationError, match="Project name is required for project scope report"): + arg_parser(cmd_args) + + def test_download_reports_missing_scan_for_scan_scope(self, args, arg_parser): + """Test validation when scan name is missing for scan scope reports.""" + cmd_args = (args() + .download_reports(scope='scan') + # Missing scan_name + .build()) + + with pytest.raises(ValidationError, match="Scan name is required for scan scope report"): + arg_parser(cmd_args) + + def test_show_results_missing_show_flags(self, args, arg_parser): + """Test validation when no show flags are provided for show-results.""" + cmd_args = (args() + .show_results(project='P', scan='S') + # No show flags added + .build()) + + with pytest.raises(ValidationError, match=re.escape("At least one '--show-*' flag must be provided")): + arg_parser(cmd_args) + + def test_scan_non_existent_path(self, args, arg_parser): + """Test validation when scan path doesn't exist.""" + with patch('os.path.exists', return_value=False): + cmd_args = args().scan(path='/non/existent/path').build() + + with pytest.raises(ValidationError, match=re.escape("Path does not exist: /non/existent/path")): + arg_parser(cmd_args) + + def test_import_da_non_existent_path(self, args, arg_parser): + """Test validation when import-da path doesn't exist.""" + with patch('os.path.exists', return_value=False): + cmd_args = args().import_da(path='/non/existent/file.json').build() + + with pytest.raises(ValidationError, match=re.escape("Path does not exist: /non/existent/file.json")): + arg_parser(cmd_args) + + +class TestArgparseValidation: + """Test validation handled by argparse itself (results in SystemExit).""" + + def test_scan_git_branch_and_tag_conflict(self, args, arg_parser): + """Test that specifying both branch and tag raises SystemExit.""" + cmd_args = (args() + .scan_git() + .git_branch('main') + .git_tag('v1.0') # Conflicting with branch + .build()) + + with pytest.raises(SystemExit): + arg_parser(cmd_args) + + def test_scan_git_branch_and_commit_conflict(self, args, arg_parser): + """Test that specifying both branch and commit raises SystemExit.""" + cmd_args = (args() + .scan_git() + .git_branch('main') + .git_commit('abc123') # Conflicting with branch + .build()) + + with pytest.raises(SystemExit): + arg_parser(cmd_args) + + def test_scan_git_tag_and_commit_conflict(self, args, arg_parser): + """Test that specifying both tag and commit raises SystemExit.""" + cmd_args = (args() + .scan_git() + .git_tag('v1.0') + .git_commit('abc123') # Conflicting with tag + .build()) + + with pytest.raises(SystemExit): + arg_parser(cmd_args) + + def test_scan_git_missing_reference(self, args, arg_parser): + """Test that scan-git without branch/tag/commit raises SystemExit.""" + cmd_args = (args() + .scan_git() + # No git reference specified + .build()) + + with pytest.raises(SystemExit): + arg_parser(cmd_args) + + def test_missing_credentials_raises_system_exit(self, arg_parser, mock_path_exists): + """Test that missing credentials raise SystemExit.""" + with patch.dict(os.environ, {"WORKBENCH_URL": "", "WORKBENCH_USER": "", "WORKBENCH_TOKEN": ""}, clear=True): + cmd_args = ['workbench-cli', 'scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.'] + + with pytest.raises(SystemExit): + arg_parser(cmd_args) + + def test_no_command_raises_system_exit(self, arg_parser): + """Test that missing command raises SystemExit.""" + cmd_args = ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z'] + + with pytest.raises(SystemExit): + arg_parser(cmd_args) + + def test_scan_missing_path_raises_system_exit(self, arg_parser): + """Test that scan without path raises SystemExit.""" + cmd_args = ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', + 'scan', '--project-name', 'P', '--scan-name', 'S'] + + with pytest.raises(SystemExit): + arg_parser(cmd_args) + + def test_scan_git_missing_url_raises_system_exit(self, arg_parser): + """Test that scan-git without URL raises SystemExit.""" + cmd_args = ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', + 'scan-git', '--project-name', 'P', '--scan-name', 'S', '--git-branch', 'main'] + + with pytest.raises(SystemExit): + arg_parser(cmd_args) + + def test_import_da_missing_path_raises_system_exit(self, arg_parser): + """Test that import-da without path raises SystemExit.""" + cmd_args = ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', + 'import-da', '--project-name', 'P', '--scan-name', 'S'] + + with pytest.raises(SystemExit): + arg_parser(cmd_args) + + def test_unknown_command_raises_system_exit(self, arg_parser): + """Test that unknown command raises SystemExit.""" + cmd_args = ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'unknown-command'] + + with pytest.raises(SystemExit): + arg_parser(cmd_args) + + +class TestValidationLogic: + """Test custom validation logic behavior.""" + + def test_id_reuse_source_ignored_for_any_type(self, args, arg_parser, mock_path_exists): + """Test that ID reuse source is ignored for 'any' type.""" + cmd_args = (args() + .scan() + .id_reuse(reuse_type='any', source='UnneededSource') + .build()) + + parsed = arg_parser(cmd_args) + + assert parsed.id_reuse is True + assert parsed.id_reuse_type == 'any' + assert parsed.id_reuse_source is None # Should be ignored + + def test_id_reuse_source_ignored_for_only_me_type(self, args, arg_parser, mock_path_exists): + """Test that ID reuse source is ignored for 'only_me' type.""" + cmd_args = (args() + .scan() + .id_reuse(reuse_type='only_me', source='UnneededSource') + .build()) + + parsed = arg_parser(cmd_args) + + assert parsed.id_reuse is True + assert parsed.id_reuse_type == 'only_me' + assert parsed.id_reuse_source is None # Should be ignored + + +class TestEdgeCases: + """Test edge cases and boundary conditions.""" + + def test_empty_environment_variables(self, arg_parser, mock_path_exists): + """Test behavior with empty environment variables.""" + env_vars = {"WORKBENCH_URL": "", "WORKBENCH_USER": "", "WORKBENCH_TOKEN": ""} + + with patch.dict(os.environ, env_vars, clear=True): + # Should require command-line credentials + cmd_args = ['workbench-cli', '--api-url', 'https://test.com', '--api-user', 'user', + '--api-token', 'token', 'scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.'] + + parsed = arg_parser(cmd_args) + assert parsed.api_url == 'https://test.com/api.php' + assert parsed.api_user == 'user' + assert parsed.api_token == 'token' + + def test_partial_environment_variables(self, arg_parser, mock_path_exists): + """Test behavior with partial environment variables.""" + env_vars = {"WORKBENCH_URL": "https://env.com", "WORKBENCH_USER": "", "WORKBENCH_TOKEN": ""} + + with patch.dict(os.environ, env_vars, clear=True): + # Should still require missing credentials via command line + cmd_args = ['workbench-cli', '--api-user', 'cmduser', '--api-token', 'cmdtoken', + 'scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.'] + + parsed = arg_parser(cmd_args) + assert parsed.api_url == 'https://env.com/api.php' # From env + assert parsed.api_user == 'cmduser' # From command line + assert parsed.api_token == 'cmdtoken' # From command line \ No newline at end of file diff --git a/tests/unit/cli/test_cli.py b/tests/unit/cli/test_cli.py deleted file mode 100644 index 5431a12..0000000 --- a/tests/unit/cli/test_cli.py +++ /dev/null @@ -1,531 +0,0 @@ -# tests/test_cli.py - -import pytest -from unittest.mock import patch, MagicMock -import argparse -import os # Added for environ patch -import re -import sys - -# Add the src directory to the path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src')) - -# Import the function to test -from workbench_cli.main import main # Correct import -from workbench_cli.cli import parse_cmdline_args, add_common_scan_options # Keep this if testing parsing separately - -from workbench_cli.exceptions import ( - WorkbenchCLIError, - ApiError, - NetworkError, - ConfigurationError, - AuthenticationError, - ProcessError, - ProcessTimeoutError, - FileSystemError, - ValidationError, # Keep for direct validation errors - CompatibilityError, - ProjectNotFoundError, - ScanNotFoundError, - ProjectExistsError, - ScanExistsError -) - -# --- Basic Command Parsing --- - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.']) -@patch('os.path.exists', return_value=True) # Mock path validation -def test_parse_scan_command(mock_exists): - args = parse_cmdline_args() - assert args.command == 'scan' - assert args.project_name == 'P' - assert args.scan_name == 'S' - assert args.path == '.' - assert args.api_url == 'X/api.php' # Check URL fix - assert args.api_user == 'Y' - assert args.api_token == 'Z' - assert args.limit == 10 # Check default - assert args.log == 'INFO' # Check default log level - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'download-reports', '--scan-name', 'S1', '--report-save-path', '/tmp/reports']) -def test_parse_download_reports_scan_scope(): - args = parse_cmdline_args() - assert args.command == 'download-reports' - assert args.report_scope == 'scan' # Check default scope - assert args.scan_name == 'S1' - assert args.project_name is None - assert args.report_type == 'ALL' # Check default type - assert args.report_save_path == '/tmp/reports' # Check non-default path - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'download-reports', '--project-name', 'P1', '--report-scope', 'project', '--report-type', 'xlsx']) -def test_parse_download_reports_project_scope(): - args = parse_cmdline_args() - assert args.command == 'download-reports' - assert args.report_scope == 'project' - assert args.project_name == 'P1' - assert args.scan_name is None # scan-name is optional if scope is project - assert args.report_type == 'xlsx' - assert args.report_save_path == '.' # Check default path - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan-git', '--project-name', 'PG', '--scan-name', 'SG', '--git-url', 'http://git.com', '--git-branch', 'dev']) -def test_parse_scan_git_branch(): - args = parse_cmdline_args() - assert args.command == 'scan-git' - assert args.project_name == 'PG' - assert args.scan_name == 'SG' - assert args.git_url == 'http://git.com' - assert args.git_branch == 'dev' - assert args.git_tag is None - assert args.git_commit is None # Verify git_commit is None - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan-git', '--project-name', 'PG', '--scan-name', 'SG', '--git-url', 'http://git.com', '--git-tag', 'v1.0']) -def test_parse_scan_git_tag(): - args = parse_cmdline_args() - assert args.command == 'scan-git' - assert args.git_tag == 'v1.0' - assert args.git_branch is None - assert args.git_commit is None # Verify git_commit is None - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan-git', '--project-name', 'PG', '--scan-name', 'SG', '--git-url', 'http://git.com', '--git-commit', 'abc123']) -def test_parse_scan_git_commit(): - args = parse_cmdline_args() - assert args.command == 'scan-git' - assert args.project_name == 'PG' - assert args.scan_name == 'SG' - assert args.git_url == 'http://git.com' - assert args.git_commit == 'abc123' - assert args.git_branch is None # Verify git_branch is None - assert args.git_tag is None # Verify git_tag is None - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'import-da', '--project-name', 'P', '--scan-name', 'S', '--path', 'results.json']) -@patch('os.path.exists', return_value=True) # Mock path validation -def test_parse_import_da(mock_exists): - args = parse_cmdline_args() - assert args.command == 'import-da' - assert args.project_name == 'P' - assert args.scan_name == 'S' - assert args.path == 'results.json' - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'evaluate-gates', '--project-name', 'P', '--scan-name', 'S', '--show-pending-files']) -def test_parse_evaluate_gates(): - args = parse_cmdline_args() - assert args.command == 'evaluate-gates' - assert args.project_name == 'P' - assert args.scan_name == 'S' - assert args.show_pending_files is True - assert args.fail_on_vuln_severity is None - assert args.fail_on_pending is False - assert args.fail_on_policy is False - -# --- Test Flags and Defaults --- - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', '--log', 'DEBUG', 'scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.', '--delta-scan', '--autoid-pending-ids']) -@patch('os.path.exists', return_value=True) # Mock path validation -def test_parse_flags_and_log_level(mock_exists): - args = parse_cmdline_args() - assert args.log == 'DEBUG' - assert args.delta_scan is True - assert args.autoid_pending_ids is True - assert args.autoid_file_licenses is False # Check default - assert args.run_dependency_analysis is False # Check default - -# --- Test Validation Logic --- - -# Use ValidationError where the custom validation logic raises it directly -# Use SystemExit where argparse itself is expected to exit - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.', '--id-reuse', '--id-reuse-type', 'project']) -@patch('os.path.exists', return_value=True) -def test_parse_validation_id_reuse_missing_source(mock_exists): - with pytest.raises(ValidationError, match="ID reuse source project/scan name is required"): - parse_cmdline_args() - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'download-reports', '--report-scope', 'project']) -def test_parse_validation_download_missing_project(): - with pytest.raises(ValidationError, match="Project name is required for project scope report"): - parse_cmdline_args() - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'download-reports', '--report-scope', 'scan']) -def test_parse_validation_download_missing_scan(): - with pytest.raises(ValidationError, match="Scan name is required for scan scope report"): - parse_cmdline_args() - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'show-results', '--project-name', 'P', '--scan-name', 'S']) -def test_parse_validation_show_results_missing_show_flag(): - with pytest.raises(ValidationError, match=re.escape("At least one '--show-*' flag must be provided")): - parse_cmdline_args() - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan-git', '--project-name', 'PG', '--scan-name', 'SG', '--git-url', 'http://git.com', '--git-branch', 'dev', '--git-tag', 'v1']) -def test_parse_validation_scan_git_branch_and_tag(): - with pytest.raises(SystemExit): - parse_cmdline_args() - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan-git', '--project-name', 'PG', '--scan-name', 'SG', '--git-url', 'http://git.com', '--git-branch', 'dev', '--git-commit', 'abc123']) -def test_parse_validation_scan_git_branch_and_commit(): - with pytest.raises(SystemExit): # Argparse handles this validation, raising SystemExit - parse_cmdline_args() - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan-git', '--project-name', 'PG', '--scan-name', 'SG', '--git-url', 'http://git.com', '--git-tag', 'v1.0', '--git-commit', 'abc123']) -def test_parse_validation_scan_git_tag_and_commit(): - with pytest.raises(SystemExit): # Argparse handles this validation, raising SystemExit - parse_cmdline_args() - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan-git', '--project-name', 'PG', '--scan-name', 'SG', '--git-url', 'http://git.com']) -def test_parse_validation_scan_git_missing_ref(): - with pytest.raises(SystemExit): # Argparse handles this validation, raising SystemExit - parse_cmdline_args() - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan', '--project-name', 'P', '--scan-name', 'S', '--path', '/non/existent/path']) -@patch('os.path.exists', return_value=False) # Mock os.path.exists -def test_parse_validation_scan_non_existent_path(mock_exists): - with pytest.raises(ValidationError, match=re.escape("Path does not exist: /non/existent/path")): - parse_cmdline_args() - mock_exists.assert_any_call('/non/existent/path') - -# Test missing credentials (if not provided by env vars) -@patch.dict(os.environ, {"WORKBENCH_URL": "", "WORKBENCH_USER": "", "WORKBENCH_TOKEN": ""}, clear=True) -@patch('sys.argv', ['workbench-cli', 'scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.']) -@patch('os.path.exists', return_value=True) -def test_parse_validation_missing_credentials(mock_exists): - with pytest.raises(SystemExit): - parse_cmdline_args() - -# --- ADDED TEST: Test credentials from environment variables --- -@patch.dict(os.environ, {"WORKBENCH_URL": "http://env.com", "WORKBENCH_USER": "env_user", "WORKBENCH_TOKEN": "env_token"}, clear=True) -@patch('sys.argv', ['workbench-cli', 'scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.']) # No credential args -@patch('os.path.exists', return_value=True) # Assume path exists -def test_parse_credentials_from_env_vars(mock_exists): - try: - args = parse_cmdline_args() - assert args.api_url == 'http://env.com/api.php' # Check URL fix too - assert args.api_user == 'env_user' - assert args.api_token == 'env_token' - except (ValidationError, SystemExit) as e: - pytest.fail(f"Parsing failed unexpectedly when using env vars: {e}") - -# --- More Specific Validation Tests --- - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z']) # No command -def test_parse_args_no_command(): - # Argparse itself might exit or raise, depending on setup. - with pytest.raises(SystemExit): - parse_cmdline_args() - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan', '--project-name', 'P', '--scan-name', 'S']) # No path -def test_parse_args_scan_no_path(): - with pytest.raises(SystemExit): - parse_cmdline_args() - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan-git', '--project-name', 'P', '--scan-name', 'S', '--git-branch', 'main']) # No git url -def test_parse_args_scan_git_no_url(): - with pytest.raises(SystemExit): - parse_cmdline_args() - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'import-da', '--project-name', 'P', '--scan-name', 'S']) # No path -def test_parse_args_import_da_no_path(): - with pytest.raises(SystemExit): - parse_cmdline_args() - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'unknown-command']) -def test_parse_args_unknown_command(): - # Argparse usually handles unknown commands with SystemExit - with pytest.raises(SystemExit): - parse_cmdline_args() - -# --- Test main() Exception Handling --- - -# Mock the handler functions used by main -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_success(mock_parse, mock_wb, mock_handle_scan): - # Configure mocks - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - - mock_workbench_instance = MagicMock() - mock_wb.return_value = mock_workbench_instance - - mock_handle_scan.return_value = True - - result = main() - assert result == 0 - -def test_main_validation_error(): - # Simulate parse_cmdline_args raising the error - with patch('workbench_cli.main.parse_cmdline_args', side_effect=ValidationError("Test validation error")): - result = main() - assert result == 1 - -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_configuration_error(mock_parse, mock_wb, mock_handle_scan): - # Configure mocks - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - - # Simulate API init raising an error - mock_wb.side_effect = ConfigurationError("Test config error") - - result = main() - assert result == 1 - mock_handle_scan.assert_not_called() - -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_authentication_error(mock_parse, mock_wb, mock_handle_scan): - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - mock_wb.side_effect = AuthenticationError("Auth error") - result = main() - assert result == 1 - -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_project_not_found(mock_parse, mock_wb, mock_handle_scan): - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - mock_handle_scan.side_effect = ProjectNotFoundError("Project not found") - result = main() - assert result == 1 - -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_scan_not_found(mock_parse, mock_wb, mock_handle_scan): - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - mock_handle_scan.side_effect = ScanNotFoundError("Scan not found") - result = main() - assert result == 1 - -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_api_error(mock_parse, mock_wb, mock_handle_scan): - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - mock_handle_scan.side_effect = ApiError("API error") - result = main() - assert result == 1 - -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_network_error(mock_parse, mock_wb, mock_handle_scan): - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - mock_handle_scan.side_effect = NetworkError("Network error") - result = main() - assert result == 1 - -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_process_error(mock_parse, mock_wb, mock_handle_scan): - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - mock_handle_scan.side_effect = ProcessError("Process error") - result = main() - assert result == 1 - -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_process_timeout(mock_parse, mock_wb, mock_handle_scan): - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - mock_handle_scan.side_effect = ProcessTimeoutError("Timeout error") - result = main() - assert result == 1 - -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_file_system_error(mock_parse, mock_wb, mock_handle_scan): - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - mock_handle_scan.side_effect = FileSystemError("FS error") - result = main() - assert result == 1 - -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_compatibility_error(mock_parse, mock_wb, mock_handle_scan): - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - mock_wb.return_value = MagicMock() - mock_handle_scan.side_effect = CompatibilityError("Compatibility error") - result = main() - assert result == 1 - -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_unexpected_error(mock_parse, mock_wb, mock_handle_scan): - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - mock_wb.return_value = MagicMock() - mock_handle_scan.side_effect = Exception("Unexpected error") - result = main() - assert result == 1 - -@patch("workbench_cli.main.handle_evaluate_gates") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_evaluate_gates_fail_returns_1(mock_parse, mock_wb, mock_handle_gates): - mock_args = MagicMock(command="evaluate-gates", log="INFO") - mock_parse.return_value = mock_args - mock_wb.return_value = MagicMock() - mock_handle_gates.return_value = False - result = main() - assert result == 1 - -# Test ID reuse parameters -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.', '--id-reuse', '--id-reuse-type', 'project', '--id-reuse-source', 'ReusePrj']) -@patch('os.path.exists', return_value=True) -def test_parse_id_reuse_project(mock_exists): - args = parse_cmdline_args() - assert args.id_reuse is True - assert args.id_reuse_type == 'project' - assert args.id_reuse_source == 'ReusePrj' - -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.', '--id-reuse', '--id-reuse-type', 'scan', '--id-reuse-source', 'ReuseScan']) -@patch('os.path.exists', return_value=True) -def test_parse_id_reuse_scan(mock_exists): - args = parse_cmdline_args() - assert args.id_reuse is True - assert args.id_reuse_type == 'scan' - assert args.id_reuse_source == 'ReuseScan' - -# Test behavior when id-reuse-source is provided with id-reuse-type that doesn't need it -@patch('sys.argv', ['workbench-cli', '--api-url', 'X', '--api-user', 'Y', '--api-token', 'Z', 'scan', '--project-name', 'P', '--scan-name', 'S', '--path', '.', '--id-reuse', '--id-reuse-type', 'any', '--id-reuse-source', 'UnneededSource']) -@patch('os.path.exists', return_value=True) -def test_parse_id_reuse_source_ignored(mock_exists): - # The warning is logged directly in parse_cmdline_args, not through the logger mock - # We can only check the result of the parsing - args = parse_cmdline_args() - assert args.id_reuse is True - assert args.id_reuse_type == 'any' - assert args.id_reuse_source is None # Source should be ignored for 'any' type - -# Test help text formatting -@patch('sys.argv', ['workbench-cli', '--help']) -@patch('argparse.ArgumentParser.print_help') -@patch('sys.exit') -def test_help_text_formatting(mock_exit, mock_print_help): - import argparse - from workbench_cli.cli import add_common_scan_options - - # Create a parser to test the help text formatting - parser = argparse.ArgumentParser() - subparser = parser.add_subparsers() - test_parser = subparser.add_parser('test') - - # Apply our add_common_scan_options to it - add_common_scan_options(test_parser) - - # Get help text for id-reuse-type - for action in test_parser._action_groups: - for act in action._actions: - if act.dest == 'id_reuse_type': - help_text = act.help - assert "use any existing identification" in help_text - assert "reuse identifications from" in help_text - - # Force the original parse call to exit to not interfere with test - mock_exit.side_effect = SystemExit - with pytest.raises(SystemExit): - parse_cmdline_args() - -# --- Test Handler Return Types --- -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_with_scan_handler_return_true(mock_parse, mock_wb, mock_handle_scan): - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - mock_workbench_instance = MagicMock() - mock_wb.return_value = mock_workbench_instance - mock_handle_scan.return_value = True - - result = main() - assert result == 0 # Should return 0 (success) when handler returns True - mock_handle_scan.assert_called_once() - -@patch("workbench_cli.main.handle_scan") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_with_scan_handler_return_false(mock_parse, mock_wb, mock_handle_scan): - mock_args = MagicMock(command="scan", log="INFO") - mock_parse.return_value = mock_args - mock_workbench_instance = MagicMock() - mock_wb.return_value = mock_workbench_instance - mock_handle_scan.return_value = False - - result = main() - # For non-evaluate-gates commands, the boolean return value isn't used for exit code - # It should still be 0 since no exception occurred - assert result == 0 - mock_handle_scan.assert_called_once() - -@patch("workbench_cli.main.handle_show_results") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_with_show_results_handler_return_true(mock_parse, mock_wb, mock_handle): - mock_args = MagicMock(command="show-results", log="INFO") - mock_parse.return_value = mock_args - mock_workbench_instance = MagicMock() - mock_wb.return_value = mock_workbench_instance - mock_handle.return_value = True - - result = main() - assert result == 0 - mock_handle.assert_called_once() - -@patch("workbench_cli.main.handle_import_da") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_with_import_da_handler_return_true(mock_parse, mock_wb, mock_handle): - mock_args = MagicMock(command="import-da", log="INFO") - mock_parse.return_value = mock_args - mock_workbench_instance = MagicMock() - mock_wb.return_value = mock_workbench_instance - mock_handle.return_value = True - - result = main() - assert result == 0 - mock_handle.assert_called_once() - -@patch("workbench_cli.main.handle_download_reports") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_with_download_reports_handler_return_true(mock_parse, mock_wb, mock_handle): - mock_args = MagicMock(command="download-reports", log="INFO") - mock_parse.return_value = mock_args - mock_workbench_instance = MagicMock() - mock_wb.return_value = mock_workbench_instance - mock_handle.return_value = True - - result = main() - assert result == 0 - mock_handle.assert_called_once() - -@patch("workbench_cli.main.handle_scan_git") -@patch("workbench_cli.main.WorkbenchAPI") -@patch("workbench_cli.main.parse_cmdline_args") -def test_main_with_scan_git_handler_return_true(mock_parse, mock_wb, mock_handle): - mock_args = MagicMock(command="scan-git", log="INFO") - mock_parse.return_value = mock_args - mock_workbench_instance = MagicMock() - mock_wb.return_value = mock_workbench_instance - mock_handle.return_value = True - - result = main() - assert result == 0 - mock_handle.assert_called_once() - diff --git a/tests/unit/cli/test_main_function.py b/tests/unit/cli/test_main_function.py new file mode 100644 index 0000000..b725e58 --- /dev/null +++ b/tests/unit/cli/test_main_function.py @@ -0,0 +1,274 @@ +"""Test main() function orchestration and exception handling.""" + +import pytest +from unittest.mock import MagicMock, patch +import os +import sys + +# Add src to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src')) + +from workbench_cli.main import main +from workbench_cli.exceptions import ( + ValidationError, ConfigurationError, AuthenticationError, ApiError, + NetworkError, ProcessError, ProcessTimeoutError, FileSystemError, + CompatibilityError, ProjectNotFoundError, ScanNotFoundError +) + + +class TestMainFunctionSuccess: + """Test successful main() function execution.""" + + def test_main_success_with_scan_handler(self, mock_main_dependencies): + """Test successful main() execution with scan handler.""" + # Setup + mock_args = MagicMock(command="scan", log="INFO") + mock_main_dependencies['handle_scan'].return_value = True + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + assert result == 0 + mock_main_dependencies['handle_scan'].assert_called_once() + mock_main_dependencies['workbench_api'].assert_called_once_with( + mock_args.api_url, mock_args.api_user, mock_args.api_token + ) + + def test_main_success_with_scan_git_handler(self, mock_main_dependencies): + """Test successful main() execution with scan-git handler.""" + mock_args = MagicMock(command="scan-git", log="INFO") + mock_main_dependencies['handle_scan_git'].return_value = True + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + assert result == 0 + mock_main_dependencies['handle_scan_git'].assert_called_once() + + def test_main_success_with_import_da_handler(self, mock_main_dependencies): + """Test successful main() execution with import-da handler.""" + mock_args = MagicMock(command="import-da", log="INFO") + mock_main_dependencies['handle_import_da'].return_value = True + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + assert result == 0 + mock_main_dependencies['handle_import_da'].assert_called_once() + + def test_main_success_with_show_results_handler(self, mock_main_dependencies): + """Test successful main() execution with show-results handler.""" + mock_args = MagicMock(command="show-results", log="INFO") + mock_main_dependencies['handle_show_results'].return_value = True + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + assert result == 0 + mock_main_dependencies['handle_show_results'].assert_called_once() + + def test_main_success_with_download_reports_handler(self, mock_main_dependencies): + """Test successful main() execution with download-reports handler.""" + mock_args = MagicMock(command="download-reports", log="INFO") + mock_main_dependencies['handle_download_reports'].return_value = True + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + assert result == 0 + mock_main_dependencies['handle_download_reports'].assert_called_once() + + +class TestMainFunctionExceptionHandling: + """Test main() function exception handling.""" + + def test_main_validation_error_during_parsing(self): + """Test main() handling ValidationError during argument parsing.""" + with patch('workbench_cli.main.parse_cmdline_args', side_effect=ValidationError("Test validation error")): + result = main() + + assert result == 1 + + def test_main_configuration_error_during_api_init(self, mock_main_dependencies): + """Test main() handling ConfigurationError during API initialization.""" + mock_args = MagicMock(command="scan", log="INFO") + mock_main_dependencies['workbench_api'].side_effect = ConfigurationError("Test config error") + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + assert result == 1 + # Handler should not be called if API init fails + mock_main_dependencies['handle_scan'].assert_not_called() + + def test_main_authentication_error_during_api_init(self, mock_main_dependencies): + """Test main() handling AuthenticationError during API initialization.""" + mock_args = MagicMock(command="scan", log="INFO") + mock_main_dependencies['workbench_api'].side_effect = AuthenticationError("Auth error") + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + assert result == 1 + + @pytest.mark.parametrize("exception_class,exception_msg", [ + (ApiError, "API error"), + (NetworkError, "Network error"), + (ProcessError, "Process error"), + (ProcessTimeoutError, "Timeout error"), + (FileSystemError, "FS error"), + (CompatibilityError, "Compatibility error"), + (ProjectNotFoundError, "Project not found"), + (ScanNotFoundError, "Scan not found"), + ]) + def test_main_specific_exception_handling(self, mock_main_dependencies, exception_class, exception_msg): + """Test main() handling of specific exception types.""" + mock_args = MagicMock(command="scan", log="INFO") + mock_main_dependencies['handle_scan'].side_effect = exception_class(exception_msg) + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + assert result == 1 + mock_main_dependencies['handle_scan'].assert_called_once() + + def test_main_unexpected_exception_handling(self, mock_main_dependencies): + """Test main() handling of unexpected exceptions.""" + mock_args = MagicMock(command="scan", log="INFO") + mock_main_dependencies['handle_scan'].side_effect = Exception("Unexpected error") + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + assert result == 1 + mock_main_dependencies['handle_scan'].assert_called_once() + + +class TestEvaluateGatesSpecialHandling: + """Test special handling for evaluate-gates command.""" + + def test_evaluate_gates_success_returns_0(self, mock_main_dependencies): + """Test that evaluate-gates returning True results in exit code 0.""" + mock_args = MagicMock(command="evaluate-gates", log="INFO") + mock_main_dependencies['handle_evaluate_gates'].return_value = True + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + assert result == 0 + mock_main_dependencies['handle_evaluate_gates'].assert_called_once() + + def test_evaluate_gates_failure_returns_1(self, mock_main_dependencies): + """Test that evaluate-gates returning False results in exit code 1.""" + mock_args = MagicMock(command="evaluate-gates", log="INFO") + mock_main_dependencies['handle_evaluate_gates'].return_value = False + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + assert result == 1 + mock_main_dependencies['handle_evaluate_gates'].assert_called_once() + + +class TestHandlerReturnValues: + """Test how main() handles different handler return values.""" + + @pytest.mark.parametrize("command,handler_name", [ + ("scan", "handle_scan"), + ("scan-git", "handle_scan_git"), + ("import-da", "handle_import_da"), + ("show-results", "handle_show_results"), + ("download-reports", "handle_download_reports"), + ]) + def test_non_evaluate_gates_handlers_ignore_return_value(self, mock_main_dependencies, command, handler_name): + """Test that non-evaluate-gates handlers' return values don't affect exit code.""" + mock_args = MagicMock(command=command, log="INFO") + mock_main_dependencies[handler_name].return_value = False # Simulate "failure" + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + # Should still return 0 since no exception was raised + assert result == 0 + mock_main_dependencies[handler_name].assert_called_once() + + +class TestLoggingConfiguration: + """Test logging configuration in main().""" + + def test_main_handles_log_level(self, mock_main_dependencies): + """Test that main() handles different log levels without error.""" + mock_args = MagicMock(command="scan", log="DEBUG") + mock_main_dependencies['handle_scan'].return_value = True + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + assert result == 0 + + +class TestMainIntegration: + """Integration tests for main() function behavior.""" + + def test_main_full_success_flow(self, mock_main_dependencies): + """Test complete successful flow through main().""" + # Setup a realistic args object + mock_args = MagicMock() + mock_args.command = "scan" + mock_args.log = "INFO" + mock_args.api_url = "https://test.com/api.php" + mock_args.api_user = "testuser" + mock_args.api_token = "testtoken" + + mock_main_dependencies['handle_scan'].return_value = True + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + # Verify the complete flow + assert result == 0 + mock_main_dependencies['workbench_api'].assert_called_once_with( + "https://test.com/api.php", "testuser", "testtoken" + ) + mock_main_dependencies['handle_scan'].assert_called_once_with( + mock_main_dependencies['workbench_instance'], mock_args + ) + + def test_main_handles_system_exit_during_parsing(self): + """Test that main() handles SystemExit from argparse gracefully.""" + # SystemExit from argparse (e.g., --help, invalid args) should not be caught + with patch('workbench_cli.main.parse_cmdline_args', side_effect=SystemExit(2)): + with pytest.raises(SystemExit): + main() + + def test_main_command_dispatch_logic(self, mock_main_dependencies): + """Test that main() correctly dispatches to the right handler based on command.""" + command_handler_mapping = [ + ("scan", "handle_scan"), + ("scan-git", "handle_scan_git"), + ("import-da", "handle_import_da"), + ("show-results", "handle_show_results"), + ("download-reports", "handle_download_reports"), + ("evaluate-gates", "handle_evaluate_gates"), + ] + + for command, expected_handler in command_handler_mapping: + # Reset all mocks + for handler in mock_main_dependencies.values(): + if hasattr(handler, 'reset_mock'): + handler.reset_mock() + + mock_args = MagicMock(command=command, log="INFO") + mock_main_dependencies[expected_handler].return_value = True + + with patch("workbench_cli.main.parse_cmdline_args", return_value=mock_args): + result = main() + + assert result == 0 + mock_main_dependencies[expected_handler].assert_called_once() + + # Verify other handlers were not called + for handler_name, handler_mock in mock_main_dependencies.items(): + if handler_name != expected_handler and handler_name not in ['workbench_api', 'workbench_instance']: + if hasattr(handler_mock, 'assert_not_called'): + handler_mock.assert_not_called() \ No newline at end of file From 8bee81162bf1e5962e4adbf734ea39a1cc5a5e94 Mon Sep 17 00:00:00 2001 From: Tomas Gonzalez Date: Thu, 19 Jun 2025 09:38:56 -0400 Subject: [PATCH 2/2] workflow hygiene because why not --- .../{main-pr-gate.yml => scan-gate-branch.yml} | 17 +++++++---------- .../{wb-scan-diff.yml => scan-pr-head-diff.yml} | 5 ++--- ...can-main-branch.yml => update-main-scan.yml} | 2 +- 3 files changed, 10 insertions(+), 14 deletions(-) rename .github/workflows/{main-pr-gate.yml => scan-gate-branch.yml} (79%) rename .github/workflows/{wb-scan-diff.yml => scan-pr-head-diff.yml} (96%) rename .github/workflows/{scan-main-branch.yml => update-main-scan.yml} (97%) diff --git a/.github/workflows/main-pr-gate.yml b/.github/workflows/scan-gate-branch.yml similarity index 79% rename from .github/workflows/main-pr-gate.yml rename to .github/workflows/scan-gate-branch.yml index c199c9a..ef82cd9 100644 --- a/.github/workflows/main-pr-gate.yml +++ b/.github/workflows/scan-gate-branch.yml @@ -1,12 +1,9 @@ -# This workflow runs on Pull Requests opened against MAIN. -# It will scan the incoming HEAD branch with Workbench. -# If Pending IDs or Policy Violations are found, the PR will be blocked. +# This workflow can be triggered to scan a branch and evaluate gates. +# The workflow will fail if Pending IDs or Policy Violations are found. -name: Scan Incoming PRs and Check Gates +name: Scan Branch and Evaluate Gates -on: - pull_request: - branches: main +on: workflow_dispatch jobs: workbench-scan: @@ -24,9 +21,9 @@ jobs: --api-token ${{ env.WORKBENCH_TOKEN }} \ scan-git \ --project-name $GITHUB_REPOSITORY \ - --scan-name $GITHUB_HEAD_REF \ + --scan-name $GITHUB_REF_NAME \ --git-url $GITHUB_SERVER_URL/$GITHUB_REPOSITORY \ - --git-branch $GITHUB_HEAD_REF \ + --git-branch $GITHUB_REF_NAME \ --git-depth 1 \ --id-reuse \ --id-reuse-type project \ @@ -52,6 +49,6 @@ jobs: --api-token ${{ env.WORKBENCH_TOKEN }} \ evaluate-gates \ --project-name $GITHUB_REPOSITORY \ - --scan-name $GITHUB_HEAD_REF \ + --scan-name $GITHUB_REF_NAME \ --fail-on-pending \ --fail-on-policy diff --git a/.github/workflows/wb-scan-diff.yml b/.github/workflows/scan-pr-head-diff.yml similarity index 96% rename from .github/workflows/wb-scan-diff.yml rename to .github/workflows/scan-pr-head-diff.yml index 0dd471b..15d5845 100644 --- a/.github/workflows/wb-scan-diff.yml +++ b/.github/workflows/scan-pr-head-diff.yml @@ -1,9 +1,8 @@ -# Alternative workflow using container approach with pre-built archive # This workflow runs on Pull Requests opened against MAIN. -# It will scan only those files touched by the incoming HEAD branch using containers. +# It will scan only those files touched by the incoming HEAD branch. # If Pending IDs or Policy Violations are found, the PR will be blocked. -name: Scan Diff of Incoming PRs +name: Scan Diff of PR Head on: pull_request: diff --git a/.github/workflows/scan-main-branch.yml b/.github/workflows/update-main-scan.yml similarity index 97% rename from .github/workflows/scan-main-branch.yml rename to .github/workflows/update-main-scan.yml index 1988897..ed09ac2 100644 --- a/.github/workflows/scan-main-branch.yml +++ b/.github/workflows/update-main-scan.yml @@ -1,7 +1,7 @@ # This workflow runs if code is pushed to the MAIN branch. # It will tell Workbench to clone the latest state of the branch and scan it. -name: Update Main Branch Scan on Push +name: Update Main Branch Scan on: push: