@@ -523,4 +523,207 @@ def test_run_tool_not_found(self):
523523 mock_run .side_effect = FileNotFoundError ("command not found" )
524524
525525 with pytest .raises (FileNotFoundError ):
526- _run_tool ("nonexistent_tool" , ["--help" ])
526+ _run_tool ("nonexistent_tool" , ["--help" ])
527+
528+ class TestDownloadAndScanPackage :
529+ """Test cases for download_and_scan_package tool."""
530+
531+ @pytest .mark .asyncio
532+ async def test_purl2notices_success (self ):
533+ """Test successful analysis using purl2notices (primary method)."""
534+ # Mock purl2notices cache output
535+ cache_data = {
536+ "components" : [{
537+ "name" : "express" ,
538+ "version" : "4.21.2" ,
539+ "purl" : "pkg:npm/express@4.21.2" ,
540+ "licenses" : [
541+ {"license" : {"id" : "MIT" }},
542+ {"license" : {"id" : "MIT-or-later" }}
543+ ],
544+ "properties" : [
545+ {"name" : "copyright" , "value" : "Copyright 2009-2013 TJ Holowaychuk" },
546+ {"name" : "copyright" , "value" : "Copyright 2014-2015 Douglas Christopher Wilson" }
547+ ]
548+ }]
549+ }
550+
551+ with patch ("mcp_semclone.server._run_tool" ) as mock_run , \
552+ patch ("builtins.open" , create = True ) as mock_open , \
553+ patch ("pathlib.Path.unlink" ):
554+
555+ # Mock purl2notices execution
556+ mock_run .return_value = MagicMock (returncode = 0 , stdout = "success" )
557+
558+ # Mock cache file read
559+ mock_open .return_value .__enter__ .return_value .read .return_value = json .dumps (cache_data )
560+
561+ result = await server_module .download_and_scan_package (
562+ purl = "pkg:npm/express@4.21.2"
563+ )
564+
565+ # Verify method used
566+ assert result ["method_used" ] == "purl2notices"
567+ assert result ["purl" ] == "pkg:npm/express@4.21.2"
568+
569+ # Verify data extracted
570+ assert result ["metadata" ]["name" ] == "express"
571+ assert result ["metadata" ]["version" ] == "4.21.2"
572+ assert "MIT" in result ["detected_licenses" ]
573+ assert len (result ["copyright_statements" ]) == 2
574+ assert "purl2notices" in result ["methods_attempted" ]
575+
576+ @pytest .mark .asyncio
577+ async def test_deep_scan_fallback (self ):
578+ """Test deep scan fallback when purl2notices fails."""
579+ # Mock purl2src output
580+ purl2src_data = [{
581+ "purl" : "pkg:npm/express@4.21.2" ,
582+ "download_url" : "https://registry.npmjs.org/express/-/express-4.21.2.tgz" ,
583+ "validated" : True
584+ }]
585+
586+ # Mock osslili output
587+ osslili_data = {
588+ "components" : [{
589+ "licenses" : [{"license" : {"id" : "MIT" }}],
590+ "properties" : [
591+ {"name" : "copyright" , "value" : "Copyright Test" }
592+ ]
593+ }]
594+ }
595+
596+ # Mock upmex output
597+ upmex_data = {
598+ "name" : "express" ,
599+ "version" : "4.21.2" ,
600+ "license" : "MIT"
601+ }
602+
603+ with patch ("mcp_semclone.server._run_tool" ) as mock_run , \
604+ patch ("urllib.request.urlretrieve" ) as mock_download , \
605+ patch ("tempfile.mkdtemp" , return_value = "/tmp/test" ), \
606+ patch ("pathlib.Path.exists" , return_value = True ), \
607+ patch ("shutil.rmtree" ):
608+
609+ def run_tool_side_effect (tool_name , args , * a , ** kw ):
610+ if tool_name == "purl2notices" :
611+ # purl2notices fails
612+ return MagicMock (returncode = 1 , stdout = "" , stderr = "failed" )
613+ elif tool_name == "purl2src" :
614+ return MagicMock (returncode = 0 , stdout = json .dumps (purl2src_data ))
615+ elif tool_name == "osslili" :
616+ return MagicMock (returncode = 0 , stdout = json .dumps (osslili_data ))
617+ elif tool_name == "upmex" :
618+ return MagicMock (returncode = 0 , stdout = json .dumps (upmex_data ))
619+ return MagicMock (returncode = 1 , stdout = "" )
620+
621+ mock_run .side_effect = run_tool_side_effect
622+
623+ result = await server_module .download_and_scan_package (
624+ purl = "pkg:npm/express@4.21.2"
625+ )
626+
627+ # Verify deep scan was used
628+ assert result ["method_used" ] == "deep_scan"
629+ assert "purl2notices" in result ["methods_attempted" ]
630+ assert "deep_scan" in result ["methods_attempted" ]
631+
632+ # Verify download was called
633+ mock_download .assert_called_once ()
634+
635+ # Verify data extracted
636+ assert "MIT" in result ["detected_licenses" ]
637+ assert len (result ["copyright_statements" ]) >= 1
638+
639+ @pytest .mark .asyncio
640+ async def test_online_fallback (self ):
641+ """Test online fallback when deep scan fails."""
642+ # Mock upmex online output
643+ upmex_data = {
644+ "name" : "express" ,
645+ "version" : "4.21.2" ,
646+ "license" : "MIT"
647+ }
648+
649+ with patch ("mcp_semclone.server._run_tool" ) as mock_run , \
650+ patch ("builtins.open" , create = True ), \
651+ patch ("pathlib.Path.unlink" ):
652+
653+ def run_tool_side_effect (tool_name , args , * a , ** kw ):
654+ if tool_name == "purl2notices" :
655+ # purl2notices fails
656+ return MagicMock (returncode = 1 , stdout = "" , stderr = "failed" )
657+ elif tool_name == "purl2src" :
658+ # purl2src fails (no download URL)
659+ return MagicMock (returncode = 1 , stdout = "[]" )
660+ elif tool_name == "upmex" and "--api" in args :
661+ # Online API succeeds
662+ return MagicMock (returncode = 0 , stdout = json .dumps (upmex_data ))
663+ return MagicMock (returncode = 1 , stdout = "" )
664+
665+ mock_run .side_effect = run_tool_side_effect
666+
667+ result = await server_module .download_and_scan_package (
668+ purl = "pkg:pypi/somepackage@1.0.0"
669+ )
670+
671+ # Verify online fallback was used
672+ assert result ["method_used" ] == "online_fallback"
673+ assert "purl2notices" in result ["methods_attempted" ]
674+ assert "deep_scan" in result ["methods_attempted" ]
675+ assert "online_fallback" in result ["methods_attempted" ]
676+
677+ # Verify metadata extracted
678+ assert result ["metadata" ]["license" ] == "MIT"
679+
680+ @pytest .mark .asyncio
681+ async def test_all_methods_fail (self ):
682+ """Test behavior when all methods fail."""
683+ with patch ("mcp_semclone.server._run_tool" ) as mock_run , \
684+ patch ("builtins.open" , create = True ), \
685+ patch ("pathlib.Path.unlink" ):
686+
687+ # All tools fail
688+ mock_run .return_value = MagicMock (returncode = 1 , stdout = "" , stderr = "failed" )
689+
690+ result = await server_module .download_and_scan_package (
691+ purl = "pkg:npm/invalid@0.0.0"
692+ )
693+
694+ # Verify error state
695+ assert result ["method_used" ] is None
696+ assert result ["error" ] == "All methods failed to retrieve package data"
697+ assert len (result ["methods_attempted" ]) == 3
698+ assert "purl2notices" in result ["methods_attempted" ]
699+ assert "deep_scan" in result ["methods_attempted" ]
700+ assert "online_fallback" in result ["methods_attempted" ]
701+
702+ @pytest .mark .asyncio
703+ async def test_keep_download (self ):
704+ """Test that keep_download preserves downloaded files."""
705+ cache_data = {
706+ "components" : [{
707+ "name" : "express" ,
708+ "version" : "4.21.2" ,
709+ "purl" : "pkg:npm/express@4.21.2" ,
710+ "licenses" : [{"license" : {"id" : "MIT" }}],
711+ "properties" : []
712+ }]
713+ }
714+
715+ with patch ("mcp_semclone.server._run_tool" ) as mock_run , \
716+ patch ("builtins.open" , create = True ) as mock_open , \
717+ patch ("pathlib.Path.unlink" ), \
718+ patch ("shutil.rmtree" ) as mock_rmtree :
719+
720+ mock_run .return_value = MagicMock (returncode = 0 , stdout = "success" )
721+ mock_open .return_value .__enter__ .return_value .read .return_value = json .dumps (cache_data )
722+
723+ result = await server_module .download_and_scan_package (
724+ purl = "pkg:npm/express@4.21.2" ,
725+ keep_download = True
726+ )
727+
728+ # Verify cleanup was NOT called (keep_download=True)
729+ mock_rmtree .assert_not_called ()
0 commit comments