File size: 16,464 Bytes
680d86c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
"""Tests for inference pipeline."""

import pytest
import tempfile
import os
from unittest.mock import Mock, patch, MagicMock
import pandas as pd
import numpy as np

from src.phising_detection.inference.pipeline import PhishingDetectionPipeline


class TestPhishingDetectionPipeline:
    """Tests for PhishingDetectionPipeline class."""

    def test_init_without_urlscan(self):
        """Test initialization without URLScan API key."""
        pipeline = PhishingDetectionPipeline(
            model_name="test_model",
            model_version=1
        )
        assert pipeline.model_name == "test_model"
        assert pipeline.model_version == 1
        assert pipeline.model is None
        assert pipeline.scaler is None
        assert pipeline.feature_names is None
        assert pipeline.urlscan_client is None

    def test_init_with_urlscan(self):
        """Test initialization with URLScan API key."""
        with patch('src.phising_detection.inference.pipeline.URLScanClient') as mock_client:
            pipeline = PhishingDetectionPipeline(
                model_name="test_model",
                urlscan_api_key="test_key"
            )
            mock_client.assert_called_once_with(api_key="test_key")
            assert pipeline.urlscan_client is not None

    def test_is_loaded_false(self):
        """Test is_loaded returns False when model not loaded."""
        pipeline = PhishingDetectionPipeline()
        assert pipeline.is_loaded() is False

    def test_is_loaded_true(self):
        """Test is_loaded returns True when model is loaded."""
        pipeline = PhishingDetectionPipeline()
        pipeline.model = Mock()
        pipeline.scaler = Mock()
        pipeline.feature_names = ['feature1', 'feature2']
        assert pipeline.is_loaded() is True

    @patch('src.phising_detection.inference.pipeline.connect_to_hopsworks')
    @patch('src.phising_detection.inference.pipeline.joblib.load')
    def test_load_model_from_hopsworks(self, mock_joblib_load, mock_connect):
        """Test loading model from Hopsworks."""
        # Setup mocks
        mock_project = Mock()
        mock_mr = Mock()
        mock_model_registry = Mock()
        mock_model_registry.version = 1
        mock_model_registry.download.return_value = "/tmp/model_dir"

        mock_project.get_model_registry.return_value = mock_mr
        mock_mr.get_model.return_value = mock_model_registry
        mock_connect.return_value = mock_project

        # Mock model and scaler
        mock_model = Mock()
        mock_scaler = Mock()
        mock_joblib_load.side_effect = [mock_model, mock_scaler]

        # Create temporary feature_names file
        with tempfile.TemporaryDirectory() as tmpdir:
            feature_names_path = os.path.join(tmpdir, "feature_names.txt")
            with open(feature_names_path, 'w') as f:
                f.write("feature1\nfeature2\nfeature3")

            # Mock download to return our temp dir
            mock_model_registry.download.return_value = tmpdir

            # Test
            pipeline = PhishingDetectionPipeline(model_name="test_model")
            pipeline.load_model_from_hopsworks()

            # Assertions
            assert pipeline.model == mock_model
            assert pipeline.scaler == mock_scaler
            assert pipeline.feature_names == ['feature1', 'feature2', 'feature3']
            mock_connect.assert_called_once()
            mock_mr.get_model.assert_called_once_with("test_model")

    @patch('src.phising_detection.inference.pipeline.connect_to_hopsworks')
    @patch('src.phising_detection.inference.pipeline.joblib.load')
    def test_load_model_with_version(self, mock_joblib_load, mock_connect):
        """Test loading specific model version from Hopsworks."""
        # Setup mocks
        mock_project = Mock()
        mock_mr = Mock()
        mock_model_registry = Mock()
        mock_model_registry.version = 2
        mock_model_registry.download.return_value = "/tmp/model_dir"

        mock_project.get_model_registry.return_value = mock_mr
        mock_mr.get_model.return_value = mock_model_registry
        mock_connect.return_value = mock_project

        mock_joblib_load.side_effect = [Mock(), Mock()]

        with tempfile.TemporaryDirectory() as tmpdir:
            feature_names_path = os.path.join(tmpdir, "feature_names.txt")
            with open(feature_names_path, 'w') as f:
                f.write("feature1")

            mock_model_registry.download.return_value = tmpdir

            # Test with specific version
            pipeline = PhishingDetectionPipeline(model_name="test_model", model_version=2)
            pipeline.load_model_from_hopsworks()

            # Should request version 2
            mock_mr.get_model.assert_called_once_with("test_model", version=2)

    def test_preprocess_features_not_loaded(self):
        """Test preprocessing fails when model not loaded."""
        pipeline = PhishingDetectionPipeline()
        features = {'feature1': 10, 'feature2': 20}

        with pytest.raises(ValueError) as exc_info:
            pipeline.preprocess_features(features)
        assert "Model not loaded" in str(exc_info.value)

    def test_preprocess_features_success(self):
        """Test successful feature preprocessing."""
        # Setup pipeline with mock components
        pipeline = PhishingDetectionPipeline()
        pipeline.model = Mock()
        pipeline.feature_names = [
            'domain_age_days',
            'secure_percentage',
            'has_umbrella_rank',
            'umbrella_rank',
            'has_tls',
            'tls_valid_days',
            'url_length',
            'subdomain_count'
        ]

        # Mock scaler
        mock_scaler = Mock()
        mock_scaler.transform.return_value = np.array([[1.5, 0.8, 5000, 365, 25, 1]])
        pipeline.scaler = mock_scaler

        # Test features
        features = {
            'domain_age_days': 3000,
            'secure_percentage': 95.0,
            'has_umbrella_rank': 1,
            'umbrella_rank': 5000,
            'has_tls': 1,
            'tls_valid_days': 365,
            'url_length': 25,
            'subdomain_count': 1
        }

        result = pipeline.preprocess_features(features)

        # Assertions
        assert isinstance(result, pd.DataFrame)
        assert list(result.columns) == pipeline.feature_names
        assert len(result) == 1
        mock_scaler.transform.assert_called_once()

    def test_preprocess_features_with_missing_features(self):
        """Test preprocessing handles missing features."""
        pipeline = PhishingDetectionPipeline()
        pipeline.model = Mock()
        pipeline.feature_names = [
            'domain_age_days', 'secure_percentage', 'has_umbrella_rank',
            'umbrella_rank', 'has_tls', 'tls_valid_days', 'url_length', 'subdomain_count'
        ]

        mock_scaler = Mock()
        # Mock the scaler to return the same shape as input continuous features (6 features)
        mock_scaler.transform.return_value = np.array([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]])
        pipeline.scaler = mock_scaler

        # Only provide some features (missing subdomain_count)
        features = {
            'domain_age_days': 3000,
            'secure_percentage': 95.0,
            'has_umbrella_rank': 1,
            'umbrella_rank': 5000,
            'has_tls': 1,
            'tls_valid_days': 365,
            'url_length': 25
        }

        result = pipeline.preprocess_features(features)

        # Should add missing subdomain_count and all features should be present
        assert 'subdomain_count' in result.columns
        assert len(result.columns) == 8  # All 8 features should be present
        assert list(result.columns) == pipeline.feature_names

    def test_predict_not_loaded(self):
        """Test prediction fails when model not loaded."""
        pipeline = PhishingDetectionPipeline()
        features = {'feature1': 10}

        with pytest.raises(ValueError) as exc_info:
            pipeline.predict(features)
        assert "Model not loaded" in str(exc_info.value)

    def test_predict_phishing(self):
        """Test prediction for phishing URL."""
        # Setup pipeline
        pipeline = PhishingDetectionPipeline()
        pipeline.feature_names = [
            'domain_age_days', 'secure_percentage', 'has_umbrella_rank',
            'umbrella_rank', 'has_tls', 'tls_valid_days', 'url_length', 'subdomain_count'
        ]

        # Mock model
        mock_model = Mock()
        mock_model.predict_proba.return_value = np.array([[0.2, 0.8]])  # 80% phishing
        mock_model.predict.return_value = np.array([1])  # Phishing
        pipeline.model = mock_model

        # Mock scaler
        mock_scaler = Mock()
        mock_scaler.transform.return_value = np.array([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]])
        pipeline.scaler = mock_scaler

        # Test features
        features = {
            'domain_age_days': 10,
            'secure_percentage': 50.0,
            'has_umbrella_rank': 0,
            'umbrella_rank': 999999,
            'has_tls': 0,
            'tls_valid_days': 0,
            'url_length': 150,
            'subdomain_count': 5
        }

        result = pipeline.predict(features)

        # Assertions
        assert result['prediction'] == "PHISHING"
        assert result['is_phishing'] is True
        assert result['confidence'] == 0.8
        assert result['phishing_probability'] == 0.8
        assert result['legitimate_probability'] == 0.2

    def test_predict_legitimate(self):
        """Test prediction for legitimate URL."""
        # Setup pipeline
        pipeline = PhishingDetectionPipeline()
        pipeline.feature_names = [
            'domain_age_days', 'secure_percentage', 'has_umbrella_rank',
            'umbrella_rank', 'has_tls', 'tls_valid_days', 'url_length', 'subdomain_count'
        ]

        # Mock model
        mock_model = Mock()
        mock_model.predict_proba.return_value = np.array([[0.9, 0.1]])  # 90% legitimate
        mock_model.predict.return_value = np.array([0])  # Legitimate
        pipeline.model = mock_model

        # Mock scaler
        mock_scaler = Mock()
        mock_scaler.transform.return_value = np.array([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]])
        pipeline.scaler = mock_scaler

        # Test features
        features = {
            'domain_age_days': 3000,
            'secure_percentage': 95.0,
            'has_umbrella_rank': 1,
            'umbrella_rank': 5000,
            'has_tls': 1,
            'tls_valid_days': 365,
            'url_length': 25,
            'subdomain_count': 1
        }

        result = pipeline.predict(features)

        # Assertions
        assert result['prediction'] == "LEGITIMATE"
        assert result['is_phishing'] is False
        assert result['confidence'] == 0.9
        assert result['phishing_probability'] == 0.1
        assert result['legitimate_probability'] == 0.9

    def test_predict_url_without_urlscan(self):
        """Test predict_url fails without URLScan client."""
        pipeline = PhishingDetectionPipeline()
        pipeline.model = Mock()
        pipeline.scaler = Mock()
        pipeline.feature_names = ['feature1']

        with pytest.raises(ValueError) as exc_info:
            pipeline.predict_url("https://example.com")
        assert "URLScan client not initialized" in str(exc_info.value)

    @patch('src.phising_detection.inference.pipeline.extract_features')
    def test_predict_url_success(self, mock_extract_features):
        """Test successful end-to-end URL prediction."""
        # Setup pipeline
        pipeline = PhishingDetectionPipeline()
        pipeline.feature_names = [
            'domain_age_days', 'secure_percentage', 'has_umbrella_rank',
            'umbrella_rank', 'has_tls', 'tls_valid_days', 'url_length', 'subdomain_count'
        ]

        # Mock model
        mock_model = Mock()
        mock_model.predict_proba.return_value = np.array([[0.7, 0.3]])
        mock_model.predict.return_value = np.array([0])
        pipeline.model = mock_model

        # Mock scaler
        mock_scaler = Mock()
        mock_scaler.transform.return_value = np.array([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]])
        pipeline.scaler = mock_scaler

        # Mock URLScan client
        mock_urlscan_client = Mock()
        mock_scan_result = {
            'task': {'uuid': 'test-uuid-123'},
            'page': {'domainAgeDays': 3000},
            'stats': {'securePercentage': 95}
        }
        mock_urlscan_client.submit_and_wait.return_value = mock_scan_result
        pipeline.urlscan_client = mock_urlscan_client

        # Mock extracted features
        extracted_features = {
            'domain_age_days': 3000,
            'secure_percentage': 95.0,
            'has_umbrella_rank': 1,
            'umbrella_rank': 5000,
            'has_tls': 1,
            'tls_valid_days': 365,
            'url_length': 25,
            'subdomain_count': 1
        }
        mock_extract_features.return_value = extracted_features

        # Test
        result = pipeline.predict_url("https://example.com")

        # Assertions
        assert result['prediction'] == "LEGITIMATE"
        assert result['confidence'] == 0.7
        assert result['features'] == extracted_features
        assert result['scan_uuid'] == 'test-uuid-123'
        mock_urlscan_client.submit_and_wait.assert_called_once_with("https://example.com")

    def test_predict_url_scan_fails(self):
        """Test predict_url handles URLScan failure."""
        # Setup pipeline
        pipeline = PhishingDetectionPipeline()
        pipeline.model = Mock()
        pipeline.scaler = Mock()
        pipeline.feature_names = ['feature1']

        # Mock URLScan client that returns None
        mock_urlscan_client = Mock()
        mock_urlscan_client.submit_and_wait.return_value = None
        pipeline.urlscan_client = mock_urlscan_client

        # Test
        result = pipeline.predict_url("https://example.com")

        # Should return error
        assert 'error' in result
        assert result['prediction'] == "ERROR"
        assert result['confidence'] == 0.0

    @patch('src.phising_detection.inference.pipeline.extract_features')
    def test_predict_url_exception_handling(self, mock_extract_features):
        """Test predict_url handles exceptions gracefully."""
        # Setup pipeline
        pipeline = PhishingDetectionPipeline()
        pipeline.model = Mock()
        pipeline.scaler = Mock()
        pipeline.feature_names = ['feature1']

        # Mock URLScan client
        mock_urlscan_client = Mock()
        mock_urlscan_client.submit_and_wait.return_value = {'task': {}}
        pipeline.urlscan_client = mock_urlscan_client

        # Mock extract_features to raise exception
        mock_extract_features.side_effect = Exception("Test error")

        # Test
        result = pipeline.predict_url("https://example.com")

        # Should return error
        assert 'error' in result
        assert result['prediction'] == "ERROR"
        assert "Test error" in result['error']

    def test_predict_numerical_stability(self):
        """Test prediction handles edge cases in probabilities."""
        # Setup pipeline
        pipeline = PhishingDetectionPipeline()
        pipeline.feature_names = [
            'domain_age_days', 'secure_percentage', 'has_umbrella_rank',
            'umbrella_rank', 'has_tls', 'tls_valid_days', 'url_length', 'subdomain_count'
        ]

        # Mock model with extreme probabilities
        mock_model = Mock()
        mock_model.predict_proba.return_value = np.array([[0.999999, 0.000001]])
        mock_model.predict.return_value = np.array([0])
        pipeline.model = mock_model

        mock_scaler = Mock()
        mock_scaler.transform.return_value = np.array([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]])
        pipeline.scaler = mock_scaler

        features = {
            'domain_age_days': 3000,
            'secure_percentage': 95.0,
            'has_umbrella_rank': 1,
            'umbrella_rank': 5000,
            'has_tls': 1,
            'tls_valid_days': 365,
            'url_length': 25,
            'subdomain_count': 1
        }
        result = pipeline.predict(features)

        # Should handle extreme values correctly
        assert result['confidence'] > 0.99
        assert result['prediction'] == "LEGITIMATE"
        assert result['phishing_probability'] < 0.01