autoscan / tests /test_cli.py
Chris4K's picture
Initial commit v5.0.0.
5248e3b verified
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)])
# 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