8
8
from unittest .mock import MagicMock , patch
9
9
10
10
import pytest
11
+ from email_validator import EmailNotValidError
11
12
12
13
from macaron .malware_analyzer .pypi_heuristics .heuristics import HeuristicResult
13
14
from macaron .malware_analyzer .pypi_heuristics .metadata .fake_email import FakeEmailAnalyzer
14
15
from macaron .slsa_analyzer .package_registry .pypi_registry import PyPIPackageJsonAsset
15
16
16
17
17
18
@pytest .fixture (name = "analyzer" )
18
- def analyzer_fixture () -> FakeEmailAnalyzer :
19
+ def analyzer_ () -> FakeEmailAnalyzer :
19
20
"""Pytest fixture to create a FakeEmailAnalyzer instance."""
20
21
return FakeEmailAnalyzer ()
21
22
@@ -24,132 +25,118 @@ def analyzer_fixture() -> FakeEmailAnalyzer:
24
25
def pypi_package_json_asset_mock_fixture () -> MagicMock :
25
26
"""Pytest fixture for a mock PyPIPackageJsonAsset."""
26
27
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
30
28
mock_asset .package_json = {}
31
29
return mock_asset
32
30
33
31
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
37
37
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
46
38
47
-
48
- # Tests for the analyze method
49
39
def test_analyze_skip_no_emails_present (analyzer : FakeEmailAnalyzer , pypi_package_json_asset_mock : MagicMock ) -> None :
50
40
"""Test the analyzer skips if no author_email or maintainer_email is present."""
51
41
pypi_package_json_asset_mock .package_json = {"info" : {"author_email" : None , "maintainer_email" : None }}
52
42
result , info = analyzer .analyze (pypi_package_json_asset_mock )
53
43
assert result == HeuristicResult .SKIP
54
- assert info ["message" ] == "No maintainers are available"
44
+ assert info ["message" ] == "No author or maintainer email available. "
55
45
56
46
57
47
def test_analyze_skip_no_info_key (analyzer : FakeEmailAnalyzer , pypi_package_json_asset_mock : MagicMock ) -> None :
58
48
"""Test the analyzer skips if 'info' key is missing in PyPI data."""
59
49
pypi_package_json_asset_mock .package_json = {} # No 'info' key
60
50
result , info = analyzer .analyze (pypi_package_json_asset_mock )
61
51
assert result == HeuristicResult .SKIP
62
- assert info ["message" ] == "No maintainers are available"
52
+ assert info ["message" ] == "No package info available."
53
+
63
54
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." )
64
62
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 }}
68
63
result , info = analyzer .analyze (pypi_package_json_asset_mock )
64
+
69
65
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 )
71
68
72
69
73
70
def 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
75
72
) -> None :
76
73
"""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
+
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 = "[email protected] "
79
+ mock_email_info .local_part = "maintainer"
80
+ mock_email_info .domain = "example.net"
81
+ mock_validate_email .return_value = mock_email_info
80
82
81
- pypi_package_json_asset_mock .package_json = {
82
- "info" : {
"author_email" :
None ,
"maintainer_email" :
"[email protected] " }
83
- }
84
83
result , info = analyzer .analyze (pypi_package_json_asset_mock )
85
84
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" :
"[email protected] " ,
"local_part" :
"maintainer" ,
"domain" :
"example.net" }
87
+ ]
88
+ mock_validate_email .assert_called_once_with (email , check_deliverability = True )
88
89
89
90
90
91
def 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
92
93
) -> None :
93
94
"""Test the analyzer passes when both emails are present and valid."""
94
95
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
104
103
105
- mock_dns_resolve .side_effect = side_effect_dns_resolve
104
+ mock_validate_email .side_effect = side_effect
106
105
107
106
pypi_package_json_asset_mock .package_json = {
108
107
"info" : {
"author_email" :
"[email protected] " ,
"maintainer_email" :
"[email protected] " }
109
108
}
110
109
result , info = analyzer .analyze (pypi_package_json_asset_mock )
111
110
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" :
"[email protected] " }
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 (
"[email protected] " )
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 (
"[email protected] " )
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" :
"[email protected] " ,
"local_part" :
"author" ,
"domain" :
"example.com" }
in validated_emails
117
+ assert {
118
+ "normalized" :
"[email protected] " ,
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 = "[email protected] "
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 (
"[email protected] " )
133
+ assert result == mock_validated_email
134
+ mock_validate_email .
assert_called_once_with (
"[email protected] " ,
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