SaiBon99 commited on
Commit
680d86c
·
1 Parent(s): 11efc47

Add unit tests for PhishingDetectionPipeline class

Browse files
Files changed (1) hide show
  1. tests/test_inference_pipeline.py +441 -0
tests/test_inference_pipeline.py ADDED
@@ -0,0 +1,441 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for inference pipeline."""
2
+
3
+ import pytest
4
+ import tempfile
5
+ import os
6
+ from unittest.mock import Mock, patch, MagicMock
7
+ import pandas as pd
8
+ import numpy as np
9
+
10
+ from src.phising_detection.inference.pipeline import PhishingDetectionPipeline
11
+
12
+
13
+ class TestPhishingDetectionPipeline:
14
+ """Tests for PhishingDetectionPipeline class."""
15
+
16
+ def test_init_without_urlscan(self):
17
+ """Test initialization without URLScan API key."""
18
+ pipeline = PhishingDetectionPipeline(
19
+ model_name="test_model",
20
+ model_version=1
21
+ )
22
+ assert pipeline.model_name == "test_model"
23
+ assert pipeline.model_version == 1
24
+ assert pipeline.model is None
25
+ assert pipeline.scaler is None
26
+ assert pipeline.feature_names is None
27
+ assert pipeline.urlscan_client is None
28
+
29
+ def test_init_with_urlscan(self):
30
+ """Test initialization with URLScan API key."""
31
+ with patch('src.phising_detection.inference.pipeline.URLScanClient') as mock_client:
32
+ pipeline = PhishingDetectionPipeline(
33
+ model_name="test_model",
34
+ urlscan_api_key="test_key"
35
+ )
36
+ mock_client.assert_called_once_with(api_key="test_key")
37
+ assert pipeline.urlscan_client is not None
38
+
39
+ def test_is_loaded_false(self):
40
+ """Test is_loaded returns False when model not loaded."""
41
+ pipeline = PhishingDetectionPipeline()
42
+ assert pipeline.is_loaded() is False
43
+
44
+ def test_is_loaded_true(self):
45
+ """Test is_loaded returns True when model is loaded."""
46
+ pipeline = PhishingDetectionPipeline()
47
+ pipeline.model = Mock()
48
+ pipeline.scaler = Mock()
49
+ pipeline.feature_names = ['feature1', 'feature2']
50
+ assert pipeline.is_loaded() is True
51
+
52
+ @patch('src.phising_detection.inference.pipeline.connect_to_hopsworks')
53
+ @patch('src.phising_detection.inference.pipeline.joblib.load')
54
+ def test_load_model_from_hopsworks(self, mock_joblib_load, mock_connect):
55
+ """Test loading model from Hopsworks."""
56
+ # Setup mocks
57
+ mock_project = Mock()
58
+ mock_mr = Mock()
59
+ mock_model_registry = Mock()
60
+ mock_model_registry.version = 1
61
+ mock_model_registry.download.return_value = "/tmp/model_dir"
62
+
63
+ mock_project.get_model_registry.return_value = mock_mr
64
+ mock_mr.get_model.return_value = mock_model_registry
65
+ mock_connect.return_value = mock_project
66
+
67
+ # Mock model and scaler
68
+ mock_model = Mock()
69
+ mock_scaler = Mock()
70
+ mock_joblib_load.side_effect = [mock_model, mock_scaler]
71
+
72
+ # Create temporary feature_names file
73
+ with tempfile.TemporaryDirectory() as tmpdir:
74
+ feature_names_path = os.path.join(tmpdir, "feature_names.txt")
75
+ with open(feature_names_path, 'w') as f:
76
+ f.write("feature1\nfeature2\nfeature3")
77
+
78
+ # Mock download to return our temp dir
79
+ mock_model_registry.download.return_value = tmpdir
80
+
81
+ # Test
82
+ pipeline = PhishingDetectionPipeline(model_name="test_model")
83
+ pipeline.load_model_from_hopsworks()
84
+
85
+ # Assertions
86
+ assert pipeline.model == mock_model
87
+ assert pipeline.scaler == mock_scaler
88
+ assert pipeline.feature_names == ['feature1', 'feature2', 'feature3']
89
+ mock_connect.assert_called_once()
90
+ mock_mr.get_model.assert_called_once_with("test_model")
91
+
92
+ @patch('src.phising_detection.inference.pipeline.connect_to_hopsworks')
93
+ @patch('src.phising_detection.inference.pipeline.joblib.load')
94
+ def test_load_model_with_version(self, mock_joblib_load, mock_connect):
95
+ """Test loading specific model version from Hopsworks."""
96
+ # Setup mocks
97
+ mock_project = Mock()
98
+ mock_mr = Mock()
99
+ mock_model_registry = Mock()
100
+ mock_model_registry.version = 2
101
+ mock_model_registry.download.return_value = "/tmp/model_dir"
102
+
103
+ mock_project.get_model_registry.return_value = mock_mr
104
+ mock_mr.get_model.return_value = mock_model_registry
105
+ mock_connect.return_value = mock_project
106
+
107
+ mock_joblib_load.side_effect = [Mock(), Mock()]
108
+
109
+ with tempfile.TemporaryDirectory() as tmpdir:
110
+ feature_names_path = os.path.join(tmpdir, "feature_names.txt")
111
+ with open(feature_names_path, 'w') as f:
112
+ f.write("feature1")
113
+
114
+ mock_model_registry.download.return_value = tmpdir
115
+
116
+ # Test with specific version
117
+ pipeline = PhishingDetectionPipeline(model_name="test_model", model_version=2)
118
+ pipeline.load_model_from_hopsworks()
119
+
120
+ # Should request version 2
121
+ mock_mr.get_model.assert_called_once_with("test_model", version=2)
122
+
123
+ def test_preprocess_features_not_loaded(self):
124
+ """Test preprocessing fails when model not loaded."""
125
+ pipeline = PhishingDetectionPipeline()
126
+ features = {'feature1': 10, 'feature2': 20}
127
+
128
+ with pytest.raises(ValueError) as exc_info:
129
+ pipeline.preprocess_features(features)
130
+ assert "Model not loaded" in str(exc_info.value)
131
+
132
+ def test_preprocess_features_success(self):
133
+ """Test successful feature preprocessing."""
134
+ # Setup pipeline with mock components
135
+ pipeline = PhishingDetectionPipeline()
136
+ pipeline.model = Mock()
137
+ pipeline.feature_names = [
138
+ 'domain_age_days',
139
+ 'secure_percentage',
140
+ 'has_umbrella_rank',
141
+ 'umbrella_rank',
142
+ 'has_tls',
143
+ 'tls_valid_days',
144
+ 'url_length',
145
+ 'subdomain_count'
146
+ ]
147
+
148
+ # Mock scaler
149
+ mock_scaler = Mock()
150
+ mock_scaler.transform.return_value = np.array([[1.5, 0.8, 5000, 365, 25, 1]])
151
+ pipeline.scaler = mock_scaler
152
+
153
+ # Test features
154
+ features = {
155
+ 'domain_age_days': 3000,
156
+ 'secure_percentage': 95.0,
157
+ 'has_umbrella_rank': 1,
158
+ 'umbrella_rank': 5000,
159
+ 'has_tls': 1,
160
+ 'tls_valid_days': 365,
161
+ 'url_length': 25,
162
+ 'subdomain_count': 1
163
+ }
164
+
165
+ result = pipeline.preprocess_features(features)
166
+
167
+ # Assertions
168
+ assert isinstance(result, pd.DataFrame)
169
+ assert list(result.columns) == pipeline.feature_names
170
+ assert len(result) == 1
171
+ mock_scaler.transform.assert_called_once()
172
+
173
+ def test_preprocess_features_with_missing_features(self):
174
+ """Test preprocessing handles missing features."""
175
+ pipeline = PhishingDetectionPipeline()
176
+ pipeline.model = Mock()
177
+ pipeline.feature_names = [
178
+ 'domain_age_days', 'secure_percentage', 'has_umbrella_rank',
179
+ 'umbrella_rank', 'has_tls', 'tls_valid_days', 'url_length', 'subdomain_count'
180
+ ]
181
+
182
+ mock_scaler = Mock()
183
+ # Mock the scaler to return the same shape as input continuous features (6 features)
184
+ mock_scaler.transform.return_value = np.array([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]])
185
+ pipeline.scaler = mock_scaler
186
+
187
+ # Only provide some features (missing subdomain_count)
188
+ features = {
189
+ 'domain_age_days': 3000,
190
+ 'secure_percentage': 95.0,
191
+ 'has_umbrella_rank': 1,
192
+ 'umbrella_rank': 5000,
193
+ 'has_tls': 1,
194
+ 'tls_valid_days': 365,
195
+ 'url_length': 25
196
+ }
197
+
198
+ result = pipeline.preprocess_features(features)
199
+
200
+ # Should add missing subdomain_count and all features should be present
201
+ assert 'subdomain_count' in result.columns
202
+ assert len(result.columns) == 8 # All 8 features should be present
203
+ assert list(result.columns) == pipeline.feature_names
204
+
205
+ def test_predict_not_loaded(self):
206
+ """Test prediction fails when model not loaded."""
207
+ pipeline = PhishingDetectionPipeline()
208
+ features = {'feature1': 10}
209
+
210
+ with pytest.raises(ValueError) as exc_info:
211
+ pipeline.predict(features)
212
+ assert "Model not loaded" in str(exc_info.value)
213
+
214
+ def test_predict_phishing(self):
215
+ """Test prediction for phishing URL."""
216
+ # Setup pipeline
217
+ pipeline = PhishingDetectionPipeline()
218
+ pipeline.feature_names = [
219
+ 'domain_age_days', 'secure_percentage', 'has_umbrella_rank',
220
+ 'umbrella_rank', 'has_tls', 'tls_valid_days', 'url_length', 'subdomain_count'
221
+ ]
222
+
223
+ # Mock model
224
+ mock_model = Mock()
225
+ mock_model.predict_proba.return_value = np.array([[0.2, 0.8]]) # 80% phishing
226
+ mock_model.predict.return_value = np.array([1]) # Phishing
227
+ pipeline.model = mock_model
228
+
229
+ # Mock scaler
230
+ mock_scaler = Mock()
231
+ mock_scaler.transform.return_value = np.array([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]])
232
+ pipeline.scaler = mock_scaler
233
+
234
+ # Test features
235
+ features = {
236
+ 'domain_age_days': 10,
237
+ 'secure_percentage': 50.0,
238
+ 'has_umbrella_rank': 0,
239
+ 'umbrella_rank': 999999,
240
+ 'has_tls': 0,
241
+ 'tls_valid_days': 0,
242
+ 'url_length': 150,
243
+ 'subdomain_count': 5
244
+ }
245
+
246
+ result = pipeline.predict(features)
247
+
248
+ # Assertions
249
+ assert result['prediction'] == "PHISHING"
250
+ assert result['is_phishing'] is True
251
+ assert result['confidence'] == 0.8
252
+ assert result['phishing_probability'] == 0.8
253
+ assert result['legitimate_probability'] == 0.2
254
+
255
+ def test_predict_legitimate(self):
256
+ """Test prediction for legitimate URL."""
257
+ # Setup pipeline
258
+ pipeline = PhishingDetectionPipeline()
259
+ pipeline.feature_names = [
260
+ 'domain_age_days', 'secure_percentage', 'has_umbrella_rank',
261
+ 'umbrella_rank', 'has_tls', 'tls_valid_days', 'url_length', 'subdomain_count'
262
+ ]
263
+
264
+ # Mock model
265
+ mock_model = Mock()
266
+ mock_model.predict_proba.return_value = np.array([[0.9, 0.1]]) # 90% legitimate
267
+ mock_model.predict.return_value = np.array([0]) # Legitimate
268
+ pipeline.model = mock_model
269
+
270
+ # Mock scaler
271
+ mock_scaler = Mock()
272
+ mock_scaler.transform.return_value = np.array([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]])
273
+ pipeline.scaler = mock_scaler
274
+
275
+ # Test features
276
+ features = {
277
+ 'domain_age_days': 3000,
278
+ 'secure_percentage': 95.0,
279
+ 'has_umbrella_rank': 1,
280
+ 'umbrella_rank': 5000,
281
+ 'has_tls': 1,
282
+ 'tls_valid_days': 365,
283
+ 'url_length': 25,
284
+ 'subdomain_count': 1
285
+ }
286
+
287
+ result = pipeline.predict(features)
288
+
289
+ # Assertions
290
+ assert result['prediction'] == "LEGITIMATE"
291
+ assert result['is_phishing'] is False
292
+ assert result['confidence'] == 0.9
293
+ assert result['phishing_probability'] == 0.1
294
+ assert result['legitimate_probability'] == 0.9
295
+
296
+ def test_predict_url_without_urlscan(self):
297
+ """Test predict_url fails without URLScan client."""
298
+ pipeline = PhishingDetectionPipeline()
299
+ pipeline.model = Mock()
300
+ pipeline.scaler = Mock()
301
+ pipeline.feature_names = ['feature1']
302
+
303
+ with pytest.raises(ValueError) as exc_info:
304
+ pipeline.predict_url("https://example.com")
305
+ assert "URLScan client not initialized" in str(exc_info.value)
306
+
307
+ @patch('src.phising_detection.inference.pipeline.extract_features')
308
+ def test_predict_url_success(self, mock_extract_features):
309
+ """Test successful end-to-end URL prediction."""
310
+ # Setup pipeline
311
+ pipeline = PhishingDetectionPipeline()
312
+ pipeline.feature_names = [
313
+ 'domain_age_days', 'secure_percentage', 'has_umbrella_rank',
314
+ 'umbrella_rank', 'has_tls', 'tls_valid_days', 'url_length', 'subdomain_count'
315
+ ]
316
+
317
+ # Mock model
318
+ mock_model = Mock()
319
+ mock_model.predict_proba.return_value = np.array([[0.7, 0.3]])
320
+ mock_model.predict.return_value = np.array([0])
321
+ pipeline.model = mock_model
322
+
323
+ # Mock scaler
324
+ mock_scaler = Mock()
325
+ mock_scaler.transform.return_value = np.array([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]])
326
+ pipeline.scaler = mock_scaler
327
+
328
+ # Mock URLScan client
329
+ mock_urlscan_client = Mock()
330
+ mock_scan_result = {
331
+ 'task': {'uuid': 'test-uuid-123'},
332
+ 'page': {'domainAgeDays': 3000},
333
+ 'stats': {'securePercentage': 95}
334
+ }
335
+ mock_urlscan_client.submit_and_wait.return_value = mock_scan_result
336
+ pipeline.urlscan_client = mock_urlscan_client
337
+
338
+ # Mock extracted features
339
+ extracted_features = {
340
+ 'domain_age_days': 3000,
341
+ 'secure_percentage': 95.0,
342
+ 'has_umbrella_rank': 1,
343
+ 'umbrella_rank': 5000,
344
+ 'has_tls': 1,
345
+ 'tls_valid_days': 365,
346
+ 'url_length': 25,
347
+ 'subdomain_count': 1
348
+ }
349
+ mock_extract_features.return_value = extracted_features
350
+
351
+ # Test
352
+ result = pipeline.predict_url("https://example.com")
353
+
354
+ # Assertions
355
+ assert result['prediction'] == "LEGITIMATE"
356
+ assert result['confidence'] == 0.7
357
+ assert result['features'] == extracted_features
358
+ assert result['scan_uuid'] == 'test-uuid-123'
359
+ mock_urlscan_client.submit_and_wait.assert_called_once_with("https://example.com")
360
+
361
+ def test_predict_url_scan_fails(self):
362
+ """Test predict_url handles URLScan failure."""
363
+ # Setup pipeline
364
+ pipeline = PhishingDetectionPipeline()
365
+ pipeline.model = Mock()
366
+ pipeline.scaler = Mock()
367
+ pipeline.feature_names = ['feature1']
368
+
369
+ # Mock URLScan client that returns None
370
+ mock_urlscan_client = Mock()
371
+ mock_urlscan_client.submit_and_wait.return_value = None
372
+ pipeline.urlscan_client = mock_urlscan_client
373
+
374
+ # Test
375
+ result = pipeline.predict_url("https://example.com")
376
+
377
+ # Should return error
378
+ assert 'error' in result
379
+ assert result['prediction'] == "ERROR"
380
+ assert result['confidence'] == 0.0
381
+
382
+ @patch('src.phising_detection.inference.pipeline.extract_features')
383
+ def test_predict_url_exception_handling(self, mock_extract_features):
384
+ """Test predict_url handles exceptions gracefully."""
385
+ # Setup pipeline
386
+ pipeline = PhishingDetectionPipeline()
387
+ pipeline.model = Mock()
388
+ pipeline.scaler = Mock()
389
+ pipeline.feature_names = ['feature1']
390
+
391
+ # Mock URLScan client
392
+ mock_urlscan_client = Mock()
393
+ mock_urlscan_client.submit_and_wait.return_value = {'task': {}}
394
+ pipeline.urlscan_client = mock_urlscan_client
395
+
396
+ # Mock extract_features to raise exception
397
+ mock_extract_features.side_effect = Exception("Test error")
398
+
399
+ # Test
400
+ result = pipeline.predict_url("https://example.com")
401
+
402
+ # Should return error
403
+ assert 'error' in result
404
+ assert result['prediction'] == "ERROR"
405
+ assert "Test error" in result['error']
406
+
407
+ def test_predict_numerical_stability(self):
408
+ """Test prediction handles edge cases in probabilities."""
409
+ # Setup pipeline
410
+ pipeline = PhishingDetectionPipeline()
411
+ pipeline.feature_names = [
412
+ 'domain_age_days', 'secure_percentage', 'has_umbrella_rank',
413
+ 'umbrella_rank', 'has_tls', 'tls_valid_days', 'url_length', 'subdomain_count'
414
+ ]
415
+
416
+ # Mock model with extreme probabilities
417
+ mock_model = Mock()
418
+ mock_model.predict_proba.return_value = np.array([[0.999999, 0.000001]])
419
+ mock_model.predict.return_value = np.array([0])
420
+ pipeline.model = mock_model
421
+
422
+ mock_scaler = Mock()
423
+ mock_scaler.transform.return_value = np.array([[1.0, 2.0, 3.0, 4.0, 5.0, 6.0]])
424
+ pipeline.scaler = mock_scaler
425
+
426
+ features = {
427
+ 'domain_age_days': 3000,
428
+ 'secure_percentage': 95.0,
429
+ 'has_umbrella_rank': 1,
430
+ 'umbrella_rank': 5000,
431
+ 'has_tls': 1,
432
+ 'tls_valid_days': 365,
433
+ 'url_length': 25,
434
+ 'subdomain_count': 1
435
+ }
436
+ result = pipeline.predict(features)
437
+
438
+ # Should handle extreme values correctly
439
+ assert result['confidence'] > 0.99
440
+ assert result['prediction'] == "LEGITIMATE"
441
+ assert result['phishing_probability'] < 0.01