from __future__ import annotations import runpy import sys from pathlib import Path from types import SimpleNamespace import pytest import vuln_scan.cli as cli # -------------------------------------------------------- # _require # -------------------------------------------------------- @pytest.mark.parametrize( "value, should_raise", [ pytest.param(None, True, id="none"), pytest.param("", True, id="empty"), pytest.param("token", False, id="valid"), ], ) def test_require(value: str | None, should_raise: bool) -> None: if should_raise: with pytest.raises(SystemExit, match="Missing required value: --x"): cli._require(value, "--x") else: cli._require(value, "--x") # -------------------------------------------------------- # _env_int # -------------------------------------------------------- @pytest.mark.parametrize( "env_value, default, expected", [ pytest.param(None, 7, 7, id="missing-uses-default"), pytest.param("", 9, 9, id="empty-uses-default"), pytest.param("42", 1, 42, id="valid-int"), pytest.param("oops", 5, 5, id="invalid-uses-default"), pytest.param("oops", None, None, id="invalid-no-default"), ], ) def test_env_int( monkeypatch: pytest.MonkeyPatch, env_value: str | None, default: int | None, expected: int | None, ) -> None: env_name = "TEST_INT_ENV" if env_value is None: monkeypatch.delenv(env_name, raising=False) else: monkeypatch.setenv(env_name, env_value) assert cli._env_int(env_name, default) == expected # -------------------------------------------------------- # build_parser # -------------------------------------------------------- _ENV_DEFAULTS = { "GITHUB_TOKEN": "ghs_x", "GITHUB_REPO_OWNER": "octo", "GITHUB_REPO": "demo", "BASE_PATH": "/tmp/repo", "PROJECT_DIR": "src", "GITHUB_PR_NUM": "123", "COMMIT_SHA": "abc123", "BUILD_URL": "https://ci/build/1", "GITHUB_PR_COMMENT": "hello", "LOG_LEVEL": "warning", "max_workers": "6", } _ENV_EXPECTED = { "github_token": "ghs_x", "owner": "octo", "repo": "demo", "base_path": "/tmp/repo", "project_dir": "src", "pr_number": 123, "commit_sha": "abc123", "build_url": "https://ci/build/1", "pr_comment_text": "hello", "skip_phrase": "skip vulnerability scan", "excluded_path": ["vagrant"], "ux": None, "post_comment": False, "log_level": "warning", "max_workers": 4, } _CLI_ARGS = [ "--github-token", "token", "--owner", "owner", "--repo", "repo", "--base-path", "/tmp/work", "--pr-number", "55", "--excluded-path", "build", "--excluded-path", "dist", "--ux", "table", "--post-comment", "--log-level", "debug", ] _CLI_EXPECTED = { "pr_number": 55, "excluded_path": ["vagrant", "build", "dist"], "ux": "table", "post_comment": True, "log_level": "debug", "max_workers": 4, } @pytest.mark.parametrize( "env_vars, cli_args, checks", [ pytest.param(_ENV_DEFAULTS, [], _ENV_EXPECTED, id="env-defaults"), pytest.param({}, _CLI_ARGS, _CLI_EXPECTED, id="cli-overrides"), ], ) def test_build_parser( monkeypatch: pytest.MonkeyPatch, env_vars: dict[str, str], cli_args: list[str], checks: dict[str, object], ) -> None: for key, val in env_vars.items(): monkeypatch.setenv(key, val) args = cli.build_parser().parse_args(cli_args) for attr, expected in checks.items(): assert getattr(args, attr) == expected, f"{attr}: {getattr(args, attr)} != {expected}" def test_build_parser_log_level_defaults_to_info( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("LOG_LEVEL", raising=False) args = cli.build_parser().parse_args([]) assert args.log_level == "info" def test_build_parser_ux_defaults_to_none( monkeypatch: pytest.MonkeyPatch, ) -> None: args = cli.build_parser().parse_args([]) assert args.ux is None # -------------------------------------------------------- # _build_reporter # -------------------------------------------------------- class TestBuildReporter: def test_local_default_is_rich(self) -> None: reporter = cli._build_reporter(ux=None, post_comment=False) assert isinstance(reporter, cli.RichReporter) def test_local_table(self) -> None: reporter = cli._build_reporter(ux="table", post_comment=False) assert isinstance(reporter, cli.PrettyTableReporter) def test_local_markdown(self) -> None: reporter = cli._build_reporter(ux="markdown", post_comment=False) assert isinstance(reporter, cli.MarkdownReporter) def test_post_comment_default_is_table(self) -> None: reporter = cli._build_reporter(ux=None, post_comment=True) assert isinstance(reporter, cli.PrettyTableReporter) def test_post_comment_explicit_markdown(self) -> None: reporter = cli._build_reporter(ux="markdown", post_comment=True) assert isinstance(reporter, cli.MarkdownReporter) def test_post_comment_table_allowed(self) -> None: reporter = cli._build_reporter(ux="table", post_comment=True) assert isinstance(reporter, cli.PrettyTableReporter) def test_post_comment_rich_rejected(self) -> None: with pytest.raises(SystemExit, match="--ux rich is not compatible with --post-comment"): cli._build_reporter(ux="rich", post_comment=True) # -------------------------------------------------------- # main() — helpers # -------------------------------------------------------- def _base_args(**overrides: object) -> SimpleNamespace: base = dict( github_token="token", owner="owner", repo="repo", base_path="/tmp/work", project_dir="app", pr_number=7, commit_sha="sha123", build_url="https://ci/build/7", pr_comment_text="text", skip_phrase="skip vulnerability scan", excluded_path=["vagrant", "build"], grace_critical_days=1, grace_high_days=2, grace_moderate_days=3, grace_low_days=4, grace_non_blocking_days=5, ux=None, post_comment=False, log_level="info", max_workers=4, github_app_id=None, github_private_key=None, github_private_key_path=None, github_installation_id=None, ) base.update(overrides) return SimpleNamespace(**base) def _run_main_with_mocks( monkeypatch: pytest.MonkeyPatch, *, args: SimpleNamespace, any_blocking: bool, ) -> tuple[object, dict[str, object]]: calls: dict[str, object] = {} class _FakeParser: def parse_args(self) -> SimpleNamespace: return args class _FakeScanner: def __init__(self, **kwargs: object) -> None: calls["scanner_kwargs"] = kwargs def scan(self, cfg: object) -> object: calls["scan_cfg"] = cfg return SimpleNamespace(any_blocking=any_blocking) def _record(name: str): def _factory(*f_args: object, **f_kwargs: object) -> object: calls.setdefault(name, []).append((f_args, f_kwargs)) return SimpleNamespace(name=name) return _factory monkeypatch.setattr(cli, "load_dotenv", lambda: None) monkeypatch.setattr(cli, "build_parser", lambda: _FakeParser()) monkeypatch.setattr(cli, "setup_logging", _record("setup_logging")) monkeypatch.setattr(cli, "GracePeriods", _record("GracePeriods")) monkeypatch.setattr(cli, "Clock", _record("Clock")) monkeypatch.setattr(cli, "BlockingPolicy", _record("BlockingPolicy")) monkeypatch.setattr(cli, "PythonEcosystemHandler", _record("PythonEcosystemHandler")) monkeypatch.setattr(cli, "EcosystemRegistry", _record("EcosystemRegistry")) monkeypatch.setattr(cli, "MarkdownReporter", _record("MarkdownReporter")) monkeypatch.setattr(cli, "RichReporter", _record("RichReporter")) monkeypatch.setattr(cli, "PrettyTableReporter", _record("PrettyTableReporter")) monkeypatch.setattr(cli, "GithubRepoAlertsClient", _record("GithubRepoAlertsClient")) monkeypatch.setattr(cli, "GitHubAdvisoryClient", _record("GitHubAdvisoryClient")) monkeypatch.setattr(cli, "NullPublisher", _record("NullPublisher")) monkeypatch.setattr(cli, "GitHubPRPublisher", _record("GitHubPRPublisher")) monkeypatch.setattr(cli, "WorkspaceScanner", _record("WorkspaceScanner")) monkeypatch.setattr(cli, "GitHubRepoClient", _record("GitHubRepoClient")) monkeypatch.setattr(cli, "DefaultBranchScanner", _record("DefaultBranchScanner")) monkeypatch.setattr(cli, "VulnerabilityScanner", _FakeScanner) with pytest.raises(SystemExit) as exc: cli.main() return exc.value.code, calls # -------------------------------------------------------- # main() # -------------------------------------------------------- @pytest.mark.parametrize( "args, any_blocking, expected_exit, expected_in_calls, not_in_calls, extra_checks", [ pytest.param( _base_args( github_token=None, github_app_id=123, github_installation_id=456, github_private_key="---PRIVATE KEY---", github_private_key_path=None, post_comment=False, ux=None, ), False, 0, ["NullPublisher", "RichReporter"], ["GitHubPRPublisher"], { # optional: check dependabot URL etc. like your first case }, id="github-app-auth-only-local-succeeds", ), pytest.param( _base_args(post_comment=False, ux=None), False, 0, ["NullPublisher", "RichReporter"], ["GitHubPRPublisher", "MarkdownReporter", "PrettyTableReporter"], { "setup_logging_level": "info", "scan_cfg_owner": "owner", "scan_cfg_repo": "repo", "scan_cfg_base_path": "/tmp/work", "scan_cfg_project_dir": "app", "scan_cfg_excluded": ("vagrant", "build"), "scan_cfg_dependabot_url": "https://github.com/owner/repo/security/dependabot", }, id="local-default-rich-exits-zero", ), pytest.param( _base_args(post_comment=True, ux="table", pr_number=21), True, 1, ["GitHubPRPublisher", "PrettyTableReporter"], ["NullPublisher", "RichReporter", "MarkdownReporter"], {}, id="post-comment-table-exits-one", ), pytest.param( _base_args(post_comment=True, ux=None, pr_number=21), False, 0, ["GitHubPRPublisher", "PrettyTableReporter"], ["NullPublisher", "RichReporter", "MarkdownReporter"], {}, id="post-comment-default-table-exits-zero", ), pytest.param( _base_args(post_comment=True, ux="markdown", pr_number=21), False, 0, ["GitHubPRPublisher", "MarkdownReporter"], ["NullPublisher", "RichReporter", "PrettyTableReporter"], {}, id="post-comment-markdown-exits-zero", ), pytest.param( _base_args(post_comment=False, ux="table"), False, 0, ["NullPublisher", "PrettyTableReporter"], ["GitHubPRPublisher", "RichReporter", "MarkdownReporter"], {}, id="local-table-exits-zero", ), pytest.param( _base_args(post_comment=False, ux="markdown"), False, 0, ["NullPublisher", "MarkdownReporter"], ["GitHubPRPublisher", "RichReporter", "PrettyTableReporter"], {}, id="local-markdown-exits-zero", ), pytest.param( _base_args(log_level="debug"), False, 0, [], [], {"setup_logging_level": "debug"}, id="log-level-debug-passed-through", ), pytest.param( _base_args( github_token="", github_app_id=None, github_private_key=None, github_private_key_path=None, github_installation_id=None, ), False, "Missing required auth: provide GITHUB_TOKEN, or GITHUB_APP_ID + (GITHUB_PRIVATE_KEY or GITHUB_PRIVATE_KEY_PATH) + GITHUB_INSTALLATION_ID", [], [], {}, id="missing-auth-fails", ), pytest.param( _base_args(post_comment=True, pr_number=None), False, "Missing required value: --pr-number / GITHUB_PR_NUM", [], [], {}, id="missing-pr-number-fails", ), pytest.param( _base_args( github_token=None, github_app_id=123, github_installation_id=456, github_private_key=None, github_private_key_path="/no/such/key.pem", ), False, "Failed to read GITHUB_PRIVATE_KEY_PATH=/no/such/key.pem", [], [], {}, id="github-app-private-key-path-unreadable-fails", ), pytest.param( _base_args( github_token=None, github_app_id=123, github_installation_id=456, github_private_key=None, github_private_key_path="/empty/key.pem", ), False, "GitHub App auth selected but private key is empty.", [], [], {}, id="github-app-empty-private-key-fails", ), pytest.param( _base_args(max_workers=0), False, "--max-workers / MAX_WORKERS must be a positive integer", [], [], {}, id="max-workers-zero-rejected", ), pytest.param( _base_args(max_workers=-1), False, "--max-workers / MAX_WORKERS must be a positive integer", [], [], {}, id="max-workers-negative-rejected", ), ], ) def test_main( monkeypatch: pytest.MonkeyPatch, args: SimpleNamespace, any_blocking: bool, expected_exit: int | str, expected_in_calls: list[str], not_in_calls: list[str], extra_checks: dict[str, object], ) -> None: if getattr(args, "github_private_key_path", None) == "/no/such/key.pem": monkeypatch.setattr( Path, "read_text", lambda self: (_ for _ in ()).throw(FileNotFoundError("missing")), ) if getattr(args, "github_private_key_path", None) == "/empty/key.pem": monkeypatch.setattr(Path, "read_text", lambda self: "") exit_code, calls = _run_main_with_mocks(monkeypatch, args=args, any_blocking=any_blocking) if isinstance(expected_exit, str): assert expected_exit in str(exit_code) else: assert exit_code == expected_exit if isinstance(expected_exit, int) and args.github_app_id and not args.github_token: alerts_calls = calls.get("GithubRepoAlertsClient", []) assert alerts_calls, "Expected GithubRepoAlertsClient to be constructed" _f_args, f_kwargs = alerts_calls[0] assert "auth_config" in f_kwargs assert "token" not in f_kwargs # ensures we didn't use old API for name in expected_in_calls: assert name in calls, f"Expected '{name}' in calls" for name in not_in_calls: assert name not in calls, f"Did not expect '{name}' in calls" if "setup_logging_level" in extra_checks: assert calls["setup_logging"][0][1] == {"level": extra_checks["setup_logging_level"]} scan_cfg = calls.get("scan_cfg") if scan_cfg: for key, expected in extra_checks.items(): if key.startswith("scan_cfg_"): attr = key.removeprefix("scan_cfg_") if attr == "base_path": assert str(getattr(scan_cfg, attr)) == expected elif attr == "excluded": assert scan_cfg.excluded_path_fragments == expected elif attr == "dependabot_url": assert scan_cfg.dependabot_url == expected else: assert getattr(scan_cfg, attr) == expected # -------------------------------------------------------- # __main__ guard # -------------------------------------------------------- def test_cli_module_main_guard(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(sys, "argv", ["vuln_scan.cli"]) monkeypatch.delitem(sys.modules, "vuln_scan.cli", raising=False) with pytest.raises(SystemExit): runpy.run_module("vuln_scan.cli", run_name="__main__")