from typer.testing import CliRunner import json from pathlib import Path import cli as cli_mod runner = CliRunner() def test_version_command(): result = runner.invoke(cli_mod.app, ["version"]) assert result.exit_code == 0 assert "hf-scanner" in result.stdout def test_scan_writes_reports_and_exits_clean(monkeypatch, tmp_path): findings = [ {"severity": "ERROR", "tool": "bandit", "rule": "B1", "file": "a.py", "line": 1, "message": "x", "owasp": [], "remediation": "r"} ] monkeypatch.setattr(cli_mod, "scan_repo", lambda target, **kwargs: (findings, ["ok"])) monkeypatch.setattr(cli_mod, "generate_html_report", lambda f, m: "") monkeypatch.setattr(cli_mod, "generate_sarif", lambda f, m: {"sarif": True}) out_stem = tmp_path / "out_stem" result = runner.invoke(cli_mod.app, ["scan", "./", "--format", "both", "--out", str(out_stem)]) # findings contain an ERROR so the CLI exits with EXIT_FINDINGS (1) assert result.exit_code == 1 assert (str(out_stem) + ".html") in result.stdout assert (str(out_stem) + ".sarif") in result.stdout assert Path(str(out_stem) + ".html").exists() def test_scan_json_and_stdout(monkeypatch, tmp_path): findings = [{"severity": "INFO", "tool": "t", "rule": "r", "file": "f", "line": 0, "message": "m", "owasp": [], "remediation": ""}] monkeypatch.setattr(cli_mod, "scan_repo", lambda target, **kwargs: (findings, [])) # pass directory as --out so the CLI writes scan_report.json inside it out_dir = tmp_path result = runner.invoke(cli_mod.app, ["scan", "./", "--format", "json", "--out", str(out_dir)]) assert result.exit_code == 0 # stdout contains JSON assert "[" in result.stdout assert (out_dir / "scan_report.json").exists() """Tests for CLI entrypoint (cli.py) — black-box subprocess tests.""" import json import os import subprocess import sys import tempfile from pathlib import Path import pytest ROOT = Path(__file__).parent.parent PYTHON = sys.executable CLI = str(ROOT / "cli.py") FIXTURES = ROOT / "tests" / "fixtures" def run_cli(*args, **kwargs) -> subprocess.CompletedProcess: """Run hf-scanner via `python cli.py ...` and return the completed process.""" env = os.environ.copy() env["PYTHONIOENCODING"] = "utf-8" return subprocess.run( [PYTHON, CLI, *args], capture_output=True, text=True, encoding="utf-8", env=env, **kwargs, ) # ── basic commands ──────────────────────────────────────────────────────────── class TestCliBasic: def test_help_exits_zero(self): result = run_cli("--help") assert result.returncode == 0 assert "scan" in result.stdout assert "version" in result.stdout def test_version_command(self): result = run_cli("version") assert result.returncode == 0 assert "hf-scanner" in result.stdout assert "4." in result.stdout def test_list_rules(self): result = run_cli("list-rules") assert result.returncode == 0 assert "Semgrep" in result.stdout def test_self_test_runs(self): result = run_cli("self-test") # 0 = all ok, 2 = some tools missing — both are acceptable here assert result.returncode in (0, 2) assert "semgrep" in result.stdout.lower() or "semgrep" in result.stderr.lower() def test_no_args_shows_help(self): result = run_cli() # typer shows help and exits 0 when no_args_is_help=True assert result.returncode == 0 def test_unknown_format_exits_usage(self, tmp_path): result = run_cli("scan", str(tmp_path), "--format", "pdf") assert result.returncode == 3 # ── scan output formats ─────────────────────────────────────────────────────── class TestCliScanOutput: @pytest.mark.slow def test_scan_produces_html(self, tmp_path): src = FIXTURES / "security" result = run_cli( "scan", str(src), "--format", "html", "--out", str(tmp_path / "report"), "--no-llm", "--no-performance", cwd=str(ROOT), ) html_path = tmp_path / "report.html" assert html_path.exists(), f"No HTML produced. stdout: {result.stdout[:500]}" @pytest.mark.slow def test_scan_produces_sarif(self, tmp_path): src = FIXTURES / "security" run_cli( "scan", str(src), "--format", "sarif", "--out", str(tmp_path / "report"), "--no-llm", "--no-performance", cwd=str(ROOT), ) sarif_path = tmp_path / "report.sarif" assert sarif_path.exists(), "No SARIF file produced" with open(sarif_path, encoding="utf-8") as fh: doc = json.load(fh) assert doc["version"] == "2.1.0" @pytest.mark.slow def test_scan_json_format(self, tmp_path): src = FIXTURES / "security" result = run_cli( "scan", str(src), "--format", "json", "--out", str(tmp_path / "report"), "--no-llm", "--no-performance", cwd=str(ROOT), ) # JSON is also printed to stdout try: findings = json.loads(result.stdout.strip().split("\n", 1)[-1] if "\n" in result.stdout else result.stdout) except json.JSONDecodeError: # may be in the output file instead json_path = tmp_path / "report.json" if json_path.exists(): findings = json.loads(json_path.read_text(encoding="utf-8")) else: pytest.skip("JSON output format not verifiable in this environment") if isinstance(findings, list): for f in findings: assert "rule" in f or "tool" in f # ── exit codes ──────────────────────────────────────────────────────────────── class TestCliExitCodes: @pytest.mark.slow def test_bad_fixture_exits_1(self, tmp_path): """Known-bad fixture should trigger findings → exit 1.""" src = FIXTURES / "security" result = run_cli( "scan", str(src), "--format", "sarif", "--out", str(tmp_path / "r"), "--no-llm", "--no-performance", "--severity-threshold", "WARNING", cwd=str(ROOT), ) assert result.returncode == 1, ( f"Expected exit 1 (findings), got {result.returncode}\n" f"stdout: {result.stdout[:500]}\nstderr: {result.stderr[:500]}" ) @pytest.mark.slow def test_clean_fixture_exits_0(self, tmp_path): """Clean fixture has no findings → exit 0.""" src = FIXTURES / "clean" result = run_cli( "scan", str(src), "--format", "sarif", "--out", str(tmp_path / "r"), "--no-llm", "--no-performance", "--no-security", cwd=str(ROOT), ) assert result.returncode == 0, ( f"Expected exit 0 (clean), got {result.returncode}\n" f"stdout: {result.stdout[:500]}\nstderr: {result.stderr[:500]}" ) # ── scanner flags ───────────────────────────────────────────────────────────── class TestCliScanFlags: @pytest.mark.slow def test_no_security_skips_bandit(self, tmp_path): src = FIXTURES / "security" result = run_cli( "scan", str(src), "--no-security", "--format", "json", "--out", str(tmp_path / "out"), cwd=str(ROOT), ) # Only examine log lines (not paths which may contain the test function name) log_lines = [ ln for ln in (result.stdout + result.stderr).splitlines() if ln.strip().startswith(("bandit", " bandit", "semgrep", "pip-audit", "gitleaks", "hadolint", "detect-secrets", "forbidden-files", "ruff", "agent-audit")) ] assert not any("bandit" in ln.lower() for ln in log_lines), \ f"bandit should not appear in scanner log lines: {log_lines}" @pytest.mark.slow def test_baseline_roundtrip(self, tmp_path): """Create baseline from bad fixture, then scan again — findings suppressed.""" src = FIXTURES / "security" baseline_path = str(tmp_path / "baseline.json") # First scan: create baseline run_cli( "scan", str(src), "--create-baseline", baseline_path, "--format", "json", "--out", str(tmp_path / "r1"), "--no-llm", "--no-performance", cwd=str(ROOT), ) assert Path(baseline_path).exists(), "Baseline file not created" # Second scan: apply baseline result2 = run_cli( "scan", str(src), "--baseline", baseline_path, "--format", "json", "--out", str(tmp_path / "r2"), "--no-llm", "--no-performance", cwd=str(ROOT), ) output = result2.stdout + result2.stderr assert "suppressed" in output.lower() or result2.returncode == 0