from __future__ import annotations from pathlib import Path import pytest import semantic_version from semantic_version.base import AllOf, AnyOf, Range, Version from tests.helpers import StubEcosystemHandler, make_alert, make_dependency from vuln_scan.core.models import Ecosystem, Severity from vuln_scan.ecosystems.base import EcosystemHandler # -------------------------------------------------------- # Fixtures # -------------------------------------------------------- @pytest.fixture def handler() -> StubEcosystemHandler: return StubEcosystemHandler( ecosystem=Ecosystem.PIP, handler_id="dummy", manifest_names={"dummy.toml"}, manifest_globs={"*dummy*.txt"}, lockfile_names={"dummy.lock"}, ) # -------------------------------------------------------- # supports_manifest # -------------------------------------------------------- @pytest.mark.parametrize( "manifest_path, expected", [ pytest.param("a/b/dummy.lock", True, id="lockfile-name"), pytest.param("dummy.toml", True, id="manifest-name"), pytest.param("sub/my-dummy-list.txt", True, id="glob-pattern"), pytest.param("requirements.in", False, id="no-match"), ], ) def test_supports_manifest( handler: StubEcosystemHandler, manifest_path: str, expected: bool, ) -> None: assert handler.supports_manifest(manifest_path) is expected # -------------------------------------------------------- # is_lockfile / is_manifest # -------------------------------------------------------- @pytest.mark.parametrize( "filename, expected_lockfile, expected_manifest", [ pytest.param("dummy.lock", True, False, id="lockfile"), pytest.param("dummy.toml", False, True, id="manifest-exact"), pytest.param("my-dummy-list.txt", False, True, id="manifest-glob"), pytest.param("package.json", False, False, id="neither"), ], ) def test_is_lockfile_and_is_manifest( handler: StubEcosystemHandler, filename: str, expected_lockfile: bool, expected_manifest: bool, ) -> None: assert handler.is_lockfile(filename) is expected_lockfile assert handler.is_manifest(filename) is expected_manifest # -------------------------------------------------------- # all_target_filenames / normalize_package_key # -------------------------------------------------------- def test_all_target_filenames_union(handler: StubEcosystemHandler) -> None: assert handler.all_target_filenames == {"dummy.lock", "dummy.toml", "*dummy*.txt"} # -------------------------------------------------------- # _normalize_vuln_range # -------------------------------------------------------- @pytest.mark.parametrize( "spec, expected", [ pytest.param("2.10.0", "==2.10.0", id="plain-version-to-exact"), pytest.param("= 2.10.0", "==2.10.0", id="single-equals-to-double"), pytest.param("<=2.0", "<=2.0", id="existing-spec-unchanged"), pytest.param(" ", "", id="whitespace-returns-empty"), pytest.param(None, "", id="none-returns-empty"), pytest.param("", "", id="empty-returns-empty"), pytest.param(">=1.0.0,<2.0.0", ">=1.0.0,<2.0.0", id="compound-unchanged"), pytest.param("==3.0.0", "==3.0.0", id="double-equals-unchanged"), pytest.param("1.2.3.4", "==1.2.3.4", id="four-part-plain-version"), pytest.param("= 3.0.0", "==3.0.0", id="single-equals-with-space"), ], ) def test_normalize_vuln_range( handler: StubEcosystemHandler, spec: str, expected: str, ) -> None: assert handler._normalize_vuln_range(spec) == expected # -------------------------------------------------------- # _MinimalHandler — defaults # -------------------------------------------------------- def test_base_class_default_filename_sets_are_empty() -> None: minimal = StubEcosystemHandler(handler_id="minimal") assert minimal.lockfile_names == set() assert minimal.manifest_names == set() assert minimal.manifest_globs == set() assert minimal.all_target_filenames == set() assert minimal.supports_manifest("anything.txt") is False # -------------------------------------------------------- # Abstract method bodies raise NotImplementedError # -------------------------------------------------------- @pytest.mark.parametrize( "method, args", [ pytest.param( EcosystemHandler.parse_manifest, ("x", Path("x")), id="parse-manifest", ), pytest.param( EcosystemHandler.parse_manifest_content, ("x", "content"), id="parse-manifest-content", ), pytest.param( EcosystemHandler.is_vulnerable, ( make_dependency(version="2.0.0"), make_alert(severity=Severity.HIGH), ), id="is-vulnerable", ), ], ) def test_abstract_method_bodies_raise_not_implemented( handler: StubEcosystemHandler, method: callable, args: tuple, ) -> None: with pytest.raises(NotImplementedError): method(handler, *args) # -------------------------------------------------------- # _normalize_version — default identity # -------------------------------------------------------- @pytest.mark.parametrize( "version", [ pytest.param("1.2.3", id="semver"), pytest.param("0.0.0-alpha", id="prerelease"), pytest.param("", id="empty-string"), ], ) def test_normalize_version_returns_input_unchanged( handler: StubEcosystemHandler, version: str, ) -> None: assert handler._normalize_version(version) == version # -------------------------------------------------------- # _build_spec — default raises NotImplementedError # -------------------------------------------------------- def test_build_spec_raises_not_implemented(handler: StubEcosystemHandler) -> None: with pytest.raises(NotImplementedError): handler._build_spec(">=1.0.0") # -------------------------------------------------------- # _Interval.overlaps # -------------------------------------------------------- _Interval = EcosystemHandler._Interval @pytest.mark.parametrize( "a, b, expected", [ pytest.param( _Interval(None, None, False, False), _Interval(None, None, False, False), True, id="both-unbounded", ), pytest.param( _Interval(None, Version("5.0.0"), False, True), _Interval(Version("3.0.0"), None, True, False), True, id="no-lower-bound-overlaps", ), pytest.param( _Interval(Version("3.0.0"), None, True, False), _Interval(None, Version("5.0.0"), False, True), True, id="no-upper-bound-overlaps", ), pytest.param( _Interval(Version("1.0.0"), Version("2.0.0"), True, True), _Interval(Version("3.0.0"), Version("4.0.0"), True, True), False, id="disjoint-no-overlap", ), pytest.param( _Interval(Version("1.0.0"), Version("3.0.0"), True, True), _Interval(Version("3.0.0"), Version("5.0.0"), True, True), True, id="adjacent-both-inclusive-overlap", ), pytest.param( _Interval(Version("1.0.0"), Version("3.0.0"), True, False), _Interval(Version("3.0.0"), Version("5.0.0"), False, True), False, id="adjacent-both-exclusive-no-overlap", ), pytest.param( _Interval(Version("1.0.0"), Version("10.0.0"), True, True), _Interval(Version("3.0.0"), Version("5.0.0"), True, True), True, id="fully-contained-overlaps", ), pytest.param( _Interval(Version("3.0.0"), Version("3.0.0"), True, True), _Interval(Version("3.0.0"), Version("3.0.0"), True, True), True, id="exact-same-point-both-inclusive", ), pytest.param( _Interval(None, Version("2.0.0"), False, True), _Interval(Version("5.0.0"), None, True, False), False, id="one-lower-none-disjoint", ), pytest.param( _Interval(Version("5.0.0"), Version("8.0.0"), True, True), _Interval(Version("3.0.0"), Version("6.0.0"), True, True), True, id="self-lower-gt-other-lower", ), ], ) def test_interval_overlaps( a: EcosystemHandler._Interval, b: EcosystemHandler._Interval, expected: bool, ) -> None: assert a.overlaps(b) is expected # -------------------------------------------------------- # _range_to_interval # -------------------------------------------------------- @pytest.mark.parametrize( "op, version_str, exp_lower, exp_upper, exp_inc_lower, exp_inc_upper", [ pytest.param(">", "1.0.0", Version("1.0.0"), None, False, False, id="gt"), pytest.param(">=", "2.0.0", Version("2.0.0"), None, True, False, id="gte"), pytest.param("<", "3.0.0", None, Version("3.0.0"), False, False, id="lt"), pytest.param("<=", "4.0.0", None, Version("4.0.0"), False, True, id="lte"), pytest.param("==", "5.0.0", Version("5.0.0"), Version("5.0.0"), True, True, id="eq"), ], ) def test_range_to_interval( handler: StubEcosystemHandler, op: str, version_str: str, exp_lower: Version | None, exp_upper: Version | None, exp_inc_lower: bool, exp_inc_upper: bool, ) -> None: r = Range(op, Version(version_str)) interval = handler._range_to_interval(r) assert interval.lower == exp_lower assert interval.upper == exp_upper assert interval.include_lower is exp_inc_lower assert interval.include_upper is exp_inc_upper def test_range_to_interval_unsupported_operator(handler: StubEcosystemHandler) -> None: r = Range("!=", Version("1.0.0")) with pytest.raises(ValueError, match="Unsupported operator"): handler._range_to_interval(r) # -------------------------------------------------------- # _intersect_intervals # -------------------------------------------------------- @pytest.mark.parametrize( "intervals, exp_lower, exp_upper, exp_inc_lower, exp_inc_upper", [ pytest.param( [_Interval(Version("1.0.0"), Version("5.0.0"), True, True)], Version("1.0.0"), Version("5.0.0"), True, True, id="single-interval", ), pytest.param( [ _Interval(Version("1.0.0"), Version("5.0.0"), True, True), _Interval(Version("3.0.0"), Version("8.0.0"), True, True), ], Version("3.0.0"), Version("5.0.0"), True, True, id="two-overlapping", ), pytest.param( [ _Interval(Version("1.0.0"), None, True, False), _Interval(None, Version("5.0.0"), False, True), ], Version("1.0.0"), Version("5.0.0"), True, # >=1.0.0 contributes the lower → inclusive True, # <=5.0.0 contributes the upper → inclusive id="open-bounds-intersected", ), pytest.param( [ _Interval(Version("2.0.0"), Version("5.0.0"), True, True), _Interval(Version("2.0.0"), Version("5.0.0"), False, False), ], Version("2.0.0"), Version("5.0.0"), False, False, id="inclusiveness-anded", ), pytest.param( [ _Interval(None, Version("5.0.0"), False, True), _Interval(Version("2.0.0"), Version("4.0.0"), True, True), ], Version("2.0.0"), Version("4.0.0"), True, True, id="result-lower-none-takes-other-lower", ), pytest.param( [ _Interval(Version("5.0.0"), Version("10.0.0"), True, True), _Interval(Version("3.0.0"), Version("8.0.0"), True, True), ], Version("5.0.0"), Version("8.0.0"), True, True, id="result-lower-gt-other-lower", ), pytest.param( [ _Interval(Version("1.0.0"), Version("5.0.0"), True, True), _Interval(Version("3.0.0"), None, True, False), ], Version("3.0.0"), Version("5.0.0"), True, True, id="other-upper-none-takes-result-upper", ), pytest.param( [ _Interval(Version("1.0.0"), Version("8.0.0"), True, True), _Interval(Version("2.0.0"), Version("5.0.0"), True, True), ], Version("2.0.0"), Version("5.0.0"), True, True, id="other-upper-lt-result-upper", ), ], ) def test_intersect_intervals( handler: StubEcosystemHandler, intervals: list[EcosystemHandler._Interval], exp_lower: Version | None, exp_upper: Version | None, exp_inc_lower: bool, exp_inc_upper: bool, ) -> None: result = handler._intersect_intervals(intervals) assert result.lower == exp_lower assert result.upper == exp_upper assert result.include_lower is exp_inc_lower assert result.include_upper is exp_inc_upper # -------------------------------------------------------- # _clause_to_intervals # -------------------------------------------------------- @pytest.mark.parametrize( "clause, expected_count", [ pytest.param( Range(">=", Version("1.0.0")), 1, id="range-clause", ), pytest.param( AllOf(Range(">=", Version("1.0.0")), Range("<", Version("3.0.0"))), 1, id="allof-clause", ), pytest.param( AnyOf(Range(">=", Version("1.0.0")), Range(">=", Version("5.0.0"))), 2, id="anyof-clause", ), pytest.param( "not-a-clause", 0, id="unknown-type-empty", ), ], ) def test_clause_to_intervals_count( handler: StubEcosystemHandler, clause: Range | AllOf | AnyOf, expected_count: int, ) -> None: assert len(handler._clause_to_intervals(clause)) == expected_count def test_clause_to_intervals_allof_bounds(handler: StubEcosystemHandler) -> None: clause = AllOf(Range(">=", Version("1.0.0")), Range("<", Version("3.0.0"))) intervals = handler._clause_to_intervals(clause) assert intervals[0].lower == Version("1.0.0") assert intervals[0].upper == Version("3.0.0") assert intervals[0].include_lower is True assert intervals[0].include_upper is False # -------------------------------------------------------- # Helper: handler with working _build_spec # -------------------------------------------------------- @pytest.fixture def npm_handler() -> StubEcosystemHandler: """Handler whose _build_spec delegates to NpmSpec.""" h = StubEcosystemHandler(handler_id="npm-like") h._build_spec = lambda s: semantic_version.NpmSpec(s) # type: ignore[assignment] return h # -------------------------------------------------------- # _ranges_overlap # -------------------------------------------------------- @pytest.mark.parametrize( "r1, r2, expected", [ pytest.param(">=1.0.0 <3.0.0", ">=2.0.0 <4.0.0", True, id="overlapping"), pytest.param(">=1.0.0 <2.0.0", ">=3.0.0 <4.0.0", False, id="disjoint"), pytest.param(">=1.0.0 <2.0.0", "1.5.0", True, id="exact-inside"), pytest.param(">=1.0.0 <2.0.0", "3.0.0", False, id="exact-outside"), ], ) def test_ranges_overlap( npm_handler: StubEcosystemHandler, r1: str, r2: str, expected: bool, ) -> None: assert npm_handler._ranges_overlap(r1, r2) is expected def test_ranges_overlap_returns_true_on_error(handler: StubEcosystemHandler) -> None: """Default _build_spec raises → caught → fail-open returns True.""" assert handler._ranges_overlap(">=1.0.0", ">=2.0.0") is True # -------------------------------------------------------- # _version_in_range # -------------------------------------------------------- @pytest.mark.parametrize( "version, vuln_range, expected", [ pytest.param(Version("1.5.0"), ">=1.0.0 <2.0.0", True, id="inside-range"), pytest.param(Version("3.0.0"), ">=1.0.0 <2.0.0", False, id="outside-range"), pytest.param(Version("1.0.0"), ">=1.0.0 <2.0.0", True, id="inclusive-lower"), pytest.param(Version("2.0.0"), ">=1.0.0 <2.0.0", False, id="exclusive-upper"), ], ) def test_version_in_range( npm_handler: StubEcosystemHandler, version: Version, vuln_range: str, expected: bool, ) -> None: assert npm_handler._version_in_range(version, vuln_range) is expected def test_version_in_range_returns_true_on_error(handler: StubEcosystemHandler) -> None: """Default _build_spec raises → caught → fail-open returns True.""" assert handler._version_in_range(Version("1.0.0"), ">=1.0.0") is True