#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Unit tests for the Security Scanner service
"""

import unittest
from unittest.mock import patch, MagicMock, mock_open
import os
import sys
import json
from pathlib import Path

# Add the project root directory to the Python path
project_root = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(project_root))

from src.services.security_scanner import SecurityScanner


class TestSecurityScanner(unittest.TestCase):
    """Test cases for the SecurityScanner class"""

    def setUp(self):
        """Set up test fixtures"""
        self.scanner = SecurityScanner()
        self.test_repo_path = "/test/repo"
    
    @patch('os.path.exists')
    @patch('subprocess.run')
    def test_scan_python_dependencies(self, mock_run, mock_exists):
        """Test scan_python_dependencies method"""
        # Set up the mocks
        mock_exists.return_value = True
        
        # Mock the requirements.txt file
        with patch('builtins.open', mock_open(read_data="requests==2.25.1\ndjango==2.2.0\n")):
            # Mock the subprocess.run result
            mock_process = MagicMock()
            mock_process.returncode = 0
            mock_process.stdout = json.dumps({
                "vulnerabilities": [
                    {
                        "package_name": "django",
                        "vulnerable_spec": "<2.2.28",
                        "installed_version": "2.2.0",
                        "description": "Django before 2.2.28 has a potential directory traversal via ../ in the file name.",
                        "id": "CVE-2022-34265",
                        "cvss_v3_score": "7.5"
                    }
                ]
            })
            mock_run.return_value = mock_process
            
            # Call the method
            result = self.scanner.scan_python_dependencies(self.test_repo_path)
            
            # Verify the result
            self.assertEqual(len(result['vulnerabilities']), 1)
            self.assertEqual(result['vulnerability_count'], 1)
            self.assertEqual(result['vulnerabilities'][0]['package'], 'django')
            self.assertEqual(result['vulnerabilities'][0]['installed_version'], '2.2.0')
            self.assertEqual(result['vulnerabilities'][0]['vulnerability_id'], 'CVE-2022-34265')
            self.assertEqual(result['vulnerabilities'][0]['severity'], 'high')  # 7.5 maps to high
    
    @patch('os.path.exists')
    @patch('subprocess.run')
    def test_scan_javascript_dependencies(self, mock_run, mock_exists):
        """Test scan_javascript_dependencies method"""
        # Set up the mocks
        mock_exists.return_value = True
        
        # Mock the subprocess.run result
        mock_process = MagicMock()
        mock_process.returncode = 0
        mock_process.stdout = json.dumps({
            "vulnerabilities": {
                "lodash": [
                    {
                        "name": "lodash",
                        "severity": "high",
                        "via": [
                            {
                                "source": 1065,
                                "name": "lodash",
                                "dependency": "lodash",
                                "title": "Prototype Pollution",
                                "url": "https://npmjs.com/advisories/1065",
                                "severity": "high",
                                "range": "<4.17.12"
                            }
                        ],
                        "effects": [],
                        "range": "<4.17.12",
                        "nodes": ["node_modules/lodash"],
                        "fixAvailable": true
                    }
                ]
            }
        })
        mock_run.return_value = mock_process
        
        # Call the method
        result = self.scanner.scan_javascript_dependencies(self.test_repo_path)
        
        # Verify the result
        self.assertEqual(len(result['vulnerabilities']), 1)
        self.assertEqual(result['vulnerability_count'], 1)
        self.assertEqual(result['vulnerabilities'][0]['package'], 'lodash')
        self.assertEqual(result['vulnerabilities'][0]['severity'], 'high')
        self.assertEqual(result['vulnerabilities'][0]['title'], 'Prototype Pollution')
    
    @patch('os.path.exists')
    @patch('subprocess.run')
    def test_scan_go_dependencies(self, mock_run, mock_exists):
        """Test scan_go_dependencies method"""
        # Set up the mocks
        mock_exists.return_value = True
        
        # Mock the subprocess.run result
        mock_process = MagicMock()
        mock_process.returncode = 0
        mock_process.stdout = json.dumps({
            "Vulns": [
                {
                    "ID": "GO-2020-0015",
                    "Details": "Improper certificate validation in crypto/x509",
                    "Affected": [
                        {
                            "Module": {
                                "Path": "golang.org/x/crypto",
                                "Versions": [
                                    {
                                        "Fixed": "v0.0.0-20200221170555-0f29369cfe45"
                                    }
                                ]
                            },
                            "Packages": [
                                {
                                    "Path": "golang.org/x/crypto/cryptobyte",
                                    "Symbols": ["String.ReadASN1"]
                                }
                            ]
                        }
                    ],
                    "References": [
                        {
                            "Type": "FIX",
                            "URL": "https://go.dev/cl/219877"
                        },
                        {
                            "Type": "REPORT",
                            "URL": "https://go.dev/issue/36837"
                        },
                        {
                            "Type": "WEB",
                            "URL": "https://nvd.nist.gov/vuln/detail/CVE-2020-7919"
                        }
                    ],
                    "Description": "Due to improper bounds checking, maliciously crafted X.509 certificates can cause a panic in certificate verification.",
                    "CVEs": ["CVE-2020-7919"],
                    "Severity": "MODERATE"
                }
            ]
        })
        mock_run.return_value = mock_process
        
        # Call the method
        result = self.scanner.scan_go_dependencies(self.test_repo_path)
        
        # Verify the result
        self.assertEqual(len(result['vulnerabilities']), 1)
        self.assertEqual(result['vulnerability_count'], 1)
        self.assertEqual(result['vulnerabilities'][0]['package'], 'golang.org/x/crypto')
        self.assertEqual(result['vulnerabilities'][0]['vulnerability_id'], 'GO-2020-0015')
        self.assertEqual(result['vulnerabilities'][0]['severity'], 'medium')  # MODERATE maps to medium
    
    @patch('os.path.exists')
    @patch('subprocess.run')
    def test_scan_rust_dependencies(self, mock_run, mock_exists):
        """Test scan_rust_dependencies method"""
        # Set up the mocks
        mock_exists.return_value = True
        
        # Mock the subprocess.run result
        mock_process = MagicMock()
        mock_process.returncode = 0
        mock_process.stdout = json.dumps({
            "vulnerabilities": {
                "RUSTSEC-2020-0071": {
                    "advisory": {
                        "id": "RUSTSEC-2020-0071",
                        "package": "smallvec",
                        "title": "Buffer overflow in SmallVec::insert_many",
                        "description": "Affected versions of smallvec did not properly calculate capacity when inserting multiple elements, which could result in a buffer overflow.",
                        "date": "2020-12-02",
                        "aliases": ["CVE-2021-25900"],
                        "categories": ["memory-corruption"],
                        "keywords": ["buffer-overflow", "heap-overflow"],
                        "cvss": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
                        "related": []
                    },
                    "versions": {
                        "patched": [">=1.6.1"],
                        "unaffected": ["<1.0.0"]
                    },
                    "affected": {
                        "arch": [],
                        "os": [],
                        "functions": ["smallvec::SmallVec::insert_many"]
                    }
                }
            },
            "warnings": []
        })
        mock_run.return_value = mock_process
        
        # Call the method
        result = self.scanner.scan_rust_dependencies(self.test_repo_path)
        
        # Verify the result
        self.assertEqual(len(result['vulnerabilities']), 1)
        self.assertEqual(result['vulnerability_count'], 1)
        self.assertEqual(result['vulnerabilities'][0]['package'], 'smallvec')
        self.assertEqual(result['vulnerabilities'][0]['vulnerability_id'], 'RUSTSEC-2020-0071')
        self.assertEqual(result['vulnerabilities'][0]['title'], 'Buffer overflow in SmallVec::insert_many')
        self.assertEqual(result['vulnerabilities'][0]['severity'], 'critical')  # CVSS 9.8 maps to critical
    
    @patch('os.path.exists')
    @patch('subprocess.run')
    def test_scan_python_code(self, mock_run, mock_exists):
        """Test scan_python_code method"""
        # Set up the mocks
        mock_exists.return_value = True
        
        # Mock the subprocess.run result
        mock_process = MagicMock()
        mock_process.returncode = 0
        mock_process.stdout = json.dumps({
            "results": [
                {
                    "filename": "test.py",
                    "line_number": 42,
                    "issue_severity": "HIGH",
                    "issue_confidence": "HIGH",
                    "issue_text": "Possible hardcoded password: 'super_secret'",
                    "test_id": "B105",
                    "test_name": "hardcoded_password_string"
                }
            ]
        })
        mock_run.return_value = mock_process
        
        # Mock the file discovery
        with patch.object(self.scanner, '_find_files', return_value=['/test/repo/test.py']):
            # Call the method
            result = self.scanner.scan_python_code(self.test_repo_path)
            
            # Verify the result
            self.assertEqual(len(result['vulnerabilities']), 1)
            self.assertEqual(result['vulnerability_count'], 1)
            self.assertEqual(result['vulnerabilities'][0]['file'], 'test.py')
            self.assertEqual(result['vulnerabilities'][0]['line'], 42)
            self.assertEqual(result['vulnerabilities'][0]['severity'], 'high')
            self.assertEqual(result['vulnerabilities'][0]['message'], "Possible hardcoded password: 'super_secret'")
    
    @patch('os.path.exists')
    @patch('subprocess.run')
    def test_scan_javascript_code(self, mock_run, mock_exists):
        """Test scan_javascript_code method"""
        # Set up the mocks
        mock_exists.return_value = True
        
        # Mock the subprocess.run result
        mock_process = MagicMock()
        mock_process.returncode = 0
        mock_process.stdout = json.dumps([
            {
                "filePath": "/test/repo/test.js",
                "messages": [
                    {
                        "ruleId": "security/detect-eval-with-expression",
                        "severity": 2,
                        "message": "eval() with variable content can allow an attacker to run arbitrary code.",
                        "line": 10,
                        "column": 1,
                        "nodeType": "CallExpression"
                    }
                ],
                "errorCount": 1,
                "warningCount": 0,
                "fixableErrorCount": 0,
                "fixableWarningCount": 0
            }
        ])
        mock_run.return_value = mock_process
        
        # Mock the file discovery
        with patch.object(self.scanner, '_find_files', return_value=['/test/repo/test.js']):
            # Call the method
            result = self.scanner.scan_javascript_code(self.test_repo_path)
            
            # Verify the result
            self.assertEqual(len(result['vulnerabilities']), 1)
            self.assertEqual(result['vulnerability_count'], 1)
            self.assertEqual(result['vulnerabilities'][0]['file'], 'test.js')
            self.assertEqual(result['vulnerabilities'][0]['line'], 10)
            self.assertEqual(result['vulnerabilities'][0]['severity'], 'high')  # Severity 2 maps to high
            self.assertEqual(result['vulnerabilities'][0]['message'], "eval() with variable content can allow an attacker to run arbitrary code.")
    
    def test_scan_repository(self):
        """Test scan_repository method"""
        # Mock the language-specific scanning methods
        self.scanner.scan_python_dependencies = MagicMock(return_value={
            'vulnerabilities': [{'package': 'django', 'vulnerability_id': 'CVE-2022-34265', 'severity': 'high'}],
            'vulnerability_count': 1
        })
        self.scanner.scan_python_code = MagicMock(return_value={
            'vulnerabilities': [{'file': 'test.py', 'line': 42, 'severity': 'high'}],
            'vulnerability_count': 1
        })
        self.scanner.scan_javascript_dependencies = MagicMock(return_value={
            'vulnerabilities': [{'package': 'lodash', 'severity': 'high'}],
            'vulnerability_count': 1
        })
        self.scanner.scan_javascript_code = MagicMock(return_value={
            'vulnerabilities': [{'file': 'test.js', 'line': 10, 'severity': 'high'}],
            'vulnerability_count': 1
        })
        
        # Call the method
        result = self.scanner.scan_repository(self.test_repo_path, ['Python', 'JavaScript'])
        
        # Verify the result
        self.assertEqual(len(result), 2)  # Two languages
        self.assertIn('Python', result)
        self.assertIn('JavaScript', result)
        
        # Check Python results
        self.assertEqual(result['Python']['dependency_vulnerabilities']['vulnerability_count'], 1)
        self.assertEqual(result['Python']['code_vulnerabilities']['vulnerability_count'], 1)
        self.assertEqual(result['Python']['total_vulnerabilities'], 2)
        
        # Check JavaScript results
        self.assertEqual(result['JavaScript']['dependency_vulnerabilities']['vulnerability_count'], 1)
        self.assertEqual(result['JavaScript']['code_vulnerabilities']['vulnerability_count'], 1)
        self.assertEqual(result['JavaScript']['total_vulnerabilities'], 2)
        
        # Verify the method calls
        self.scanner.scan_python_dependencies.assert_called_once_with(self.test_repo_path)
        self.scanner.scan_python_code.assert_called_once_with(self.test_repo_path)
        self.scanner.scan_javascript_dependencies.assert_called_once_with(self.test_repo_path)
        self.scanner.scan_javascript_code.assert_called_once_with(self.test_repo_path)
    
    @patch('os.walk')
    def test_find_files(self, mock_walk):
        """Test _find_files method"""
        # Set up the mock
        mock_walk.return_value = [
            ('/test/repo', ['dir1'], ['file1.py', 'file2.js']),
            ('/test/repo/dir1', [], ['file3.py'])
        ]
        
        # Call the method
        python_files = self.scanner._find_files(self.test_repo_path, '.py')
        
        # Verify the result
        self.assertEqual(len(python_files), 2)
        self.assertIn('/test/repo/file1.py', python_files)
        self.assertIn('/test/repo/dir1/file3.py', python_files)
    
    @patch('os.path.exists')
    def test_check_tool_availability(self, mock_exists):
        """Test _check_tool_availability method"""
        # Set up the mock
        mock_exists.side_effect = [True, False]  # First tool exists, second doesn't
        
        # Call the method
        result1 = self.scanner._check_tool_availability('tool1')
        result2 = self.scanner._check_tool_availability('tool2')
        
        # Verify the result
        self.assertTrue(result1)
        self.assertFalse(result2)
    
    @patch('subprocess.run')
    def test_run_command(self, mock_run):
        """Test _run_command method"""
        # Set up the mock
        mock_process = MagicMock()
        mock_process.returncode = 0
        mock_process.stdout = "Test output"
        mock_run.return_value = mock_process
        
        # Call the method
        returncode, output = self.scanner._run_command(['test', 'command'])
        
        # Verify the result
        self.assertEqual(returncode, 0)
        self.assertEqual(output, "Test output")
        mock_run.assert_called_once()
    
    def test_map_cvss_to_severity(self):
        """Test _map_cvss_to_severity method"""
        # Call the method with different CVSS scores
        low = self.scanner._map_cvss_to_severity(3.5)
        medium = self.scanner._map_cvss_to_severity(5.5)
        high = self.scanner._map_cvss_to_severity(8.0)
        critical = self.scanner._map_cvss_to_severity(9.5)
        
        # Verify the results
        self.assertEqual(low, 'low')
        self.assertEqual(medium, 'medium')
        self.assertEqual(high, 'high')
        self.assertEqual(critical, 'critical')


if __name__ == "__main__":
    unittest.main()