88from unittest .mock import MagicMock , patch
99
1010import pytest
11+ from email_validator import EmailNotValidError
1112
1213from macaron .malware_analyzer .pypi_heuristics .heuristics import HeuristicResult
1314from macaron .malware_analyzer .pypi_heuristics .metadata .fake_email import FakeEmailAnalyzer
1415from macaron .slsa_analyzer .package_registry .pypi_registry import PyPIPackageJsonAsset
1516
1617
1718@pytest .fixture (name = "analyzer" )
18- def analyzer_fixture () -> FakeEmailAnalyzer :
19+ def analyzer_ () -> FakeEmailAnalyzer :
1920 """Pytest fixture to create a FakeEmailAnalyzer instance."""
2021 return FakeEmailAnalyzer ()
2122
@@ -24,132 +25,118 @@ def analyzer_fixture() -> FakeEmailAnalyzer:
2425def pypi_package_json_asset_mock_fixture () -> MagicMock :
2526 """Pytest fixture for a mock PyPIPackageJsonAsset."""
2627 mock_asset = MagicMock (spec = PyPIPackageJsonAsset )
27- # Default to successful download, tests can override
28- mock_asset .download = MagicMock (return_value = True )
29- # package_json should be set by each test to simulate different PyPI responses
3028 mock_asset .package_json = {}
3129 return mock_asset
3230
3331
34- @pytest .fixture (name = "mock_dns_resolve" )
35- def mock_dns_resolve_fixture () -> Generator [MagicMock ]:
36- """General purpose mock for dns.resolver.resolve.
32+ @pytest .fixture (name = "mock_validate_email" )
33+ def mock_validate_email_fixture () -> Generator [MagicMock ]:
34+ """Patch validate_email and mock its behavior."""
35+ with patch ("macaron.malware_analyzer.pypi_heuristics.metadata.fake_email.validate_email" ) as mock :
36+ yield mock
3737
38- Patches where dns_resolver is imported in the module under test.
39- """
40- with patch ("macaron.malware_analyzer.pypi_heuristics.metadata.fake_email.dns_resolver.resolve" ) as mock_resolve :
41- # Default behavior: simulate successful MX record lookup.
42- mock_mx_record = MagicMock ()
43- mock_mx_record .exchange = "mail.default-domain.com"
44- mock_resolve .return_value = [mock_mx_record ]
45- yield mock_resolve
4638
47-
48- # Tests for the analyze method
4939def test_analyze_skip_no_emails_present (analyzer : FakeEmailAnalyzer , pypi_package_json_asset_mock : MagicMock ) -> None :
5040 """Test the analyzer skips if no author_email or maintainer_email is present."""
5141 pypi_package_json_asset_mock .package_json = {"info" : {"author_email" : None , "maintainer_email" : None }}
5242 result , info = analyzer .analyze (pypi_package_json_asset_mock )
5343 assert result == HeuristicResult .SKIP
54- assert info ["message" ] == "No maintainers are available"
44+ assert info ["message" ] == "No author or maintainer email available. "
5545
5646
5747def test_analyze_skip_no_info_key (analyzer : FakeEmailAnalyzer , pypi_package_json_asset_mock : MagicMock ) -> None :
5848 """Test the analyzer skips if 'info' key is missing in PyPI data."""
5949 pypi_package_json_asset_mock .package_json = {} # No 'info' key
6050 result , info = analyzer .analyze (pypi_package_json_asset_mock )
6151 assert result == HeuristicResult .SKIP
62- assert info ["message" ] == "No maintainers are available"
52+ assert info ["message" ] == "No package info available."
53+
6354
55+ def test_analyze_fail_invalid_email (
56+ analyzer : FakeEmailAnalyzer , pypi_package_json_asset_mock : MagicMock , mock_validate_email : MagicMock
57+ ) -> None :
58+ """Test analyzer fails for an invalid email format."""
59+ invalid_email = "invalid-email"
60+ pypi_package_json_asset_mock .package_json = {"info" : {"author_email" : invalid_email , "maintainer_email" : None }}
61+ mock_validate_email .side_effect = EmailNotValidError ("Invalid email." )
6462
65- def test_analyze_fail_empty_author_email (analyzer : FakeEmailAnalyzer , pypi_package_json_asset_mock : MagicMock ) -> None :
66- """Test analyzer fails for empty author_email string (maintainer_email is None)."""
67- pypi_package_json_asset_mock .package_json = {"info" : {"author_email" : "" , "maintainer_email" : None }}
6863 result , info = analyzer .analyze (pypi_package_json_asset_mock )
64+
6965 assert result == HeuristicResult .FAIL
70- assert info ["email" ] == ""
66+ assert info == {"email" : invalid_email }
67+ mock_validate_email .assert_called_once_with (invalid_email , check_deliverability = True )
7168
7269
7370def test_analyze_pass_only_maintainer_email_valid (
74- analyzer : FakeEmailAnalyzer , pypi_package_json_asset_mock : MagicMock , mock_dns_resolve : MagicMock
71+ analyzer : FakeEmailAnalyzer , pypi_package_json_asset_mock : MagicMock , mock_validate_email : MagicMock
7572) -> None :
7673 """Test analyzer passes when only maintainer_email is present and valid."""
77- mock_mx_record = MagicMock ()
78- mock_mx_record .exchange = "mail.example.net"
79- mock_dns_resolve .return_value = [mock_mx_record ]
74+ email = "maintainer@example.net"
75+ pypi_package_json_asset_mock .package_json = {"info" : {"author_email" : None , "maintainer_email" : email }}
76+
77+ mock_email_info = MagicMock ()
78+ mock_email_info .normalized = "maintainer@example.net"
79+ mock_email_info .local_part = "maintainer"
80+ mock_email_info .domain = "example.net"
81+ mock_validate_email .return_value = mock_email_info
8082
81- pypi_package_json_asset_mock .package_json = {
82- "info" : {"author_email" : None , "maintainer_email" : "maintainer@example.net" }
83- }
8483 result , info = analyzer .analyze (pypi_package_json_asset_mock )
8584 assert result == HeuristicResult .PASS
86- assert info == {}
87- mock_dns_resolve .assert_called_once_with ("example.net" , "MX" )
85+ assert info ["validated_emails" ] == [
86+ {"normalized" : "maintainer@example.net" , "local_part" : "maintainer" , "domain" : "example.net" }
87+ ]
88+ mock_validate_email .assert_called_once_with (email , check_deliverability = True )
8889
8990
9091def test_analyze_pass_both_emails_valid (
91- analyzer : FakeEmailAnalyzer , pypi_package_json_asset_mock : MagicMock , mock_dns_resolve : MagicMock
92+ analyzer : FakeEmailAnalyzer , pypi_package_json_asset_mock : MagicMock , mock_validate_email : MagicMock
9293) -> None :
9394 """Test the analyzer passes when both emails are present and valid."""
9495
95- def side_effect_dns_resolve (domain : str , record_type : str = "MX" ) -> list [MagicMock ]:
96- mock_mx = MagicMock ()
97- domains = {
98- "MX" : {"example.com" , "example.net" },
99- }
100- if domain not in domains .get (record_type , set ()):
101- pytest .fail (f"Unexpected domain for DNS resolve: { domain } " )
102- mock_mx .exchange = f"mail.{ domain } "
103- return [mock_mx ]
96+ def side_effect (email : str , check_deliverability : bool ) -> MagicMock : # pylint: disable=unused-argument
97+ local_part , domain = email .split ("@" )
98+ mock_email_info = MagicMock ()
99+ mock_email_info .normalized = email
100+ mock_email_info .local_part = local_part
101+ mock_email_info .domain = domain
102+ return mock_email_info
104103
105- mock_dns_resolve .side_effect = side_effect_dns_resolve
104+ mock_validate_email .side_effect = side_effect
106105
107106 pypi_package_json_asset_mock .package_json = {
108107 "info" : {"author_email" : "author@example.com" , "maintainer_email" : "maintainer@example.net" }
109108 }
110109 result , info = analyzer .analyze (pypi_package_json_asset_mock )
111110 assert result == HeuristicResult .PASS
112- assert info == {}
113- assert mock_dns_resolve .call_count == 2
114- mock_dns_resolve .assert_any_call ("example.com" , "MX" )
115- mock_dns_resolve .assert_any_call ("example.net" , "MX" )
116-
117-
118- def test_analyze_fail_author_email_invalid_format (
119- analyzer : FakeEmailAnalyzer , pypi_package_json_asset_mock : MagicMock , mock_dns_resolve : MagicMock
120- ) -> None :
121- """Test analyzer fails when author_email has an invalid format."""
122- pypi_package_json_asset_mock .package_json = {
123- "info" : {"author_email" : "bad_email_format" , "maintainer_email" : "maintainer@example.net" }
124- }
125- result , info = analyzer .analyze (pypi_package_json_asset_mock )
126- assert result == HeuristicResult .FAIL
127- assert info ["email" ] == "bad_email_format"
128- mock_dns_resolve .assert_not_called () # Regex check fails before DNS lookup
129-
130-
131- # Tests for the is_valid_email method
132- def test_is_valid_email_valid_email_with_mx (analyzer : FakeEmailAnalyzer , mock_dns_resolve : MagicMock ) -> None :
133- """Test is_valid_email returns True for a valid email with MX records."""
134- mock_mx_record = MagicMock ()
135- mock_mx_record .exchange = "mail.example.com"
136- mock_dns_resolve .return_value = [mock_mx_record ]
137- assert analyzer .is_valid_email ("test@example.com" ) is True
138- mock_dns_resolve .assert_called_once_with ("example.com" , "MX" )
139-
140-
141- def test_is_valid_email_invalid_format (analyzer : FakeEmailAnalyzer , mock_dns_resolve : MagicMock ) -> None :
142- """Test is_valid_email method with various invalid email formats."""
143- assert not analyzer .is_valid_email ("not_an_email" )
144- assert not analyzer .is_valid_email ("test@" )
145- assert not analyzer .is_valid_email ("@example.com" )
146- assert not analyzer .is_valid_email ("test@example" )
147- assert not analyzer .is_valid_email ("" )
148- mock_dns_resolve .assert_not_called ()
149-
150-
151- def test_is_valid_email_no_mx_records_returned (analyzer : FakeEmailAnalyzer , mock_dns_resolve : MagicMock ) -> None :
152- """Test is_valid_email returns False if DNS resolve returns no MX records."""
153- mock_dns_resolve .return_value = [] # Simulate no MX records found
154- assert analyzer .is_valid_email ("test@no-mx-domain.com" ) is False
155- mock_dns_resolve .assert_called_once_with ("no-mx-domain.com" , "MX" )
111+ assert mock_validate_email .call_count == 2
112+
113+ validated_emails = info .get ("validated_emails" )
114+ assert isinstance (validated_emails , list )
115+ assert len (validated_emails ) == 2
116+ assert {"normalized" : "author@example.com" , "local_part" : "author" , "domain" : "example.com" } in validated_emails
117+ assert {
118+ "normalized" : "maintainer@example.net" ,
119+ "local_part" : "maintainer" ,
120+ "domain" : "example.net" ,
121+ } in validated_emails
122+
123+
124+ def test_is_valid_email_success (analyzer : FakeEmailAnalyzer , mock_validate_email : MagicMock ) -> None :
125+ """Test is_valid_email returns the validation object on success."""
126+ mock_validated_email = MagicMock ()
127+ mock_validated_email .normalized = "test@example.com"
128+ mock_validated_email .local_part = "test"
129+ mock_validated_email .domain = "example.com"
130+
131+ mock_validate_email .return_value = mock_validated_email
132+ result = analyzer .is_valid_email ("test@example.com" )
133+ assert result == mock_validated_email
134+ mock_validate_email .assert_called_once_with ("test@example.com" , check_deliverability = True )
135+
136+
137+ def test_is_valid_email_failure (analyzer : FakeEmailAnalyzer , mock_validate_email : MagicMock ) -> None :
138+ """Test is_valid_email returns None on failure."""
139+ mock_validate_email .side_effect = EmailNotValidError ("The email address is not valid." )
140+ result = analyzer .is_valid_email ("invalid-email" )
141+ assert result is None
142+ mock_validate_email .assert_called_once_with ("invalid-email" , check_deliverability = True )
0 commit comments