| 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: "<html></html>")
|
| 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)])
|
|
|
| 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, []))
|
|
|
| out_dir = tmp_path
|
| result = runner.invoke(cli_mod.app, ["scan", "./", "--format", "json", "--out", str(out_dir)])
|
| assert result.exit_code == 0
|
|
|
| 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,
|
| )
|
|
|
|
|
|
|
|
|
| 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")
|
|
|
| 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()
|
|
|
| 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
|
|
|
|
|
|
|
|
|
| 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),
|
| )
|
|
|
| try:
|
| findings = json.loads(result.stdout.strip().split("\n", 1)[-1]
|
| if "\n" in result.stdout else result.stdout)
|
| except json.JSONDecodeError:
|
|
|
| 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
|
|
|
|
|
|
|
|
|
| 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]}"
|
| )
|
|
|
|
|
|
|
|
|
| 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),
|
| )
|
|
|
| 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")
|
|
|
| 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"
|
|
|
| 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
|
|
|