from __future__ import annotations import pytest from tests.helpers import make_dependency, make_graph from vuln_scan.core.dependency_graph import DependencyGraph from vuln_scan.core.models import Dependency, Ecosystem # -------------------------------------------------------- # Fixtures # -------------------------------------------------------- @pytest.fixture def graph() -> DependencyGraph: return DependencyGraph() # -------------------------------------------------------- # add_dependency / basic queries # -------------------------------------------------------- def test_add_dependency_populates_all_indexes(graph: DependencyGraph) -> None: dep = make_dependency(name="Requests", version="2.31.0", requirement="==2.31.0") graph.add_dependency("requirements.txt", dep) assert graph.dependency_count() == 1 assert graph.get_manifest_paths() == {"requirements.txt"} assert graph.get_dependencies(Ecosystem.PIP, "requests", "requirements.txt") == [dep] assert graph.get_dependencies_for_manifest("requirements.txt") == [dep] @pytest.mark.parametrize( "method, args, expected", [ pytest.param( "get_dependencies", (Ecosystem.PIP, "missing", "requirements.txt"), [], id="get-dependencies-missing", ), pytest.param( "get_version", (Ecosystem.PIP, "missing", "requirements.txt"), None, id="get-version-missing", ), ], ) def test_queries_return_empty_or_none_when_missing( graph: DependencyGraph, method: str, args: tuple, expected: list | None, ) -> None: assert getattr(graph, method)(*args) == expected def test_get_version_returns_first_occurrence(graph: DependencyGraph) -> None: graph.add_dependency("requirements.txt", make_dependency(name="flask", version="2.0.0")) graph.add_dependency("requirements.txt", make_dependency(name="Flask", version="2.1.0")) assert graph.get_version(Ecosystem.PIP, "FLASK", "requirements.txt") == "2.0.0" # -------------------------------------------------------- # packages / package_versions # -------------------------------------------------------- def test_packages_and_versions_are_unique_and_normalized() -> None: g = make_graph( ("requirements.txt", make_dependency(name="Requests", version="2.30.0")), ( "constraints.txt", make_dependency(name="requests", manifest_path="constraints.txt", version="2.32.0"), ), ) assert g.packages() == {(Ecosystem.PIP, "requests")} assert g.package_versions() == {(Ecosystem.PIP, "requests"): {"2.30.0", "2.32.0"}} @pytest.mark.parametrize( "deps, ecosystem, package, manifest, expected", [ pytest.param( [ ( "yarn.lock", make_dependency( name="minimatch", version="3.1.2", ecosystem=Ecosystem.NPM, manifest_path="yarn.lock", is_lockfile=True, ), ), ( "yarn.lock", make_dependency( name="minimatch", version="5.1.6", ecosystem=Ecosystem.NPM, manifest_path="yarn.lock", is_lockfile=True, ), ), ( "yarn.lock", make_dependency( name="minimatch", version="9.0.3", ecosystem=Ecosystem.NPM, manifest_path="yarn.lock", is_lockfile=True, ), ), ], Ecosystem.NPM, "minimatch", "yarn.lock", {"3.1.2", "5.1.6", "9.0.3"}, id="multiple-versions", ), pytest.param( [ ( "package.json", make_dependency( name="express", version=None, requirement="^4.0.0", ecosystem=Ecosystem.NPM, manifest_path="package.json", ), ), ], Ecosystem.NPM, "express", "package.json", set(), id="none-versions-excluded", ), pytest.param( [], Ecosystem.PIP, "missing", "requirements.txt", set(), id="missing-package-returns-empty", ), ], ) def test_get_versions( graph: DependencyGraph, deps: list[tuple[str, Dependency]], ecosystem: Ecosystem, package: str, manifest: str, expected: set[str], ) -> None: for manifest_path, dep in deps: graph.add_dependency(manifest_path, dep) assert set(graph.get_versions(ecosystem, package, manifest)) == expected # -------------------------------------------------------- # to_manifest_package_map — priority order # -------------------------------------------------------- def test_to_manifest_package_map_uses_priority_order() -> None: g = make_graph( ( "poetry.lock", make_dependency( name="urllib3", manifest_path="poetry.lock", version=None, requirement=">=1.26", is_lockfile=True, ), ), ( "requirements.txt", make_dependency(name="urllib3", version="1.26.0"), ), ( "requirements.txt", make_dependency(name="urllib3", requirement=">=1.25"), ), ( "poetry.lock", make_dependency( name="urllib3", manifest_path="poetry.lock", version="1.26.18", is_lockfile=True ), ), ) result = g.to_manifest_package_map() assert result[(Ecosystem.PIP, "urllib3", "poetry.lock")] == "1.26.18" assert result[(Ecosystem.PIP, "urllib3", "requirements.txt")] == "1.26.0" # -------------------------------------------------------- # introduced / removed packages # -------------------------------------------------------- @pytest.mark.parametrize( "method, expected", [ pytest.param( "introduced_packages", {(Ecosystem.PIP, "urllib3")}, id="introduced", ), pytest.param( "removed_packages", {(Ecosystem.PIP, "requests")}, id="removed", ), ], ) def test_introduced_and_removed_packages( method: str, expected: set, ) -> None: base = make_graph( ("requirements.txt", make_dependency(name="requests", version="2.30.0")), ) current = make_graph( ( "constraints.txt", make_dependency(name="urllib3", manifest_path="constraints.txt", version="1.26.18"), ), ) assert getattr(current, method)(base) == expected # -------------------------------------------------------- # changed_versions # -------------------------------------------------------- def test_changed_versions_returns_base_and_current_values() -> None: base = make_graph( ("requirements.txt", make_dependency(name="requests", version="2.30.0")), ) current = make_graph( ("requirements.txt", make_dependency(name="requests", version="2.32.0")), ) assert current.changed_versions(base) == { (Ecosystem.PIP, "requests"): ("2.30.0", "2.32.0"), } def test_changed_versions_keeps_first_manifest_difference_per_package() -> None: base = make_graph( ( "requirements-a.txt", make_dependency(name="requests", manifest_path="requirements-a.txt", version="2.30.0"), ), ( "requirements-b.txt", make_dependency(name="requests", manifest_path="requirements-b.txt", version="2.30.0"), ), ) current = make_graph( ( "requirements-a.txt", make_dependency(name="requests", manifest_path="requirements-a.txt", version="2.31.0"), ), ( "requirements-b.txt", make_dependency(name="requests", manifest_path="requirements-b.txt", version="2.32.0"), ), ) changed = current.changed_versions(base) assert (Ecosystem.PIP, "requests") in changed assert changed[(Ecosystem.PIP, "requests")][0] == "2.30.0" assert changed[(Ecosystem.PIP, "requests")][1] in {"2.31.0", "2.32.0"} # -------------------------------------------------------- # introduced_versioned # -------------------------------------------------------- def test_introduced_versioned_new_package() -> None: """Entirely new package: all its versions should appear as introduced.""" base = make_graph( ("requirements.txt", make_dependency(name="requests", version="2.30.0")), ) current = make_graph( ("requirements.txt", make_dependency(name="requests", version="2.30.0")), ("requirements.txt", make_dependency(name="urllib3", version="1.26.18")), ) assert current.introduced_versioned(base) == { (Ecosystem.PIP, "urllib3", "1.26.18"), } def test_introduced_versioned_new_version_of_existing_package() -> None: """ Core npm scenario: lodash@4.17.15 is added as a new transitive dep while lodash@4.17.21 already existed on the default branch. Only the new version should be introduced — not the pre-existing one. """ base = make_graph( ( "yarn.lock", make_dependency( name="lodash", version="4.17.21", ecosystem=Ecosystem.NPM, manifest_path="yarn.lock", is_lockfile=True, ), ), ) current = make_graph( ( "yarn.lock", make_dependency( name="lodash", version="4.17.21", ecosystem=Ecosystem.NPM, manifest_path="yarn.lock", is_lockfile=True, ), ), ( "yarn.lock", make_dependency( name="lodash", version="4.17.15", ecosystem=Ecosystem.NPM, manifest_path="yarn.lock", is_lockfile=True, ), ), ) result = current.introduced_versioned(base) assert (Ecosystem.NPM, "lodash", "4.17.15") in result assert (Ecosystem.NPM, "lodash", "4.17.21") not in result def test_introduced_versioned_unchanged_package_not_included() -> None: """A package present in both graphs at the same version should not appear.""" base = make_graph( ("requirements.txt", make_dependency(name="requests", version="2.30.0")), ) current = make_graph( ("requirements.txt", make_dependency(name="requests", version="2.30.0")), ) assert current.introduced_versioned(base) == set() def test_introduced_versioned_empty_base() -> None: """Everything in current should be introduced when base is empty.""" base = DependencyGraph() current = make_graph( ( "yarn.lock", make_dependency( name="express", version="4.18.2", ecosystem=Ecosystem.NPM, manifest_path="yarn.lock", is_lockfile=True, ), ), ( "yarn.lock", make_dependency( name="express", version="4.17.1", ecosystem=Ecosystem.NPM, manifest_path="yarn.lock", is_lockfile=True, ), ), ) assert current.introduced_versioned(base) == { (Ecosystem.NPM, "express", "4.18.2"), (Ecosystem.NPM, "express", "4.17.1"), } def test_introduced_versioned_removed_package_not_included() -> None: """A package removed in current (only in base) should not appear in introduced.""" base = make_graph( ("requirements.txt", make_dependency(name="requests", version="2.30.0")), ("requirements.txt", make_dependency(name="urllib3", version="1.26.18")), ) current = make_graph( ("requirements.txt", make_dependency(name="requests", version="2.30.0")), ) assert current.introduced_versioned(base) == set() def test_introduced_versioned_version_upgrade_treated_as_introduced() -> None: """ Single-version ecosystem (pip): upgrading requests 2.30.0 → 2.32.0 means 2.32.0 is introduced (new key) and 2.30.0 is removed (old key). """ base = make_graph( ("requirements.txt", make_dependency(name="requests", version="2.30.0")), ) current = make_graph( ("requirements.txt", make_dependency(name="requests", version="2.32.0")), ) result = current.introduced_versioned(base) assert (Ecosystem.PIP, "requests", "2.32.0") in result assert (Ecosystem.PIP, "requests", "2.30.0") not in result # -------------------------------------------------------- # Stats helpers # -------------------------------------------------------- def test_stats_helpers() -> None: g = make_graph( ("requirements.txt", make_dependency(name="requests", version="2.30.0")), ( "requirements-dev.txt", make_dependency(name="pytest", manifest_path="requirements-dev.txt", version="9.0.0"), ), ) assert g.dependency_count() == 2 assert g.manifest_count() == 2 assert g.package_count() == 2 assert g.get_manifests() == {"requirements.txt", "requirements-dev.txt"}