File size: 10,419 Bytes
fd7800b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5afa05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fd7800b
 
 
 
 
f5afa05
fd7800b
 
f5afa05
 
fd7800b
 
 
 
 
 
 
 
 
 
 
 
f5afa05
fd7800b
 
f5afa05
 
fd7800b
 
 
 
 
 
 
 
 
 
f5afa05
fd7800b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5afa05
fd7800b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f5afa05
 
 
fd7800b
 
f5afa05
 
fd7800b
 
 
 
 
 
9fa1941
 
f5afa05
 
 
 
 
 
 
 
 
9fa1941
 
f5afa05
 
9fa1941
 
 
 
 
 
 
fd7800b
f5afa05
 
 
 
fd7800b
 
f5afa05
 
fd7800b
 
 
 
 
 
 
 
 
 
 
f5afa05
 
 
 
fd7800b
 
f5afa05
 
fd7800b
 
 
 
 
 
 
f5afa05
 
 
fd7800b
 
f5afa05
 
fd7800b
 
 
 
 
 
 
f5afa05
fd7800b
 
f5afa05
 
 
fd7800b
 
f5afa05
fd7800b
 
 
 
 
 
 
 
 
 
 
 
f5afa05
 
 
 
fd7800b
 
f5afa05
 
fd7800b
 
 
 
 
 
 
 
f5afa05
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
import unittest
from unittest.mock import AsyncMock, patch

from app.models import (
    CatchAllResult,
    DNSResult,
    DisposableResult,
    ScoringResult,
    SMTPResult,
    SyntaxResult,
    ValidationStatus,
)
from app.config import settings
from app.validator.engine import EmailValidationEngine


def _syntax(**kw):
    base = dict(
        passed=True,
        normalized_email="person@example.test",
        domain="example.test",
        local_part="person",
    )
    base.update(kw)
    return SyntaxResult(**base)


def _dns(**kw):
    base = dict(
        domain_exists=True,
        has_mx_records=True,
        mx_records=[{"host": "mx.example.test", "priority": 10}],
    )
    base.update(kw)
    return DNSResult(**base)


def _disposable(**kw):
    base = dict(is_disposable=False, is_free_provider=False, is_role_based=False)
    base.update(kw)
    return DisposableResult(**base)


class FakeMailbox:
    """Injectable mailbox provider — controls the Layer 5/6 verdicts exactly.

    Injecting the provider (instead of patching module internals) keeps these
    tests independent of which concrete provider the engine resolves by default.
    """

    def __init__(self, smtp=None, catch_all=(False, 0.9), raises=None):
        self._smtp = smtp if smtp is not None else SMTPResult(verified=True, smtp_code=250)
        self._catch_all = catch_all
        self._raises = raises

    async def verify(self, email, mx_host):
        if self._raises:
            raise self._raises
        return self._smtp

    async def catch_all(self, domain, mx_host):
        if self._raises:
            raise self._raises
        return self._catch_all


def _engine_with(smtp=None, catch_all=(False, 0.9)):
    return EmailValidationEngine(
        mailbox=FakeMailbox(smtp=smtp, catch_all=catch_all)
    )


class EngineMetadataTests(unittest.IsolatedAsyncioTestCase):
    """The engine must emit the provenance fields the frontend contract expects:
    checks_run, reason_code, validator_version, run_id (RC5)."""

    async def test_deep_result_emits_provenance_fields(self):
        engine = _engine_with()
        with (
            patch("app.validator.engine.validate_syntax", return_value=_syntax()),
            patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
            patch("app.validator.engine.check_disposable", return_value=_disposable()),
        ):
            result = await engine.validate("person@example.test", deep=True)

        self.assertEqual(
            result.checks_run,
            ["syntax", "dns", "mx", "disposable", "catch_all", "smtp", "scoring"],
        )
        self.assertEqual(result.validator_version, settings.app_version)
        self.assertTrue(result.run_id)
        self.assertGreater(len(result.run_id), 5)

    async def test_fast_mode_checks_run_excludes_mailbox_layers(self):
        engine = _engine_with()
        with (
            patch("app.validator.engine.validate_syntax", return_value=_syntax()),
            patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
            patch("app.validator.engine.check_disposable", return_value=_disposable()),
        ):
            result = await engine.validate("person@example.test", deep=False)

        self.assertEqual(
            result.checks_run, ["syntax", "dns", "mx", "disposable", "scoring"]
        )
        self.assertIsNone(result.smtp)
        self.assertIsNone(result.catch_all)

    async def test_reason_code_dns_timeout(self):
        engine = _engine_with()
        with (
            patch("app.validator.engine.validate_syntax", return_value=_syntax()),
            patch(
                "app.validator.engine.check_dns",
                new=AsyncMock(
                    return_value=_dns(
                        domain_exists=False,
                        has_mx_records=False,
                        error="DNS timeout",
                        error_code="timeout",
                    )
                ),
            ),
        ):
            result = await engine.validate("person@example.test", deep=True)

        self.assertEqual(result.reason_code, "dns_timeout")

    async def test_reason_code_nxdomain(self):
        engine = _engine_with()
        with (
            patch("app.validator.engine.validate_syntax", return_value=_syntax()),
            patch(
                "app.validator.engine.check_dns",
                new=AsyncMock(
                    return_value=_dns(
                        domain_exists=False,
                        has_mx_records=False,
                        error="Domain not found",
                        error_code="nxdomain",
                    )
                ),
            ),
        ):
            result = await engine.validate("person@example.test", deep=True)

        self.assertEqual(result.reason_code, "nxdomain")

    async def test_reason_code_catch_all(self):
        engine = _engine_with(
            smtp=SMTPResult(verified=True, smtp_code=250), catch_all=(True, 0.95)
        )
        with (
            patch("app.validator.engine.validate_syntax", return_value=_syntax()),
            patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
            patch("app.validator.engine.check_disposable", return_value=_disposable()),
        ):
            result = await engine.validate("person@example.test", deep=True)

        self.assertEqual(result.reason_code, "catch_all_detected")
        self.assertEqual(result.status, ValidationStatus.RISKY)

    async def test_explicit_5xx_rejection_outranks_catch_all(self):
        # A definitive "mailbox does not exist" (5xx) is stronger and more
        # actionable than the domain-level catch-all flag.
        engine = _engine_with(
            smtp=SMTPResult(
                verified=False,
                smtp_code=550,
                smtp_message="550 mailbox does not exist (provider confirmed)",
            ),
            catch_all=(True, 0.95),
        )
        with (
            patch("app.validator.engine.validate_syntax", return_value=_syntax()),
            patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
            patch("app.validator.engine.check_disposable", return_value=_disposable()),
        ):
            result = await engine.validate("person@example.test", deep=True)

        self.assertEqual(result.reason_code, "rcpt_5xx")
        self.assertEqual(result.scoring.score, 0)
        self.assertEqual(result.status, ValidationStatus.INVALID)

    async def test_reason_code_smtp_timeout(self):
        engine = _engine_with(
            smtp=SMTPResult(verified=None, error="SMTP verification timed out"),
            catch_all=(None, 0.0),
        )
        with (
            patch("app.validator.engine.validate_syntax", return_value=_syntax()),
            patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
            patch("app.validator.engine.check_disposable", return_value=_disposable()),
        ):
            result = await engine.validate("person@example.test", deep=True)

        self.assertEqual(result.reason_code, "smtp_timeout")


class CatchAllUnknownTests(unittest.IsolatedAsyncioTestCase):
    """An unreachable catch-all probe must read as UNKNOWN (None), never as
    False ("domain rejects unknown recipients"). RC6."""

    async def test_catch_all_probe_unavailable_is_unknown_not_false(self):
        engine = _engine_with(
            smtp=SMTPResult(verified=None, error="SMTP verification timed out"),
            catch_all=(None, 0.0),
        )
        with (
            patch("app.validator.engine.validate_syntax", return_value=_syntax()),
            patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
            patch("app.validator.engine.check_disposable", return_value=_disposable()),
        ):
            result = await engine.validate("person@example.test", deep=True)

        self.assertIsNone(result.catch_all.is_catch_all)
        self.assertEqual(result.catch_all.confidence, 0.0)

    async def test_catch_all_exception_is_unknown_not_false(self):
        engine = EmailValidationEngine(
            mailbox=FakeMailbox(raises=RuntimeError("port 25 unreachable"))
        )
        with (
            patch("app.validator.engine.validate_syntax", return_value=_syntax()),
            patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
            patch("app.validator.engine.check_disposable", return_value=_disposable()),
        ):
            result = await engine.validate("person@example.test", deep=True)

        self.assertIsNone(result.catch_all.is_catch_all)


class ScoreCoherenceTests(unittest.IsolatedAsyncioTestCase):
    """A perfect score (100/low) must only appear when a mailbox verdict exists."""

    async def test_smtp_verified_can_reach_high_score(self):
        engine = _engine_with(
            smtp=SMTPResult(verified=True, smtp_code=250), catch_all=(False, 0.9)
        )
        with (
            patch("app.validator.engine.validate_syntax", return_value=_syntax()),
            patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
            patch(
                "app.validator.engine.check_disposable",
                return_value=_disposable(is_free_provider=False),
            ),
        ):
            result = await engine.validate("person@example.test", deep=True)

        self.assertGreaterEqual(result.scoring.score, 75)
        self.assertEqual(result.scoring.risk_level.value, "low")
        self.assertEqual(result.status, ValidationStatus.VALID)

    async def test_unverified_mailbox_never_scores_100(self):
        engine = _engine_with(
            smtp=SMTPResult(verified=None, error="SMTP verification timed out"),
            catch_all=(None, 0.0),
        )
        with (
            patch("app.validator.engine.validate_syntax", return_value=_syntax()),
            patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())),
            patch("app.validator.engine.check_disposable", return_value=_disposable()),
        ):
            result = await engine.validate("person@example.test", deep=True)

        self.assertLess(result.scoring.score, 75)
        self.assertNotEqual(result.scoring.risk_level.value, "low")


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