File size: 8,037 Bytes
cd07add
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb964d1
 
 
 
cd07add
 
 
 
d82b7e3
 
cd07add
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb964d1
 
 
 
 
 
 
cd07add
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cb964d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cd07add
 
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
import unittest
from unittest.mock import AsyncMock, MagicMock, patch

from app.models import DNSResult, DisposableResult, SMTPResult, ValidationStatus
from app.validator.engine import EmailValidationEngine


def _dns():
    return DNSResult(
        domain_exists=True,
        has_mx_records=True,
        mx_records=[{"host": "mx.corp.test", "priority": 10}],
    )


def _clean():
    return DisposableResult(
        is_disposable=False, is_free_provider=False, is_role_based=False
    )


class NullCache:
    async def get_dns(self, domain):
        return None

    async def put_dns(self, domain, result):
        return None

    async def get_verdict(self, email, deep):
        return None

    async def put_verdict(self, email, deep, smtp, catch_all):
        return None


class FakeProvider:
    async def verify_many(self, pairs):
        return ({e: SMTPResult(verified=True, smtp_code=250) for e, _ in pairs}, {})

    async def verify(self, email, mx_host):
        return SMTPResult(verified=True, smtp_code=250)

    async def catch_all(self, domain, mx_host):
        return (False, 0.9)


def _engine_with_ml(flag=True, repo="user/model", predict=None):
    engine = EmailValidationEngine(mailbox=FakeProvider(), cache=NullCache())
    # The get_model patcher must honor the flag: with the flag off the REAL
    # get_model returns None (that IS the production contract), so the double
    # must not force a model into a disabled engine.
    if predict is not None and flag:
        fake_model = MagicMock()
        fake_model.predict = MagicMock(side_effect=predict)
        fake_model.load = MagicMock(return_value=True)
        engine.mailbox = engine.mailbox  # unchanged
        patcher = patch("app.validator.engine.get_model_async",
                        new=AsyncMock(return_value=fake_model))
        patcher.start()
        engine._ml_patcher = patcher  # keep alive until stopAll
        engine._ml_model = fake_model
    p = patch.object(
        __import__("app.config", fromlist=["settings"]).settings,
        "ml_disposable_enabled",
        flag,
    )
    p.start()
    engine._flag_patcher = p
    p2 = patch.object(
        __import__("app.config", fromlist=["settings"]).settings,
        "ml_model_repo",
        repo,
    )
    p2.start()
    engine._flag_patcher2 = p2
    return engine


def _stop(engine):
    engine._ml_patcher.stop() if hasattr(engine, "_ml_patcher") else None
    engine._flag_patcher.stop()
    engine._flag_patcher2.stop()


class EngineMLTests(unittest.IsolatedAsyncioTestCase):
    def setUp(self):
        self._engines = []

    def tearDown(self):
        # Stop settings/model patchers so they never leak into other tests.
        for e in self._engines:
            _stop(e)
    def tearDown(self):
        for e in getattr(self, "_engines", []):
            _stop(e)

    async def _run(self, engine, emails):
        self._engines = getattr(self, "_engines", [])
        self._engines.append(engine)
        # NOTE: check_disposable is NOT mocked - the real blocklist/role sets
        # run, so the known-domain test exercises the true deterministic path.
        with patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())):
            return await engine.validate_batch(emails, deep=True)

    async def test_flag_off_leaves_results_unchanged(self):
        engine = _engine_with_ml(flag=False)
        results = await self._run(engine, ["a@brand-new-xyz.tk"])

        self.assertIsNone(results[0].disposable.ml_label)
        self.assertIsNone(results[0].disposable.ml_confidence)
        self.assertNotIn(
            "ML model flagged domain as disposable-like",
            results[0].scoring.warnings,
        )

    async def test_known_disposable_domain_skips_ml(self):
        # mailinator.com IS in the live blocklist -> the deterministic layer
        # already hard-fails it; the model must not be consulted.
        engine = _engine_with_ml(flag=True, predict=[AssertionError("must not run")])
        results = await self._run(engine, ["a@mailinator.com"])

        self.assertEqual(results[0].status, ValidationStatus.INVALID)
        fake_model = engine._ml_model
        fake_model.predict.assert_not_called()

    async def test_unknown_domain_gets_advisory_ml_signal(self):
        engine = _engine_with_ml(
            flag=True, predict=[[("DISPOSABLE", 0.93)]]  # contract: (label, score)
        )
        results = await self._run(engine, ["a@brand-new-disposable-xyz.tk"])

        d = results[0].disposable
        self.assertEqual(d.ml_label, "DISPOSABLE")
        self.assertEqual(d.ml_confidence, 0.93)
        self.assertTrue(
            any("ML model flagged" in w for w in results[0].scoring.warnings)
        )
        # ADVISORY: the deterministic verdict must not become invalid.
        self.assertNotEqual(results[0].status, ValidationStatus.INVALID)
        # The penalty is proven by the breakdown: the SMTP-verified bonus (+10)
        # can legitimately re-inflate the absolute score past the clamp.
        self.assertEqual(
            results[0].scoring.breakdown.get("ML_DISPOSABLE_SUSPECT"), -5
        )

    async def test_model_failure_degrades_to_deterministic(self):
        engine = _engine_with_ml(flag=True, predict=[None])
        results = await self._run(engine, ["a@brand-new-xyz.tk"])

        self.assertIsNone(results[0].disposable.ml_label)
        self.assertEqual(
            results[0].scoring.warnings,
            [w for w in results[0].scoring.warnings if "ML model flagged" not in w]
            or results[0].scoring.warnings,
        )
        self.assertEqual(results[0].scoring.score, 100)

    async def test_unknown_domains_batch_into_one_predict_call(self):
        engine = _engine_with_ml(
            flag=True,
            predict=[
                [
                    ("DISPOSABLE", 0.9),
                    ("NORMAL", 0.9),
                    ("DISPOSABLE", 0.95),
                ]
            ],
        )
        emails = [f"u{i}@dk{i}.example" for i in range(3)]
        results = await self._run(engine, emails)

        fake_model = engine._ml_model
        self.assertEqual(fake_model.predict.call_count, 1)
        sent = fake_model.predict.call_args.args[0]
        self.assertEqual(len(sent), 3)
        self.assertEqual(results[1].disposable.ml_label, "NORMAL")


    async def test_single_validate_also_applies_the_ml_signal(self):
        # The single endpoint runs engine.validate() - it must get the same
        # advisory signal as bulk, or the two surfaces disagree on one address.
        engine = _engine_with_ml(
            flag=True, predict=[[("DISPOSABLE", 0.93)]]
        )
        self._engines.append(engine)
        with patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())):
            result = await engine.validate("a@brand-new-disposable-xyz.tk", deep=True)

        self.assertEqual(result.disposable.ml_label, "DISPOSABLE")
        self.assertEqual(
            result.scoring.breakdown.get("ML_DISPOSABLE_SUSPECT"), -5
        )

    async def test_single_validate_flag_off_unchanged(self):
        engine = _engine_with_ml(flag=False, predict=[[("DISPOSABLE", 0.93)]])
        self._engines.append(engine)
        with patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())):
            result = await engine.validate("a@brand-new-disposable-xyz.tk", deep=True)

        self.assertIsNone(result.disposable.ml_label)

    async def test_single_validate_known_domain_skips_ml(self):
        engine = _engine_with_ml(
            flag=True, predict=[AssertionError("must not run")]
        )
        self._engines.append(engine)
        with patch("app.validator.engine.check_dns", new=AsyncMock(return_value=_dns())):
            result = await engine.validate("a@mailinator.com", deep=True)

        self.assertEqual(result.status, ValidationStatus.INVALID)
        self.assertIsNone(result.disposable.ml_label)


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