from __future__ import annotations from unittest.mock import patch import pytest from tests.helpers import make_alert, make_dependency, make_finding from vuln_scan.core.models import Finding, FindingStatus, Severity from vuln_scan.reporting.markdown_reporter import MarkdownReporter # -------------------------------------------------------- # Helpers # -------------------------------------------------------- DEPENDABOT_URL = "https://github.com/org/repo/security/dependabot" EXPECTED_HEADERS = [ "File", "Package", "Current Version", "Min. Required Version", "Vulnerable Range", "Severity", "Blocking", "Grace Period", "Blocking Date", "Reason", ] def _finding( *, package_name: str = "requests", severity: Severity = Severity.HIGH, is_blocking: bool = False, with_dependency: bool = True, first_patched: str | None = "2.32.0", version: str = "2.30.0", vulnerable_range: str = "<2.32.0", status: FindingStatus = FindingStatus.VULNERABLE, reason: str = "Test reason", ) -> Finding: dep = make_dependency(name=package_name, version=version) if with_dependency else None return make_finding( dep=dep, alert=make_alert( package_name=package_name, severity=severity, first_patched=first_patched, vulnerable_range=vulnerable_range, ), status=status, is_blocking=is_blocking, reason=reason, ) @pytest.fixture def reporter() -> MarkdownReporter: return MarkdownReporter() # -------------------------------------------------------- # render — empty findings # -------------------------------------------------------- def test_render_empty_findings(reporter: MarkdownReporter) -> None: result = reporter.render([], DEPENDABOT_URL) assert result == "✅ **No vulnerabilities found.**" # -------------------------------------------------------- # render — markdown structure # -------------------------------------------------------- class TestMarkdownStructure: def test_header_order(self, reporter: MarkdownReporter) -> None: result = reporter.render([_finding()], DEPENDABOT_URL) header = result.splitlines()[0] columns = [c.strip() for c in header.split("|") if c.strip()] assert columns == EXPECTED_HEADERS def test_separator_row_exists(self, reporter: MarkdownReporter) -> None: result = reporter.render([_finding()], DEPENDABOT_URL) assert "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |" in result def test_data_row_has_expected_column_count( self, reporter: MarkdownReporter, ) -> None: result = reporter.render([_finding()], DEPENDABOT_URL) data_row = result.splitlines()[2] columns = [c for c in data_row.split("|") if c.strip()] assert len(columns) == 10 def test_output_ends_with_summary(self, reporter: MarkdownReporter) -> None: result = reporter.render([_finding()], DEPENDABOT_URL) assert "**Total findings:** 1 (0 blocking, 1 non-blocking)" in result # -------------------------------------------------------- # render — content verification # -------------------------------------------------------- class TestMarkdownContent: def test_row_contains_backtick_quoted_values(self, reporter: MarkdownReporter) -> None: result = reporter.render( [_finding(package_name="requests", version="2.30.0")], DEPENDABOT_URL, ) assert "`requests`" in result assert "`2.30.0`" in result assert "`<2.32.0`" in result assert "`2.32.0`" in result assert "`0`" in result assert "`-`" in result def test_file_path_in_backticks(self, reporter: MarkdownReporter) -> None: finding = _finding() row = reporter.extract_row(finding) result = reporter.render([finding], DEPENDABOT_URL) assert f"`{row.file}`" in result def test_missing_dependency_renders(self, reporter: MarkdownReporter) -> None: result = reporter.render([_finding(with_dependency=False)], DEPENDABOT_URL) assert "`requests`" in result assert "`-`" in result # version falls back to "-" def test_no_patch_shows_no_patch(self, reporter: MarkdownReporter) -> None: result = reporter.render( [_finding(first_patched=None)], DEPENDABOT_URL, ) assert "`No Patch`" in result def test_multiple_findings_render_multiple_rows(self, reporter: MarkdownReporter) -> None: findings = [ _finding(package_name="requests"), _finding(package_name="urllib3"), ] result = reporter.render(findings, DEPENDABOT_URL) assert "`requests`" in result assert "`urllib3`" in result # header + separator + 2 data rows + blank + summary = 6 lines lines = [line for line in result.strip().split("\n") if line] assert len(lines) == 5 def test_grace_period_and_blocking_date_render( self, reporter: MarkdownReporter, ) -> None: result = reporter.render( [_finding(reason="7 days remaining")], DEPENDABOT_URL, ) assert "Grace Period" in result assert "Blocking Date" in result assert "7 days remaining" in result def test_reason_with_pipe_character( self, reporter: MarkdownReporter, ) -> None: result = reporter.render( [_finding(reason="Upgrade required | immediately")], DEPENDABOT_URL, ) assert "Upgrade required" in result # -------------------------------------------------------- # render — Markdown badges # -------------------------------------------------------- class TestMarkdownBadges: @pytest.mark.parametrize( "severity, expected_badge", [ pytest.param(Severity.CRITICAL, "🔴 CRITICAL", id="critical"), pytest.param(Severity.HIGH, "🟠 HIGH", id="high"), pytest.param(Severity.MODERATE, "🟡 MODERATE", id="moderate"), pytest.param(Severity.LOW, "🟢 LOW", id="low"), ], ) def test_severity_badges( self, reporter: MarkdownReporter, severity: Severity, expected_badge: str, ) -> None: result = reporter.render([_finding(severity=severity)], DEPENDABOT_URL) assert expected_badge in result @pytest.mark.parametrize( "is_blocking, expected_badge", [ pytest.param(True, "🔴 YES", id="blocking"), pytest.param(False, "🟢 NO", id="non-blocking"), ], ) def test_blocking_badges( self, reporter: MarkdownReporter, is_blocking: bool, expected_badge: str, ) -> None: result = reporter.render([_finding(is_blocking=is_blocking)], DEPENDABOT_URL) assert expected_badge in result # -------------------------------------------------------- # render — summary line # -------------------------------------------------------- class TestSummary: def test_summary_counts(self, reporter: MarkdownReporter) -> None: findings = [ _finding(is_blocking=True), _finding(is_blocking=True), _finding(is_blocking=False), ] result = reporter.render(findings, DEPENDABOT_URL) assert "**Total findings:** 3 (2 blocking, 1 non-blocking)" in result def test_single_finding_summary(self, reporter: MarkdownReporter) -> None: result = reporter.render([_finding(is_blocking=True)], DEPENDABOT_URL) assert "**Total findings:** 1 (1 blocking, 0 non-blocking)" in result # -------------------------------------------------------- # render — terminal output via RichReporter # -------------------------------------------------------- class TestTerminalOutput: def test_prints_rich_table_to_terminal(self, reporter: MarkdownReporter) -> None: with patch.object(reporter, "_print_terminal") as mock_print: findings = [_finding()] reporter.render(findings, DEPENDABOT_URL) mock_print.assert_called_once_with(findings, DEPENDABOT_URL) def test_render_returns_markdown_not_rich(self, reporter: MarkdownReporter) -> None: result = reporter.render([_finding()], DEPENDABOT_URL) # Return value is markdown, not Rich terminal output assert result.startswith("| File |") assert "```" not in result