from __future__ import annotations from datetime import UTC, datetime import pytest from tests.helpers import make_alert, make_dependency from vuln_scan.core.models import ( Ecosystem, EcosystemParseResult, Finding, FindingStatus, PolicyDecision, ScanResult, SecurityVulnerability, Severity, ) # -------------------------------------------------------- # Helpers # -------------------------------------------------------- def _decision( *, is_blocking: bool = False, status: FindingStatus = FindingStatus.FIXED ) -> PolicyDecision: return PolicyDecision( blocking_date=None, remaining_days=0, is_blocking=is_blocking, status=status, message="", reason="", ) # -------------------------------------------------------- # Severity.from_string # -------------------------------------------------------- @pytest.mark.parametrize( "value, expected", [ pytest.param("critical", Severity.CRITICAL, id="critical"), pytest.param("high", Severity.HIGH, id="high"), pytest.param("medium", Severity.MODERATE, id="medium-maps-to-moderate"), pytest.param("moderate", Severity.MODERATE, id="moderate"), pytest.param("low", Severity.LOW, id="low"), pytest.param("unexpected", Severity.UNKNOWN, id="unexpected-string"), pytest.param("", Severity.UNKNOWN, id="empty-string"), pytest.param(None, Severity.UNKNOWN, id="none"), ], ) def test_severity_from_string(value: str | None, expected: Severity) -> None: assert Severity.from_string(value) is expected # -------------------------------------------------------- # Dependency # -------------------------------------------------------- def test_dependency_normalized_name_is_lowercase() -> None: assert make_dependency(name="Django-REST-Framework").normalized_name == "django-rest-framework" # -------------------------------------------------------- # SecurityVulnerability — properties # -------------------------------------------------------- @pytest.mark.parametrize( "first_patched, expected", [ pytest.param("2.32.0", True, id="with-patch"), pytest.param(None, False, id="without-patch"), ], ) def test_has_patch_property(first_patched: str | None, expected: bool) -> None: assert make_alert(first_patched=first_patched).has_patch is expected # -------------------------------------------------------- # SecurityVulnerability._parse_datetime # -------------------------------------------------------- def test_parse_datetime_returns_parsed_utc_value() -> None: assert SecurityVulnerability._parse_datetime("2026-01-01T10:15:30Z") == datetime( 2026, 1, 1, 10, 15, 30, tzinfo=UTC ) def test_parse_datetime_falls_back_to_current_utc_when_missing() -> None: before = datetime.now(UTC) parsed = SecurityVulnerability._parse_datetime(None) after = datetime.now(UTC) assert before <= parsed <= after # -------------------------------------------------------- # SecurityVulnerability.from_dependabot_node # -------------------------------------------------------- _DEPENDABOT_FULL_NODE = { "number": 123, "createdAt": "2025-05-20T12:00:00Z", "vulnerableManifestPath": "requirements.txt", "securityVulnerability": { "vulnerableVersionRange": "<2.32.0", "severity": "medium", "package": {"name": "requests", "ecosystem": "pip"}, "firstPatchedVersion": {"identifier": "2.32.0"}, "advisory": {"ghsaId": "GHSA-abcd-efgh-ijkl"}, }, } _DEPENDABOT_MINIMAL_NODE = { "number": "42", "createdAt": "2025-05-20T12:00:00Z", "securityVulnerability": { "severity": "unexpected", "package": {"name": "demo", "ecosystem": "npm"}, "firstPatchedVersion": "not-a-dict", "advisory": {"ghsaId": "GHSA-1111-2222-3333"}, }, } @pytest.mark.parametrize( "node, checks", [ pytest.param( _DEPENDABOT_FULL_NODE, { "id": "123", "ghsa_id": "GHSA-abcd-efgh-ijkl", "package_name": "requests", "ecosystem": Ecosystem.PIP, "vulnerable_manifest_path": "requirements.txt", "vulnerable_range": "<2.32.0", "first_patched_version": "2.32.0", "severity": Severity.MODERATE, "created_at": datetime(2025, 5, 20, 12, 0, 0, tzinfo=UTC), "source": "dependabot", }, id="full-node", ), pytest.param( _DEPENDABOT_MINIMAL_NODE, { "first_patched_version": None, "severity": Severity.UNKNOWN, "ecosystem": Ecosystem.NPM, }, id="missing-patch-unknown-severity", ), ], ) def test_from_dependabot_node(node: dict, checks: dict) -> None: result = SecurityVulnerability.from_dependabot_node(node) for attr, expected in checks.items(): assert getattr(result, attr) == expected, f"{attr}: {getattr(result, attr)} != {expected}" # -------------------------------------------------------- # SecurityVulnerability.from_advisory_node # -------------------------------------------------------- def test_from_advisory_node_maps_fields() -> None: node = { "severity": "high", "vulnerableVersionRange": "<1.0.1", "package": {"name": "pyyaml", "ecosystem": "pip"}, "firstPatchedVersion": {"identifier": "1.0.1"}, "advisory": { "ghsaId": "GHSA-aaaa-bbbb-cccc", "publishedAt": "2024-03-01T00:00:00Z", }, } result = SecurityVulnerability.from_advisory_node(node) assert result.id == "GHSA-aaaa-bbbb-cccc" assert result.ghsa_id == "GHSA-aaaa-bbbb-cccc" assert result.package_name == "pyyaml" assert result.ecosystem is Ecosystem.PIP assert result.vulnerable_manifest_path == "" assert result.vulnerable_range == "<1.0.1" assert result.first_patched_version == "1.0.1" assert result.severity is Severity.HIGH assert result.created_at == datetime(2024, 3, 1, 0, 0, 0, tzinfo=UTC) assert result.source == "advisory" # -------------------------------------------------------- # EcosystemParseResult # -------------------------------------------------------- def test_ecosystem_parse_result_defaults() -> None: result = EcosystemParseResult() assert result.dependencies == [] assert result.manifest_type is None assert result.parser_name is None # -------------------------------------------------------- # Finding.package_name # -------------------------------------------------------- @pytest.mark.parametrize( "dep_name, alert_package_name, expected", [ pytest.param("Flask", "Requests", "flask", id="uses-dependency-normalized-name"), pytest.param(None, "Requests", "Requests", id="falls-back-to-alert-name"), pytest.param(None, "", "unknown", id="falls-back-to-unknown"), ], ) def test_finding_package_name( dep_name: str | None, alert_package_name: str, expected: str, ) -> None: dep = ( make_dependency(name=dep_name, version="3.0.0", requirement="==3.0.0") if dep_name else None ) finding = Finding( dependency=dep, alert=make_alert(package_name=alert_package_name), decision=_decision(), ) assert finding.package_name == expected # -------------------------------------------------------- # ScanResult counts & blocking # -------------------------------------------------------- def test_scan_result_counts_and_blocking_flags() -> None: scenarios = [ (True, FindingStatus.VULNERABLE), (False, FindingStatus.FIXED), (False, FindingStatus.NO_PATCH), (False, FindingStatus.REMOVED), ] findings = [ Finding( dependency=None, alert=make_alert(), decision=_decision(is_blocking=blocking, status=status), ) for blocking, status in scenarios ] result = ScanResult(findings=findings) assert result.any_blocking is True assert result.blocking_count == 1 assert result.vulnerable_count == 1 assert result.fixed_count == 1 assert result.no_patch_count == 1 assert result.removed_count == 1