from __future__ import annotations from datetime import datetime import pytest from tests.helpers import make_alert, make_dependency, make_finding from vuln_scan.core.models import Finding, Severity from vuln_scan.reporting.base import FindingRow, Reporter # -------------------------------------------------------- # Concrete subclass for testing (ABC can't be instantiated) # -------------------------------------------------------- class _StubReporter(Reporter): def render(self, findings: list[Finding], dependabot_url: str) -> str: return "" @pytest.fixture def reporter() -> _StubReporter: return _StubReporter() # -------------------------------------------------------- # Reporter is abstract # -------------------------------------------------------- def test_reporter_cannot_be_instantiated() -> None: with pytest.raises(TypeError): Reporter() # type: ignore[abstract] def test_reporter_has_render_method() -> None: assert hasattr(Reporter, "render") methods = [m for m in dir(Reporter) if not m.startswith("_")] assert "render" in methods # -------------------------------------------------------- # extract_row # -------------------------------------------------------- class TestExtractRow: def test_returns_finding_row(self) -> None: finding = make_finding( dep=make_dependency( name="requests", version="2.30.0", manifest_path="requirements.txt", ), alert=make_alert( package_name="requests", severity=Severity.HIGH, first_patched="2.32.0", vulnerable_range="<2.32.0", ), is_blocking=True, reason="HIGH vulnerability — no grace period", ) row = Reporter.extract_row(finding) assert isinstance(row, FindingRow) assert row.file == "requirements.txt" assert row.package == "requests" assert row.version == "2.30.0" assert row.vuln_range == "<2.32.0" assert row.patch == "2.32.0" assert row.severity == "HIGH" assert row.is_blocking is True assert row.grace_period == "0" assert row.blocking_date == "-" assert row.reason == "HIGH vulnerability — no grace period" def test_missing_dependency_falls_back_to_alert(self) -> None: finding = make_finding( dep=None, alert=make_alert( package_name="requests", severity=Severity.HIGH, first_patched="2.32.0", vulnerable_range="<2.32.0", manifest_path="requirements.txt", ), is_blocking=False, reason="Test reason", ) row = Reporter.extract_row(finding) assert row.file == "requirements.txt" assert row.package == "requests" assert row.version == "-" def test_requirement_used_when_version_missing(self) -> None: dependency = make_dependency(name="requests", version=None, requirement="^2.30.0") finding = make_finding( dep=dependency, alert=make_alert(package_name="requests"), ) row = Reporter.extract_row(finding) assert row.version == "^2.30.0" def test_no_patch_shows_no_patch(self) -> None: finding = make_finding( dep=make_dependency(name="requests", version="2.30.0"), alert=make_alert( package_name="requests", severity=Severity.HIGH, first_patched=None, vulnerable_range="<2.32.0", ), is_blocking=False, reason="No patch", ) row = Reporter.extract_row(finding) assert row.patch == "No Patch" def test_negative_remaining_days_clamped_to_zero(self) -> None: finding = make_finding() finding.decision.remaining_days = -10 row = Reporter.extract_row(finding) assert row.grace_period == "0" def test_blocking_date_is_formatted(self) -> None: finding = make_finding() finding.decision.blocking_date = datetime(2026, 5, 21) row = Reporter.extract_row(finding) assert row.blocking_date == "2026-05-21" # -------------------------------------------------------- # summary_counts # -------------------------------------------------------- class TestSummaryCounts: def test_counts_blocking_and_non_blocking(self) -> None: findings = [ make_finding(is_blocking=True, reason="blocked"), make_finding(is_blocking=True, reason="blocked"), make_finding(is_blocking=False, reason="ok"), ] blocking, non_blocking = Reporter.summary_counts(findings) assert blocking == 2 assert non_blocking == 1 def test_empty_findings(self) -> None: blocking, non_blocking = Reporter.summary_counts([]) assert blocking == 0 assert non_blocking == 0 # -------------------------------------------------------- # severity_badge / blocking_badge # -------------------------------------------------------- class TestBadges: @pytest.mark.parametrize( "severity, expected", [ pytest.param("critical", "🔴 CRITICAL", id="critical"), pytest.param("high", "🟠 HIGH", id="high"), pytest.param("moderate", "🟡 MODERATE", id="moderate"), pytest.param("low", "🟢 LOW", id="low"), pytest.param("unknown", "UNKNOWN", id="unknown-falls-back-to-upper"), ], ) def test_severity_badge(self, severity: str, expected: str) -> None: assert Reporter.severity_badge(severity) == expected @pytest.mark.parametrize( "is_blocking, expected", [ pytest.param(True, "🔴 YES", id="blocking"), pytest.param(False, "🟢 NO", id="non-blocking"), ], ) def test_blocking_badge(self, is_blocking: bool, expected: str) -> None: assert Reporter.blocking_badge(is_blocking) == expected